Promotions : période de validité (date de début / date de fin)

Une promotion peut désormais porter une date de début et une date de fin,
toutes deux facultatives et indépendantes. Sans date, rien n'est affiché.

- Schéma : `promotions.starts_on` / `ends_on` (`date`), migration 0015.
- `src/lib/promo-validity.ts` (pur, testé) : `formatValidity()` produit
  « Valable du 1er au 15 mars 2027 », « Valable jusqu'au … », « Valable à
  partir du … » en repliant mois et année quand ils se répètent ;
  `formatValidityShort()` pour les lignes de méta ; `isRangeInvalid()` pour
  la validation.
- Espace adhérent : deux champs date, aperçu en direct de la phrase et
  blocage de l'envoi si la fin précède le début.
- `publishPromo` relit et revalide les bornes côté serveur.
- Affichage : carte d'accueil, fiche adhérent, liste de l'adhérent, carte de
  modération — et le message Facebook / LinkedIn, via le même formateur.
- L'ancien texte libre `valid_until` reste comme repli d'affichage pour les
  promotions de démonstration.

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 11:00:44 +00:00
1 parent 81b99ed5fb
commit 02c5378a47
15 files changed
+1700 -6

No files matched your search

+11
View File
@@ -56,6 +56,17 @@ suspension par le staff ne peut être levée que par le staff. Les lectures
publiques filtrent sur `status = 'live'`, donc une promo suspendue disparaît du
site sans traitement supplémentaire.
**Période de validité** : `promotions.starts_on` / `ends_on` (colonnes `date`,
les deux facultatives et indépendantes). `src/lib/promo-validity.ts` est **pur**
et verrouillé par `tests/promo-validity.test.ts` : `formatValidity()` rend
« Valable du 1er au 15 mars 2027 » / « Valable jusqu'au … » / « Valable à partir
du … », `formatValidityShort()` la variante sans « Valable » pour les lignes de
méta, `isRangeInvalid()` garde le formulaire et l'action. Sans date, rien n'est
affiché ; l'ancien texte libre `valid_until` (jamais écrit par l'application)
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.
## Réseaux sociaux
- `src/lib/social.ts` publie une promo sur la page Facebook (Graph API) ou
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE "promotions" ADD COLUMN "starts_on" date;--> statement-breakpoint
ALTER TABLE "promotions" ADD COLUMN "ends_on" date;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -106,6 +106,13 @@
"when": 1788691242685,
"tag": "0014_lean_marvel_zombies",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1788691741621,
"tag": "0015_curious_winter_soldier",
"breakpoints": true
}
]
}
+3 -2
View File
@@ -26,6 +26,7 @@ import {
parseMemberHours,
websiteLabel,
} from "@/lib/member-profile";
import { formatValidity } from "@/lib/promo-validity";
export const dynamic = "force-dynamic";
@@ -437,8 +438,8 @@ export default async function FicheAdherentPage({
<div style={{ padding: "14px 15px 15px" }}>
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 16, margin: "0 0 5px", color: "#26201a" }}>{p.title}</h3>
<p style={{ margin: "0 0 11px", fontSize: 13, color: "#8c8068", lineHeight: 1.5 }}>{p.text}</p>
{p.validUntil && (
<div style={{ borderTop: "1px solid #f0e8d6", paddingTop: 10, fontSize: 12, color: "#a99c82" }}>{p.validUntil}</div>
{formatValidity(p) && (
<div style={{ borderTop: "1px solid #f0e8d6", paddingTop: 10, fontSize: 12, color: "#a99c82" }}>{formatValidity(p)}</div>
)}
</div>
</article>
+18
View File
@@ -43,6 +43,7 @@ import {
selectTarget,
} from "@/lib/social-accounts";
import { normalizeWebsite } from "@/lib/member-profile";
import { isRangeInvalid } from "@/lib/promo-validity";
import { SITE_SETTING_DEFAULTS } from "@/lib/site-settings";
import type { AppRole } from "@/types/next-auth";
@@ -114,6 +115,14 @@ function asImageDataUri(formData: FormData, key: string): string | null {
return value;
}
/** Date `YYYY-MM-DD` facultative issue d'un `<input type="date">`. */
function asOptionalDate(formData: FormData, key: string): string | null {
const value = asString(formData, key);
if (!value) return null;
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) throw new Error("Date invalide.");
return value;
}
function revalidatePromoPaths(memberId?: number | null) {
revalidatePath("/backend/promotions");
revalidatePath("/backend/espace");
@@ -159,6 +168,8 @@ async function publishPromoShares(
text: promotions.text,
badge: promotions.badge,
validUntil: promotions.validUntil,
startsOn: promotions.startsOn,
endsOn: promotions.endsOn,
imageUrl: promotions.imageUrl,
memberId: promotions.memberId,
memberName: members.name,
@@ -371,6 +382,11 @@ export async function publishPromo(formData: FormData) {
const text = String(formData.get("text") ?? "").slice(0, 240);
const category = String(formData.get("category") ?? "");
const badge = String(formData.get("badge") ?? "").trim() || null;
const startsOn = asOptionalDate(formData, "startsOn");
const endsOn = asOptionalDate(formData, "endsOn");
if (isRangeInvalid({ startsOn, endsOn })) {
throw new Error("La date de fin doit être postérieure à la date de début.");
}
const imageUrl = asImageDataUri(formData, "imageUrl");
// Réseaux souhaités : rien n'est publié ici, la diffusion attend la validation.
@@ -382,6 +398,8 @@ export async function publishPromo(formData: FormData) {
imageUrl,
memberId: memberId ?? null,
status: "pending",
startsOn,
endsOn,
...readShareTargets(formData),
});
+48 -1
View File
@@ -7,6 +7,7 @@ import { PromoImage } from "@/components/PromoImage";
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
import type { SocialNetwork } from "@/lib/social";
import type { PromoCategoryGroup } from "@/lib/promo-categories";
import { formatValidity, isRangeInvalid } from "@/lib/promo-validity";
const STRIPE_WARM =
"repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)";
@@ -36,6 +37,8 @@ export function MemberSpaceForm({
const [text, setText] = useState("");
const [cat, setCat] = useState(initialCategory);
const [badge, setBadge] = useState("");
const [startsOn, setStartsOn] = useState("");
const [endsOn, setEndsOn] = useState("");
const [imgData, setImgData] = useState("");
const [shares, setShares] = useState<SocialNetwork[]>([]);
const [submitted, setSubmitted] = useState(false);
@@ -62,6 +65,8 @@ export function MemberSpaceForm({
setTitle("");
setText("");
setBadge("");
setStartsOn("");
setEndsOn("");
setImgData("");
setShares([]);
setSubmitted(false);
@@ -76,6 +81,8 @@ export function MemberSpaceForm({
fd.set("text", text);
fd.set("category", cat);
fd.set("badge", badge);
fd.set("startsOn", startsOn);
fd.set("endsOn", endsOn);
fd.set("imageUrl", imgData);
if (shares.includes("facebook")) fd.set("shareFacebook", "on");
if (shares.includes("linkedin")) fd.set("shareLinkedin", "on");
@@ -89,6 +96,9 @@ export function MemberSpaceForm({
const hasImg = !!imgData;
const hasBadge = !!badge.trim();
const validity = formatValidity({ startsOn, endsOn });
// Bornes incohérentes : on le dit avant l'envoi plutôt qu'après un refus.
const badRange = isRangeInvalid({ startsOn, endsOn });
return (
<div className="grid grid-2" style={{ alignItems: "start" }}>
@@ -169,6 +179,40 @@ export function MemberSpaceForm({
</div>
</div>
<div style={{ marginBottom: 16 }}>
<label className="field-label">Durée de validité · facultatif</label>
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
<div style={{ flex: "1 1 150px" }}>
<input
type="date"
value={startsOn}
onChange={(e) => { setStartsOn(e.target.value); setSubmitted(false); }}
className="field"
aria-label="Début de la promotion"
/>
<div style={{ fontSize: 11.5, color: "#a99c82", marginTop: 4 }}>Début</div>
</div>
<div style={{ flex: "1 1 150px" }}>
<input
type="date"
value={endsOn}
min={startsOn || undefined}
onChange={(e) => { setEndsOn(e.target.value); setSubmitted(false); }}
className="field"
aria-label="Fin de la promotion"
/>
<div style={{ fontSize: 11.5, color: "#a99c82", marginTop: 4 }}>Fin</div>
</div>
</div>
<div style={{ fontSize: 12, color: badRange ? "#d8472b" : "#8c8068", marginTop: 8, lineHeight: 1.5 }}>
{badRange
? "La date de fin est antérieure à la date de début."
: validity
? `Affiché sur le site et dans la publication : « ${validity} »`
: "Laissez vide si votre offre n'a pas de date limite : rien ne sera affiché."}
</div>
</div>
<label className="field-label">Texte de la promotion</label>
<textarea
value={text}
@@ -230,7 +274,7 @@ export function MemberSpaceForm({
<div style={{ display: "flex", gap: 10 }}>
<button
onClick={submit}
disabled={pending}
disabled={pending || badRange}
className="font-display"
style={{ flex: 1, border: "none", background: "#9a6638", color: "#fff", fontWeight: 700, fontSize: 15, padding: 14, borderRadius: 11, cursor: pending ? "default" : "pointer", opacity: pending ? 0.7 : 1 }}
>
@@ -293,6 +337,9 @@ export function MemberSpaceForm({
{text.trim() ||
"Le texte de votre offre apparaîtra ici au fur et à mesure que vous le rédigez."}
</p>
{validity && (
<div style={{ fontSize: 12, color: "#a99c82", marginBottom: 12 }}>{validity}</div>
)}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", borderTop: "1px solid #f0e8d6", paddingTop: 12 }}>
<div style={{ fontSize: 13, fontWeight: 700, color: "#3c3322" }}>{memberName}</div>
<span style={{ color: "#2C6FB3", fontWeight: 700, fontSize: 13 }}>Voir l&apos;offre →</span>
+6 -1
View File
@@ -7,6 +7,7 @@ import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
import { configuredNetworks, SOCIAL_LABELS, type SocialNetwork } from "@/lib/social";
import { PROMO_CATEGORY_GROUPS, defaultPromoCategory } from "@/lib/promo-categories";
import { memberPath } from "@/lib/seo";
import { formatValidityShort } from "@/lib/promo-validity";
import { setOwnPromoShareTargets, setOwnPromoSuspension } from "../../actions";
import { EspaceHeader } from "../EspaceHeader";
import { MemberSpaceForm } from "../MemberSpaceForm";
@@ -28,6 +29,8 @@ type MyPromo = {
category: string | null;
badge: string | null;
validUntil: string | null;
startsOn: string | null;
endsOn: string | null;
createdAt: Date;
imageUrl: string | null;
suspendedBy: "member" | "staff" | null;
@@ -79,6 +82,8 @@ export default async function EspacePromotionsPage() {
category: promotions.category,
badge: promotions.badge,
validUntil: promotions.validUntil,
startsOn: promotions.startsOn,
endsOn: promotions.endsOn,
createdAt: promotions.createdAt,
imageUrl: promotions.imageUrl,
suspendedBy: promotions.suspendedBy,
@@ -237,7 +242,7 @@ function PromoSection({
function PromoRow({ promo: mp, networks, lastShare }: { promo: MyPromo; networks: SocialNetwork[]; lastShare: ShareState }) {
const st = PROMO_STATUS[mp.status] ?? PROMO_STATUS.expired;
const suspendedByStaff = mp.status === "suspended" && mp.suspendedBy === "staff";
const meta = [mp.category, mp.badge, mp.validUntil].filter(Boolean).join(" · ");
const meta = [mp.category, mp.badge, formatValidityShort(mp)].filter(Boolean).join(" · ");
return (
<div style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 12, padding: "12px 14px", marginBottom: 10 }}>
<div style={{ display: "flex", alignItems: "center", gap: 13, flexWrap: "wrap" }}>
+12
View File
@@ -8,6 +8,7 @@ import { configuredNetworks, SOCIAL_LABELS } from "@/lib/social";
import { moderatePromo, retryPromoShare } from "../actions";
import { PromoImage } from "@/components/PromoImage";
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
import { formatValidity } from "@/lib/promo-validity";
export const dynamic = "force-dynamic";
@@ -41,6 +42,9 @@ export default async function PromotionsPage() {
text: promotions.text,
category: promotions.category,
status: promotions.status,
validUntil: promotions.validUntil,
startsOn: promotions.startsOn,
endsOn: promotions.endsOn,
imageUrl: promotions.imageUrl,
suspendedBy: promotions.suspendedBy,
suspendedAt: promotions.suspendedAt,
@@ -176,6 +180,14 @@ export default async function PromotionsPage() {
{p.text}
</p>
{/* Le modérateur voit la période de validité avant de valider :
c'est elle qui partira dans le message réseaux sociaux. */}
{formatValidity(p) && (
<div style={{ fontSize: 12, color: "#9a8d72", fontWeight: 600, margin: "0 0 12px" }}>
{formatValidity(p)}
</div>
)}
{isSuspended && (
<div style={{ background: "#fbe9e6", border: "1px solid #f2d5cf", color: "#a8503c", borderRadius: 10, padding: "9px 11px", fontSize: 12, lineHeight: 1.45, marginBottom: 11 }}>
Suspendue par {p.suspendedBy === "member" ? "l'adhérent" : "l'association"}
+4 -1
View File
@@ -11,6 +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 { formatValidity } from "@/lib/promo-validity";
import { SITE_DESCRIPTION, SITE_TITLE, memberPath, pageMetadata } from "@/lib/seo";
export const dynamic = "force-dynamic";
@@ -315,7 +316,9 @@ export default async function AccueilPage() {
}}
>
<div style={{ fontSize: 13, fontWeight: 700, color: "#3c3322" }}>{p.memberName}</div>
<div style={{ fontSize: 11.5, color: "#a99c82" }}>{p.validUntil}</div>
{formatValidity(p) && (
<div style={{ fontSize: 11.5, color: "#a99c82" }}>{formatValidity(p)}</div>
)}
</div>
</div>
</Link>
+6
View File
@@ -6,6 +6,7 @@ import {
text,
varchar,
boolean,
date,
timestamp,
index,
uniqueIndex,
@@ -107,6 +108,11 @@ export const promotions = pgTable("promotions", {
imageUrl: text("image_url"),
memberId: integer("member_id").references(() => members.id),
status: promoStatusEnum("status").notNull().default("pending"),
// Période de validité. Les deux bornes sont facultatives et indépendantes :
// une offre peut n'avoir qu'une date de fin, qu'une date de début, ou aucune.
startsOn: date("starts_on"),
endsOn: date("ends_on"),
/** @deprecated Texte libre des anciennes promos ; sert de repli à l'affichage. */
validUntil: varchar("valid_until", { length: 120 }),
// Réseaux demandés à la soumission. Le choix reste modifiable tant que la
// promo est « pending » ; la validation le fige ET déclenche la publication.
+90
View File
@@ -0,0 +1,90 @@
/**
* Période de validité d'une promotion.
*
* Module **pur** : aucune dépendance à la base ni à React, pour être verrouillé
* par les tests. Les dates arrivent en `YYYY-MM-DD` (colonnes `date` de
* Postgres, lues telles quelles par Drizzle) et sont donc traitées comme des
* jours calendaires, sans fuseau horaire.
*/
export type PromoValidity = {
startsOn?: string | null;
endsOn?: string | null;
/** Texte libre des anciennes promotions, utilisé à défaut de dates. */
validUntil?: string | null;
};
const MONTHS = [
"janvier", "février", "mars", "avril", "mai", "juin",
"juillet", "août", "septembre", "octobre", "novembre", "décembre",
];
type Parts = { day: number; month: number; year: number };
function parse(value: string | null | undefined): Parts | null {
if (!value) return null;
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(value.trim());
if (!match) return null;
const [, year, month, day] = match;
const parts = { day: Number(day), month: Number(month) - 1, year: Number(year) };
if (parts.month < 0 || parts.month > 11 || parts.day < 1 || parts.day > 31) return null;
return parts;
}
/** « 1er » et non « 1 », comme on l'écrit en français. */
function dayLabel(day: number): string {
return day === 1 ? "1er" : String(day);
}
function full(p: Parts): string {
return `${dayLabel(p.day)} ${MONTHS[p.month]} ${p.year}`;
}
/**
* Phrase de validité, ou `null` si la promotion n'en a pas.
* L'année et le mois ne sont répétés que lorsqu'ils changent : « du 1er au
* 15 mars 2027 » plutôt que « du 1er mars 2027 au 15 mars 2027 ».
*/
export function formatValidity(promo: PromoValidity): string | null {
const start = parse(promo.startsOn);
const end = parse(promo.endsOn);
if (start && end) {
const sameYear = start.year === end.year;
const sameMonth = sameYear && start.month === end.month;
const from = sameMonth
? dayLabel(start.day)
: sameYear
? `${dayLabel(start.day)} ${MONTHS[start.month]}`
: full(start);
return `Valable du ${from} au ${full(end)}`;
}
if (end) return `Valable jusqu'au ${full(end)}`;
if (start) return `Valable à partir du ${full(start)}`;
const legacy = promo.validUntil?.trim();
return legacy || null;
}
/** Variante sans le mot « Valable », pour les libellés déjà introduits. */
export function formatValidityShort(promo: PromoValidity): string | null {
const formatted = formatValidity(promo);
if (!formatted) return null;
return formatted.replace(/^Valable /, "");
}
/** `true` si la date de fin est dépassée. Un jour de fin reste valable en entier. */
export function isExpired(promo: PromoValidity, today = new Date()): boolean {
const end = parse(promo.endsOn);
if (!end) return false;
const endOfDay = Date.UTC(end.year, end.month, end.day, 23, 59, 59);
return today.getTime() > endOfDay;
}
/** Bornes invalides : fin antérieure au début. */
export function isRangeInvalid(promo: PromoValidity): boolean {
const start = parse(promo.startsOn);
const end = parse(promo.endsOn);
if (!start || !end) return false;
return Date.UTC(end.year, end.month, end.day) < Date.UTC(start.year, start.month, start.day);
}
+4
View File
@@ -67,6 +67,8 @@ export async function getLivePromotions(limit = 6) {
badge: promotions.badge,
imageUrl: promotions.imageUrl,
validUntil: promotions.validUntil,
startsOn: promotions.startsOn,
endsOn: promotions.endsOn,
memberId: promotions.memberId,
memberName: members.name,
memberCity: members.city,
@@ -119,6 +121,8 @@ export async function getMemberLivePromotions(memberId: number) {
badge: promotions.badge,
imageUrl: promotions.imageUrl,
validUntil: promotions.validUntil,
startsOn: promotions.startsOn,
endsOn: promotions.endsOn,
})
.from(promotions)
.where(and(eq(promotions.memberId, memberId), eq(promotions.status, "live")))
+6 -1
View File
@@ -15,6 +15,7 @@ import {
publicBaseUrl,
type SocialNetwork,
} from "./social-accounts";
import { formatValidity } from "./promo-validity";
import { memberPath } from "./seo";
export {
@@ -35,6 +36,8 @@ export type PromoForSharing = {
text: string | null;
badge: string | null;
validUntil: string | null;
startsOn?: string | null;
endsOn?: string | null;
imageUrl: string | null;
memberId: number | null;
memberName: string | null;
@@ -55,7 +58,9 @@ export async function buildPromoMessage(promo: PromoForSharing): Promise<string>
lines.push(promo.badge ? `${promo.badge} — ${promo.title}` : promo.title);
if (promo.memberName) lines.push(`Chez ${promo.memberName}`);
if (promo.text?.trim()) lines.push("", promo.text.trim());
if (promo.validUntil?.trim()) lines.push("", promo.validUntil.trim());
// « Valable du … au … » : la même phrase que sur la carte du site.
const validity = formatValidity(promo);
if (validity) lines.push("", validity);
const link = await promoLink(promo);
if (link) lines.push("", link);
lines.push("", "#PleinR #BassinDePompey #CommerceLocal");
+76
View File
@@ -0,0 +1,76 @@
import { strict as assert } from "node:assert";
import { describe, it } from "node:test";
import {
formatValidity,
formatValidityShort,
isExpired,
isRangeInvalid,
} from "../src/lib/promo-validity";
describe("Période de validité d'une promotion", () => {
it("affiche les deux bornes", () => {
assert.equal(
formatValidity({ startsOn: "2027-03-01", endsOn: "2027-03-15" }),
"Valable du 1er au 15 mars 2027"
);
});
it("ne répète pas l'année quand elle est identique", () => {
assert.equal(
formatValidity({ startsOn: "2027-03-01", endsOn: "2027-04-10" }),
"Valable du 1er mars au 10 avril 2027"
);
});
it("écrit les deux années quand elles diffèrent", () => {
assert.equal(
formatValidity({ startsOn: "2027-12-20", endsOn: "2028-01-05" }),
"Valable du 20 décembre 2027 au 5 janvier 2028"
);
});
it("gère une seule borne", () => {
assert.equal(formatValidity({ endsOn: "2027-06-30" }), "Valable jusqu'au 30 juin 2027");
assert.equal(
formatValidity({ startsOn: "2027-06-01" }),
"Valable à partir du 1er juin 2027"
);
});
it("ne renvoie rien sans date ni texte : la carte n'affiche alors aucune mention", () => {
assert.equal(formatValidity({}), null);
assert.equal(formatValidity({ startsOn: null, endsOn: null, validUntil: " " }), null);
});
it("retombe sur l'ancien texte libre des promotions existantes", () => {
assert.equal(formatValidity({ validUntil: "Tout l'été" }), "Tout l'été");
// Les dates priment sur le texte libre.
assert.equal(
formatValidity({ endsOn: "2027-07-31", validUntil: "Tout l'été" }),
"Valable jusqu'au 31 juillet 2027"
);
});
it("ignore une date illisible", () => {
assert.equal(formatValidity({ endsOn: "pas-une-date" }), null);
assert.equal(formatValidity({ endsOn: "2027-13-45" }), null);
});
it("propose une variante sans le mot « Valable »", () => {
assert.equal(formatValidityShort({ endsOn: "2027-06-30" }), "jusqu'au 30 juin 2027");
});
it("considère le dernier jour comme encore valable", () => {
const end = { endsOn: "2027-06-30" };
assert.equal(isExpired(end, new Date("2027-06-30T23:00:00Z")), false);
assert.equal(isExpired(end, new Date("2027-07-01T12:00:00Z")), true);
assert.equal(isExpired({}, new Date("2099-01-01T00:00:00Z")), false);
});
it("détecte une fin antérieure au début", () => {
assert.equal(isRangeInvalid({ startsOn: "2027-06-10", endsOn: "2027-06-01" }), true);
assert.equal(isRangeInvalid({ startsOn: "2027-06-01", endsOn: "2027-06-01" }), false);
assert.equal(isRangeInvalid({ startsOn: "2027-06-01" }), false);
});
});