mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-12 01:38:04 +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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
|
||||
@@ -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'offre →</span>
|
||||
|
||||
@@ -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" }}>
|
||||
|
||||
@@ -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
@@ -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,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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user