mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-15 19:26:21 +02:00
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:
15 files changed
+1700
-6
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user