mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-24 07:38:04 +02:00
Promotions : publication programmée et affichage limité à la période
Deux comportements demandés : 1. Une promotion n'apparaît sur le site que pendant sa période de validité. Les lectures publiques passent par `VISIBLE_PROMO` (`src/lib/queries.ts`) : statut `live` **et** fenêtre `starts_on` / `ends_on`, journée calculée en Europe/Paris pour qu'un conteneur en UTC ne retire pas une offre deux heures trop tôt. Le statut n'est pas touché : hors période, le backoffice explique pourquoi l'offre n'est pas visible (`visibilityNote`) et elle revient d'elle-même si les dates changent. 2. La publication peut être programmée. `promotions.publish_at` : l'adhérent propose une date, le modérateur la garde, la déplace ou la vide dans le formulaire « Valider ». Avec une échéance future la promotion passe au statut `scheduled` — rien n'est visible, rien n'est diffusé — et `releaseDuePromotions()` fait la bascule à l'heure dite, site et réseaux ensemble. Un bouton « Publier maintenant » court-circuite l'attente. Mécanique : - `src/lib/promo-schedule.ts` (pur, testé) : conversion `datetime-local` ⇄ heure de l'association, changements d'heure compris, et refus d'une saisie illisible plutôt qu'une publication immédiate involontaire. - `src/lib/promo-publish.ts` reprend `publishPromoShares()` : deux appelants désormais, une action serveur et la boucle de libération, qui n'a pas de requête et ne peut donc pas appeler `revalidatePath()`. Idem pour `logActivity()` dans `src/lib/activity-log.ts`. - La bascule `scheduled → live` est un `UPDATE … RETURNING` filtré sur le statut : atomique, donc pas de double publication même à plusieurs instances, en plus de la garde existante sur `social_posts`. - Déclencheur : `src/instrumentation.ts`, boucle d'une minute avec rattrapage au démarrage (`PROMO_SCHEDULER=off` la désactive). Le travail vit dans `instrumentation-node.ts`, écarté du bundle edge par `next.config.mjs` : Next compile aussi l'instrumentation pour le middleware, où `pg` ne peut pas être embarqué. Migration 0016 : `promo_status += 'scheduled'`, `promotions.publish_at`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
This commit is contained in:
20 files changed
+2134
-121
No files matched your search
@@ -8,6 +8,7 @@ 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";
|
||||
import { formatSchedule, parseScheduleInput } from "@/lib/promo-schedule";
|
||||
|
||||
const STRIPE_WARM =
|
||||
"repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)";
|
||||
@@ -39,6 +40,7 @@ export function MemberSpaceForm({
|
||||
const [badge, setBadge] = useState("");
|
||||
const [startsOn, setStartsOn] = useState("");
|
||||
const [endsOn, setEndsOn] = useState("");
|
||||
const [publishAt, setPublishAt] = useState("");
|
||||
const [imgData, setImgData] = useState("");
|
||||
const [shares, setShares] = useState<SocialNetwork[]>([]);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
@@ -67,6 +69,7 @@ export function MemberSpaceForm({
|
||||
setBadge("");
|
||||
setStartsOn("");
|
||||
setEndsOn("");
|
||||
setPublishAt("");
|
||||
setImgData("");
|
||||
setShares([]);
|
||||
setSubmitted(false);
|
||||
@@ -83,6 +86,7 @@ export function MemberSpaceForm({
|
||||
fd.set("badge", badge);
|
||||
fd.set("startsOn", startsOn);
|
||||
fd.set("endsOn", endsOn);
|
||||
fd.set("publishAt", publishAt);
|
||||
fd.set("imageUrl", imgData);
|
||||
if (shares.includes("facebook")) fd.set("shareFacebook", "on");
|
||||
if (shares.includes("linkedin")) fd.set("shareLinkedin", "on");
|
||||
@@ -99,6 +103,14 @@ export function MemberSpaceForm({
|
||||
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 });
|
||||
// Aperçu de la programmation. Le champ donne une heure locale : on la relit
|
||||
// avec le même formateur que le backoffice pour éviter toute surprise.
|
||||
let scheduleHint: string | null = null;
|
||||
try {
|
||||
scheduleHint = formatSchedule(parseScheduleInput(publishAt));
|
||||
} catch {
|
||||
scheduleHint = null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-2" style={{ alignItems: "start" }}>
|
||||
@@ -208,8 +220,24 @@ export function MemberSpaceForm({
|
||||
{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é."}
|
||||
? `Affiché sur le site et dans la publication : « ${validity} ». L'offre n'apparaît que pendant cette période.`
|
||||
: "Laissez vide si votre offre n'a pas de date limite : elle restera affichée sans échéance."}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label className="field-label">Programmer la publication · facultatif</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={publishAt}
|
||||
onChange={(e) => { setPublishAt(e.target.value); setSubmitted(false); }}
|
||||
className="field"
|
||||
aria-label="Date et heure de publication souhaitées"
|
||||
/>
|
||||
<div style={{ fontSize: 12, color: "#8c8068", marginTop: 8, lineHeight: 1.5 }}>
|
||||
{scheduleHint
|
||||
? `Mise en ligne demandée ${scheduleHint} — site et réseaux en même temps. L'association reste libre de la déplacer.`
|
||||
: "Laissez vide pour une mise en ligne dès la validation par l'association."}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in new issue
Block a user