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.

- + Toutes les promos → - + {promos.map((p) => ( - -
- {p.imageUrl && } - {!p.imageUrl && ( - - photo de l'offre - - )} - {p.badge && ( - - {p.badge} - - )} - {p.category && ( - - {p.category} - - )} -
-
-

- {p.title} -

-

- {p.text} -

-
- {/* 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 ( + +
+ {p.imageUrl && } + {!p.imageUrl && ( + + photo de l'offre + + )} + {p.badge && ( + + {p.badge} + + )} + {p.category && ( + + {p.category} + + )} +
+
+

+ {p.title} +

+

+ {p.text} +

+
+ {/* Le logo du commerce juste à gauche de son nom. */} +
+ +
+
{p.memberName}
+ {validity &&
{validity}
} +
+
+
+
+ + ); +} diff --git a/src/components/SiteFooter.tsx b/src/components/SiteFooter.tsx index 09a79e6..f993370 100644 --- a/src/components/SiteFooter.tsx +++ b/src/components/SiteFooter.tsx @@ -32,7 +32,7 @@ export async function SiteFooter() {
Accès rapide
Annuaire - Promotions + Promotions L'association Rencontres passées Espace adhérent diff --git a/src/components/SiteHeader.tsx b/src/components/SiteHeader.tsx index e05681b..d20415a 100644 --- a/src/components/SiteHeader.tsx +++ b/src/components/SiteHeader.tsx @@ -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" }, ]; diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 580cc06..a9418ae 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -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) { diff --git a/src/lib/seo.ts b/src/lib/seo.ts index 824a22e..b1ceb09 100644 --- a/src/lib/seo.ts +++ b/src/lib/seo.ts @@ -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" }, diff --git a/src/lib/social.ts b/src/lib/social.ts index 6449290..db06f71 100644 --- a/src/lib/social.ts +++ b/src/lib/social.ts @@ -49,7 +49,7 @@ export async function promoLink(promo: PromoForSharing): Promise 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. */