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

+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");