Promotions : page publique dédiée, l'accueil garde les six dernières

`/promotions` liste toutes les offres en cours ; l'accueil en montre six et
renvoie vers elle. Les deux listes partagent `PromoCard`, extrait de la page
d'accueil : un seul rendu de carte à maintenir.

- `getLivePromotions(limit)` accepte `null` pour « toutes les offres », la
  limite restant appliquée en SQL et non après coup.
- Page complète : `<h1>` unique, `<main>`, métadonnées et canonique via
  `pageMetadata`, JSON-LD `BreadcrumbList` + `ItemList`, entrée au sitemap,
  état vide qui renvoie vers l'annuaire.
- Les liens « Promotions » de l'en-tête, du pied de page et de l'espace
  adhérent visent désormais cette page. Idem pour le lien d'une publication
  réseau sans adhérent rattaché, qui pointait sur l'ancre `/#promotions`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
This commit is contained in:
Claude committed 2026-09-06 14:34:27 +00:00
1 parent cf8cf5c8e2
commit a52e13e623
10 files changed
+294 -116

No files matched your search

+8
View File
@@ -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
+1 -1
View File
@@ -201,7 +201,7 @@ export default async function EspacePromotionsPage() {
{myPromos.length > 0 && (
<p style={{ fontSize: 13, color: "#8c8068", marginTop: 18 }}>
Les offres en ligne apparaissent sur <Link href="/#promotions" style={{ color: "#9a6638", fontWeight: 700 }}>la page d&apos;accueil</Link>
Les offres en ligne apparaissent sur <Link href="/promotions" style={{ color: "#9a6638", fontWeight: 700 }}>la page des promotions</Link>
{publicPath ? (
<>
{" "}et sur <Link href={publicPath} style={{ color: "#9a6638", fontWeight: 700 }}>votre fiche</Link>
+5 -108
View File
@@ -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.
</p>
</div>
<a href="#promotions" className="link-arrow" style={{ textDecoration: "none", color: "#9a6638", fontWeight: 700, fontSize: 14.5, whiteSpace: "nowrap" }}>
<Link href="/promotions" className="link-arrow" style={{ textDecoration: "none", color: "#9a6638", fontWeight: 700, fontSize: 14.5, whiteSpace: "nowrap" }}>
Toutes les promos →
</a>
</Link>
</div>
<Reveal className="grid grid-3 reveal-stagger" style={{ marginTop: 26 }}>
{promos.map((p) => (
<Link
key={p.id}
href={p.memberId ? memberPath({ id: p.memberId, name: p.memberName ?? "", city: p.memberCity }) : "#"}
className="lift-card"
style={{
textDecoration: "none",
color: "inherit",
background: "#fff",
border: "1px solid #ece3d0",
borderRadius: 18,
overflow: "hidden",
display: "flex",
flexDirection: "column",
}}
>
<div
style={{
position: "relative",
aspectRatio: "1 / 1",
background: STRIPE_WARM,
overflow: "hidden",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{p.imageUrl && <PromoImage src={p.imageUrl} alt={p.title ?? ""} />}
{!p.imageUrl && (
<span style={{ fontSize: 10.5, letterSpacing: "0.12em", color: "#a99c82", textTransform: "uppercase" }}>
photo de l&apos;offre
</span>
)}
{p.badge && (
<span
className="font-display"
style={{
position: "absolute",
top: 0,
right: 0,
background: badgeColor(p.badge),
color: "#fff",
fontWeight: 800,
fontSize: 18,
padding: "8px 16px",
borderRadius: "0 0 0 16px",
}}
>
{p.badge}
</span>
)}
{p.category && (
<span
style={{
position: "absolute",
top: 12,
left: 12,
background: "#9a6638",
color: "#fff",
borderRadius: 999,
padding: "5px 12px",
fontSize: 11,
fontWeight: 700,
}}
>
{p.category}
</span>
)}
</div>
<div style={{ padding: "17px 18px 18px", display: "flex", flexDirection: "column", flex: 1 }}>
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 18, margin: "0 0 5px", color: "#26201a" }}>
{p.title}
</h3>
<p style={{ margin: "0 0 14px", fontSize: 13.5, color: "#8c8068", lineHeight: 1.5, flex: 1 }}>
{p.text}
</p>
<div
style={{
borderTop: "1px solid #f0e8d6",
paddingTop: 12,
}}
>
{/* Le logo du commerce juste à gauche de son nom. */}
<div style={{ display: "flex", alignItems: "center", gap: 9 }}>
<MemberAvatar name={p.memberName ?? ""} logoUrl={p.memberLogoUrl} size={28} />
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 700, color: "#3c3322" }}>{p.memberName}</div>
{formatValidity(p) && (
<div style={{ fontSize: 11.5, color: "#a99c82" }}>{formatValidity(p)}</div>
)}
</div>
</div>
</div>
</div>
</Link>
<PromoCard key={p.id} promo={p} />
))}
</Reveal>
</section>
+135
View File
@@ -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 (
<div style={{ background: "#F6F2E8", minHeight: "100vh", fontFamily: "'Public Sans',sans-serif", color: "#33291D" }}>
<SiteHeader active="promotions" />
<JsonLd
data={[
breadcrumbJsonLd(baseUrl, [
{ name: "Accueil", path: "/" },
{ name: "Promotions", path: "/promotions" },
]),
memberListJsonLd(baseUrl, "Promotions en cours chez les adhérents Plein R", listed),
]}
/>
<main className="container">
<section
style={{
position: "relative",
margin: "30px 0 10px",
background: "#fff",
border: "1px solid #e6dcc6",
borderRadius: 24,
padding: "38px 36px 42px",
overflow: "hidden",
boxShadow: "0 26px 60px -40px rgba(40,30,15,0.5)",
}}
>
<Sparkle color="#E0A63C" size={20} style={{ top: 28, right: 44 }} duration={3.4} />
<Sparkle color="#6FB0C6" size={13} style={{ top: 64, right: 96 }} duration={2.7} delay={0.5} />
<div
style={{
display: "inline-flex",
alignItems: "center",
gap: 8,
background: "#fdeccd",
color: "#9a6638",
fontSize: 12,
letterSpacing: "0.12em",
textTransform: "uppercase",
fontWeight: 800,
padding: "6px 13px",
borderRadius: 999,
marginBottom: 12,
}}
>
Bons plans
</div>
<h1
className="font-display"
style={{ fontWeight: 800, fontSize: "clamp(24px, 5vw, 34px)", letterSpacing: "-0.02em", margin: 0, color: "#26201a" }}
>
Les promotions de nos adhérents
</h1>
<p style={{ margin: "8px 0 0", color: "#8c8068", fontSize: 15, maxWidth: 640, lineHeight: 1.6 }}>
Toutes les offres en cours chez les commerçants, artisans et entreprises du réseau.
Chaque promotion renvoie vers la fiche de son commerce.
</p>
{promos.length === 0 ? (
<div
style={{
marginTop: 26,
border: "1px dashed #d8cdb4",
borderRadius: 16,
padding: "34px 20px",
textAlign: "center",
color: "#8c8068",
fontSize: 14.5,
lineHeight: 1.6,
}}
>
Aucune promotion en cours pour le moment.
<br />
<Link href="/annuaire" style={{ color: "#9a6638", fontWeight: 700 }}>
Parcourir l&apos;annuaire des adhérents →
</Link>
</div>
) : (
<>
<div style={{ marginTop: 22, fontSize: 13, color: "#a99c82", fontWeight: 600 }}>
{promos.length} offre{promos.length > 1 ? "s" : ""} en cours
</div>
<div className="grid grid-3" style={{ marginTop: 14 }}>
{promos.map((p) => (
<PromoCard key={p.id} promo={p} />
))}
</div>
</>
)}
</section>
<p style={{ margin: "22px 0 40px", fontSize: 14, color: "#8c8068" }}>
Vous êtes commerçant du Bassin de Pompey ?{" "}
<Link href="/association" style={{ color: "#9a6638", fontWeight: 700 }}>
Rejoignez Plein R
</Link>{" "}
pour publier vos offres ici.
</p>
</main>
<SiteFooter />
</div>
);
}
+129
View File
@@ -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 (
<Link
href={p.memberId ? memberPath({ id: p.memberId, name: p.memberName ?? "", city: p.memberCity }) : "#"}
className="lift-card"
style={{
textDecoration: "none",
color: "inherit",
background: "#fff",
border: "1px solid #ece3d0",
borderRadius: 18,
overflow: "hidden",
display: "flex",
flexDirection: "column",
}}
>
<div
style={{
position: "relative",
aspectRatio: "1 / 1",
background: STRIPE_WARM,
overflow: "hidden",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{p.imageUrl && <PromoImage src={p.imageUrl} alt={p.title ?? ""} />}
{!p.imageUrl && (
<span style={{ fontSize: 10.5, letterSpacing: "0.12em", color: "#a99c82", textTransform: "uppercase" }}>
photo de l&apos;offre
</span>
)}
{p.badge && (
<span
className="font-display"
style={{
position: "absolute",
top: 0,
right: 0,
background: badgeColor(p.badge),
color: "#fff",
fontWeight: 800,
fontSize: 18,
padding: "8px 16px",
borderRadius: "0 0 0 16px",
}}
>
{p.badge}
</span>
)}
{p.category && (
<span
style={{
position: "absolute",
top: 12,
left: 12,
background: "#9a6638",
color: "#fff",
borderRadius: 999,
padding: "5px 12px",
fontSize: 11,
fontWeight: 700,
}}
>
{p.category}
</span>
)}
</div>
<div style={{ padding: "17px 18px 18px", display: "flex", flexDirection: "column", flex: 1 }}>
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 18, margin: "0 0 5px", color: "#26201a" }}>
{p.title}
</h3>
<p style={{ margin: "0 0 14px", fontSize: 13.5, color: "#8c8068", lineHeight: 1.5, flex: 1 }}>
{p.text}
</p>
<div style={{ borderTop: "1px solid #f0e8d6", paddingTop: 12 }}>
{/* Le logo du commerce juste à gauche de son nom. */}
<div style={{ display: "flex", alignItems: "center", gap: 9 }}>
<MemberAvatar name={p.memberName ?? ""} logoUrl={p.memberLogoUrl} size={28} />
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 700, color: "#3c3322" }}>{p.memberName}</div>
{validity && <div style={{ fontSize: 11.5, color: "#a99c82" }}>{validity}</div>}
</div>
</div>
</div>
</div>
</Link>
);
}
+1 -1
View File
@@ -32,7 +32,7 @@ export async function SiteFooter() {
<div className="footer-title">Accès rapide</div>
<div className="footer-links">
<Link href="/annuaire">Annuaire</Link>
<Link href="/#promotions">Promotions</Link>
<Link href="/promotions">Promotions</Link>
<Link href="/association">L&apos;association</Link>
<Link href="/rencontres-passees">Rencontres passées</Link>
<Link href="/login">Espace adhérent</Link>
+1 -1
View File
@@ -3,7 +3,7 @@ import Link from "next/link";
const NAV = [
{ key: "accueil", label: "Accueil", href: "/" },
{ key: "annuaire", label: "Annuaire", href: "/annuaire" },
{ key: "promotions", label: "Promotions", href: "/#promotions" },
{ key: "promotions", label: "Promotions", href: "/promotions" },
{ key: "association", label: "L'association", href: "/association" },
];
+12 -4
View File
@@ -72,8 +72,14 @@ export async function getCategoriesWithCounts(limit = 6) {
return rows;
}
export async function getLivePromotions(limit = 6) {
const rows = await db
/**
* Promotions visibles, de la plus récente à la plus ancienne.
*
* `limit` à `null` : toutes les offres en cours (page `/promotions`) ; sinon
* les N dernières (accueil).
*/
export async function getLivePromotions(limit: number | null = 6) {
const query = db
.select({
id: promotions.id,
title: promotions.title,
@@ -93,8 +99,10 @@ export async function getLivePromotions(limit = 6) {
.leftJoin(members, eq(promotions.memberId, members.id))
.where(VISIBLE_PROMO)
.orderBy(desc(promotions.createdAt))
.limit(limit);
return rows;
.$dynamic();
// La limite reste dans le SQL : la page « toutes les promos » est la seule à
// tout demander, l'accueil ne rapatrie que ses six cartes.
return limit === null ? query : query.limit(limit);
}
export async function getPublicMember(id: number) {
+1
View File
@@ -71,6 +71,7 @@ export const PRIVATE_PATHS = ["/backend", "/api", "/login", "/inscription"];
export const STATIC_ROUTES: { path: string; priority: number; changeFrequency: "daily" | "weekly" | "monthly" | "yearly" }[] = [
{ path: "/", priority: 1, changeFrequency: "daily" },
{ path: "/annuaire", priority: 0.9, changeFrequency: "daily" },
{ path: "/promotions", priority: 0.9, changeFrequency: "daily" },
{ path: "/association", priority: 0.8, changeFrequency: "weekly" },
{ path: "/rencontres-passees", priority: 0.6, changeFrequency: "monthly" },
{ path: "/mentions-legales", priority: 0.2, changeFrequency: "yearly" },
+1 -1
View File
@@ -49,7 +49,7 @@ export async function promoLink(promo: PromoForSharing): Promise<string | null>
if (!base) return null;
return promo.memberId
? `${base}${memberPath({ id: promo.memberId, name: promo.memberName ?? "", city: promo.memberCity })}`
: `${base}/#promotions`;
: `${base}/promotions`;
}
/** Texte du post, commun aux deux réseaux. */