mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-22 14:48: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
+37
-102
@@ -13,7 +13,6 @@ import { CATEGORY_PALETTE } from "@/db/categories";
|
||||
import { slugify } from "@/lib/slug";
|
||||
import { autoTags } from "@/lib/tags";
|
||||
import {
|
||||
activityLog,
|
||||
categories,
|
||||
contactMessages,
|
||||
imageConsents,
|
||||
@@ -25,14 +24,10 @@ import {
|
||||
pastMeetings,
|
||||
promotions,
|
||||
siteSettings,
|
||||
socialPosts,
|
||||
users,
|
||||
} from "@/db/schema";
|
||||
import { sanitizeActivityMessage } from "@/lib/activity";
|
||||
import { can, LABEL_TO_ROLE } from "@/lib/rbac";
|
||||
import {
|
||||
isNetworkConfigured,
|
||||
publishPromoToNetwork,
|
||||
SOCIAL_LABELS,
|
||||
SOCIAL_NETWORKS,
|
||||
type SocialNetwork,
|
||||
@@ -44,16 +39,14 @@ import {
|
||||
} from "@/lib/social-accounts";
|
||||
import { normalizeWebsite } from "@/lib/member-profile";
|
||||
import { isRangeInvalid } from "@/lib/promo-validity";
|
||||
import { formatSchedule, isDue, parseScheduleInput } from "@/lib/promo-schedule";
|
||||
import { publishPromoShares, requestedNetworks } from "@/lib/promo-publish";
|
||||
import { logActivity } from "@/lib/activity-log";
|
||||
import { SITE_SETTING_DEFAULTS } from "@/lib/site-settings";
|
||||
import type { AppRole } from "@/types/next-auth";
|
||||
|
||||
|
||||
|
||||
async function logActivity(message: string, dot = "#2C6FB3") {
|
||||
// Le message agrège des saisies de tiers : on ne laisse passer que <strong>.
|
||||
await db.insert(activityLog).values({ message: sanitizeActivityMessage(message), dot });
|
||||
}
|
||||
|
||||
// Mot de passe temporaire lisible (sans caractères ambigus).
|
||||
// `randomInt` et non `Math.random()` : ce dernier n'est pas cryptographique et
|
||||
// permet de prédire les mots de passe suivants à partir de quelques tirages.
|
||||
@@ -133,13 +126,6 @@ function revalidatePromoPaths(memberId?: number | null) {
|
||||
if (memberId) revalidatePath("/adherents/[id]", "page");
|
||||
}
|
||||
|
||||
/** Réseaux demandés pour une promo, dans l'ordre d'affichage. */
|
||||
function requestedNetworks(promo: { shareFacebook: boolean; shareLinkedin: boolean }): SocialNetwork[] {
|
||||
return SOCIAL_NETWORKS.filter((n) =>
|
||||
n === "facebook" ? promo.shareFacebook : promo.shareLinkedin
|
||||
);
|
||||
}
|
||||
|
||||
function readShareTargets(formData: FormData) {
|
||||
return {
|
||||
shareFacebook: formData.get("shareFacebook") === "on",
|
||||
@@ -147,86 +133,6 @@ function readShareTargets(formData: FormData) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Publie une promotion sur les réseaux demandés. Ne lève jamais : un réseau
|
||||
* indisponible ne doit pas faire échouer la mise en ligne, l'échec est
|
||||
* enregistré dans `social_posts` et rattrapable depuis le backoffice.
|
||||
*
|
||||
* Un réseau déjà publié avec succès est ignoré : c'est ce qui empêche toute
|
||||
* republication, y compris sur un cycle suspension → remise en ligne.
|
||||
*/
|
||||
async function publishPromoShares(
|
||||
promoId: number,
|
||||
networks: SocialNetwork[],
|
||||
userId: number | null
|
||||
) {
|
||||
if (networks.length === 0) return;
|
||||
|
||||
const [promo] = await db
|
||||
.select({
|
||||
title: promotions.title,
|
||||
text: promotions.text,
|
||||
badge: promotions.badge,
|
||||
validUntil: promotions.validUntil,
|
||||
startsOn: promotions.startsOn,
|
||||
endsOn: promotions.endsOn,
|
||||
imageUrl: promotions.imageUrl,
|
||||
memberId: promotions.memberId,
|
||||
memberName: members.name,
|
||||
memberCity: members.city,
|
||||
})
|
||||
.from(promotions)
|
||||
.leftJoin(members, eq(promotions.memberId, members.id))
|
||||
.where(eq(promotions.id, promoId));
|
||||
if (!promo) return;
|
||||
|
||||
const done = await db
|
||||
.select({ network: socialPosts.network })
|
||||
.from(socialPosts)
|
||||
.where(and(eq(socialPosts.promotionId, promoId), eq(socialPosts.status, "posted")));
|
||||
const alreadyPosted = new Set(done.map((d) => d.network));
|
||||
|
||||
for (const network of networks) {
|
||||
if (alreadyPosted.has(network)) continue;
|
||||
|
||||
if (!(await isNetworkConfigured(network))) {
|
||||
await db.insert(socialPosts).values({
|
||||
promotionId: promoId,
|
||||
network,
|
||||
status: "failed",
|
||||
error: `${SOCIAL_LABELS[network]} n'est pas configuré sur ce serveur.`,
|
||||
postedById: userId,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await publishPromoToNetwork(network, promo);
|
||||
await db.insert(socialPosts).values({
|
||||
promotionId: promoId,
|
||||
network,
|
||||
status: "posted",
|
||||
externalId: result.externalId,
|
||||
url: result.url,
|
||||
postedById: userId,
|
||||
});
|
||||
await logActivity(
|
||||
`Promotion « ${promo.title} » publiée sur ${SOCIAL_LABELS[network]}`,
|
||||
"#2C6FB3"
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Erreur inconnue";
|
||||
await db.insert(socialPosts).values({
|
||||
promotionId: promoId,
|
||||
network,
|
||||
status: "failed",
|
||||
error: message.slice(0, 2000),
|
||||
postedById: userId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Promotion moderation ----
|
||||
export async function moderatePromo(formData: FormData) {
|
||||
const { role, userId, name } = await requireRole();
|
||||
@@ -243,20 +149,36 @@ export async function moderatePromo(formData: FormData) {
|
||||
// Le modérateur peut ajuster les réseaux demandés par l'adhérent, mais
|
||||
// uniquement ici : après validation le choix est figé.
|
||||
const targets = readShareTargets(formData);
|
||||
// Le champ est pré-rempli avec la date demandée par l'adhérent : vidé, la
|
||||
// promotion part tout de suite ; une échéance déjà passée aussi.
|
||||
const publishAt = parseScheduleInput(String(formData.get("publishAt") ?? ""));
|
||||
const immediate = isDue(publishAt);
|
||||
|
||||
await db
|
||||
.update(promotions)
|
||||
.set({
|
||||
status: "live",
|
||||
status: immediate ? "live" : "scheduled",
|
||||
publishAt: immediate ? null : publishAt,
|
||||
...targets,
|
||||
suspendedBy: null,
|
||||
suspendedById: null,
|
||||
suspendedAt: null,
|
||||
})
|
||||
.where(eq(promotions.id, id));
|
||||
await logActivity(`Promotion « ${promo.title} » validée et mise en ligne`, "#1f8a5b");
|
||||
// Mise en ligne committée avant l'appel réseau : si un réseau répond mal,
|
||||
// la promo est quand même publiée sur le site.
|
||||
await publishPromoShares(id, requestedNetworks(targets), userId);
|
||||
|
||||
if (immediate) {
|
||||
await logActivity(`Promotion « ${promo.title} » validée et mise en ligne`, "#1f8a5b");
|
||||
// Mise en ligne committée avant l'appel réseau : si un réseau répond mal,
|
||||
// la promo est quand même publiée sur le site.
|
||||
await publishPromoShares(id, requestedNetworks(targets), userId);
|
||||
} else {
|
||||
// Rien n'est diffusé maintenant : `releaseDuePromotions()` s'en chargera
|
||||
// à l'échéance, site et réseaux d'un seul coup.
|
||||
await logActivity(
|
||||
`Promotion « ${promo.title} » validée, publication programmée ${formatSchedule(publishAt)}`,
|
||||
"#9a6638"
|
||||
);
|
||||
}
|
||||
} else if (action === "suspend") {
|
||||
// Suspension par l'association : la promotion sort du site public et
|
||||
// l'adhérent ne peut pas la remettre en ligne lui-même.
|
||||
@@ -268,6 +190,15 @@ export async function moderatePromo(formData: FormData) {
|
||||
`Promotion « ${promo.title} » suspendue par <strong>${name}</strong>`,
|
||||
"#d8472b"
|
||||
);
|
||||
} else if (action === "publish-now") {
|
||||
// Raccourci sur une promo programmée : on n'attend pas l'échéance.
|
||||
if (promo.status !== "scheduled") return;
|
||||
await db
|
||||
.update(promotions)
|
||||
.set({ status: "live", publishAt: null })
|
||||
.where(eq(promotions.id, id));
|
||||
await logActivity(`Promotion « ${promo.title} » publiée avant l'heure programmée`, "#1f8a5b");
|
||||
await publishPromoShares(id, requestedNetworks(promo), userId);
|
||||
} else if (action === "restore") {
|
||||
await db
|
||||
.update(promotions)
|
||||
@@ -388,6 +319,9 @@ export async function publishPromo(formData: FormData) {
|
||||
throw new Error("La date de fin doit être postérieure à la date de début.");
|
||||
}
|
||||
const imageUrl = asImageDataUri(formData, "imageUrl");
|
||||
// Date souhaitée de mise en ligne. Elle n'engage rien : le modérateur la voit
|
||||
// et peut la garder, la déplacer ou la vider dans le formulaire « Valider ».
|
||||
const publishAt = parseScheduleInput(String(formData.get("publishAt") ?? ""));
|
||||
|
||||
// Réseaux souhaités : rien n'est publié ici, la diffusion attend la validation.
|
||||
await db.insert(promotions).values({
|
||||
@@ -400,6 +334,7 @@ export async function publishPromo(formData: FormData) {
|
||||
status: "pending",
|
||||
startsOn,
|
||||
endsOn,
|
||||
publishAt,
|
||||
...readShareTargets(formData),
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ 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 { formatValidityShort, visibilityNote } from "@/lib/promo-validity";
|
||||
import { scheduleLabel } from "@/lib/promo-schedule";
|
||||
import { setOwnPromoShareTargets, setOwnPromoSuspension } from "../../actions";
|
||||
import { EspaceHeader } from "../EspaceHeader";
|
||||
import { MemberSpaceForm } from "../MemberSpaceForm";
|
||||
@@ -20,6 +21,7 @@ const PROMO_STATUS: Record<string, { label: string; bg: string; color: string }>
|
||||
rejected: { label: "Refusée", bg: "#fbe9e6", color: "#d8472b" },
|
||||
suspended: { label: "Suspendue", bg: "#fbe9e6", color: "#d8472b" },
|
||||
expired: { label: "Expirée", bg: "#f1efe7", color: "#a99c82" },
|
||||
scheduled: { label: "Programmée", bg: "#eaf0f6", color: "#2C6FB3" },
|
||||
};
|
||||
|
||||
type MyPromo = {
|
||||
@@ -29,6 +31,7 @@ type MyPromo = {
|
||||
category: string | null;
|
||||
badge: string | null;
|
||||
validUntil: string | null;
|
||||
publishAt: Date | null;
|
||||
startsOn: string | null;
|
||||
endsOn: string | null;
|
||||
createdAt: Date;
|
||||
@@ -82,6 +85,7 @@ export default async function EspacePromotionsPage() {
|
||||
category: promotions.category,
|
||||
badge: promotions.badge,
|
||||
validUntil: promotions.validUntil,
|
||||
publishAt: promotions.publishAt,
|
||||
startsOn: promotions.startsOn,
|
||||
endsOn: promotions.endsOn,
|
||||
createdAt: promotions.createdAt,
|
||||
@@ -116,7 +120,12 @@ export default async function EspacePromotionsPage() {
|
||||
const networks = await configuredNetworks();
|
||||
const live = myPromos.filter((p) => p.status === "live");
|
||||
const pending = myPromos.filter((p) => p.status === "pending");
|
||||
const others = myPromos.filter((p) => p.status !== "live" && p.status !== "pending");
|
||||
// Validées mais pas encore à leur date : elles n'ont rien à faire dans
|
||||
// l'historique des offres terminées.
|
||||
const scheduled = myPromos.filter((p) => p.status === "scheduled");
|
||||
const others = myPromos.filter(
|
||||
(p) => p.status !== "live" && p.status !== "pending" && p.status !== "scheduled"
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -156,6 +165,16 @@ export default async function EspacePromotionsPage() {
|
||||
networks={networks}
|
||||
/>
|
||||
|
||||
{scheduled.length > 0 && (
|
||||
<PromoSection
|
||||
title="Publications programmées"
|
||||
intro="Validées par l'association : elles apparaîtront sur le site et sur les réseaux à la date prévue."
|
||||
promos={scheduled}
|
||||
networks={networks}
|
||||
lastShare={lastShare}
|
||||
/>
|
||||
)}
|
||||
|
||||
{pending.length > 0 && (
|
||||
<PromoSection
|
||||
title="En attente de validation"
|
||||
@@ -286,6 +305,16 @@ function PromoRow({ promo: mp, networks, lastShare }: { promo: MyPromo; networks
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
{mp.status === "live" && visibilityNote(mp) && (
|
||||
<div style={{ marginTop: 10, background: "#faf7ef", border: "1px solid #f0e8d6", color: "#9a8d72", borderRadius: 9, padding: "9px 11px", fontSize: 12.5, lineHeight: 1.5 }}>
|
||||
{visibilityNote(mp)}
|
||||
</div>
|
||||
)}
|
||||
{mp.status === "scheduled" && (
|
||||
<div style={{ marginTop: 10, background: "#eaf0f6", border: "1px solid #d3e0ee", color: "#2C6FB3", borderRadius: 9, padding: "9px 11px", fontSize: 12.5, lineHeight: 1.5, fontWeight: 600 }}>
|
||||
Validée par l'association. {scheduleLabel(mp.publishAt)} — site et réseaux en même temps.
|
||||
</div>
|
||||
)}
|
||||
{suspendedByStaff && (
|
||||
<div style={{ marginTop: 10, background: "#fbe9e6", border: "1px solid #f2d5cf", color: "#a8503c", borderRadius: 9, padding: "9px 11px", fontSize: 12.5, lineHeight: 1.5 }}>
|
||||
Cette promotion a été suspendue par l'association. Contactez-la pour la remettre en ligne.
|
||||
|
||||
@@ -6,6 +6,7 @@ import { db } from "@/db";
|
||||
import { activityLog, contactMessages, members, membershipRequests, promotions, socialPosts } from "@/db/schema";
|
||||
import { activityNodes } from "@/lib/activity";
|
||||
import { can, isStaff } from "@/lib/rbac";
|
||||
import { countVisiblePromotions } from "@/lib/queries";
|
||||
import {
|
||||
expiryStatus,
|
||||
getSocialAccounts,
|
||||
@@ -48,7 +49,7 @@ export default async function DashboardPage() {
|
||||
|
||||
const [activeMembers, livePromos, pendingPromos, requests, newMessages, activity] = await Promise.all([
|
||||
db.select({ id: members.id }).from(members).where(eq(members.status, "active")),
|
||||
db.select({ id: promotions.id }).from(promotions).where(eq(promotions.status, "live")),
|
||||
countVisiblePromotions(),
|
||||
db.select({ id: promotions.id }).from(promotions).where(eq(promotions.status, "pending")),
|
||||
db.select({ id: membershipRequests.id }).from(membershipRequests).where(eq(membershipRequests.status, "new")),
|
||||
db.select({ id: contactMessages.id }).from(contactMessages).where(eq(contactMessages.status, "new")),
|
||||
@@ -142,7 +143,7 @@ export default async function DashboardPage() {
|
||||
|
||||
<div className="grid grid-4" style={{ marginBottom: 26 }}>
|
||||
<StatCard label="Adhérents actifs" value={activeMembers.length} hint="sur le réseau" valueColor="#13324F" hintColor="#1f8a5b" />
|
||||
<StatCard label="Promotions en ligne" value={livePromos.length} hint="visibles sur le site" />
|
||||
<StatCard label="Promotions en ligne" value={livePromos} hint="visibles sur le site" />
|
||||
<StatCard label="À modérer" value={pendingCount} hint="en attente de validation" valueColor="#9a6638" hintColor="#9a6638" />
|
||||
<Link href="/backend/demandes" style={{ textDecoration: "none" }}>
|
||||
<StatCard
|
||||
|
||||
@@ -8,7 +8,8 @@ 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";
|
||||
import { formatValidity, visibilityNote } from "@/lib/promo-validity";
|
||||
import { formatSchedule, scheduleLabel, toScheduleInput } from "@/lib/promo-schedule";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -21,6 +22,7 @@ const STATUS_CHIP: Record<string, { label: string; bg: string; color: string }>
|
||||
pending: { label: "En attente", bg: "#fbeede", color: "#9a6638" },
|
||||
live: { label: "En ligne", bg: "#e6f4ec", color: "#1f8a5b" },
|
||||
suspended: { label: "Suspendue", bg: "#fbe9e6", color: "#d8472b" },
|
||||
scheduled: { label: "Programmée", bg: "#eaf0f6", color: "#2C6FB3" },
|
||||
};
|
||||
|
||||
function fmtDate(d: Date) {
|
||||
@@ -42,6 +44,7 @@ export default async function PromotionsPage() {
|
||||
text: promotions.text,
|
||||
category: promotions.category,
|
||||
status: promotions.status,
|
||||
publishAt: promotions.publishAt,
|
||||
validUntil: promotions.validUntil,
|
||||
startsOn: promotions.startsOn,
|
||||
endsOn: promotions.endsOn,
|
||||
@@ -54,14 +57,16 @@ export default async function PromotionsPage() {
|
||||
})
|
||||
.from(promotions)
|
||||
.leftJoin(members, eq(promotions.memberId, members.id))
|
||||
.where(inArray(promotions.status, ["pending", "live", "suspended"]))
|
||||
.where(inArray(promotions.status, ["pending", "live", "suspended", "scheduled"]))
|
||||
.orderBy(desc(promotions.createdAt));
|
||||
|
||||
const pending = rows.filter((r) => r.status === "pending");
|
||||
const live = rows.filter((r) => r.status === "live");
|
||||
const suspended = rows.filter((r) => r.status === "suspended");
|
||||
// En attente d'abord, puis en ligne, puis suspendues.
|
||||
const ordered = [...pending, ...live, ...suspended];
|
||||
const scheduled = rows.filter((r) => r.status === "scheduled");
|
||||
// En attente d'abord, puis les programmées (elles attendent une décision de
|
||||
// calendrier), puis les offres en ligne et enfin les suspendues.
|
||||
const ordered = [...pending, ...scheduled, ...live, ...suspended];
|
||||
|
||||
const shares = ordered.length
|
||||
? await db
|
||||
@@ -94,6 +99,9 @@ export default async function PromotionsPage() {
|
||||
<span style={{ background: "#13324F", color: "#fff", fontSize: 13, fontWeight: 600, padding: "8px 16px", borderRadius: 999 }}>
|
||||
En attente · {pending.length}
|
||||
</span>
|
||||
<span style={{ background: "#fff", border: "1px solid #e6dcc6", color: "#6c6150", fontSize: 13, fontWeight: 600, padding: "8px 16px", borderRadius: 999 }}>
|
||||
Programmées · {scheduled.length}
|
||||
</span>
|
||||
<span style={{ background: "#fff", border: "1px solid #e6dcc6", color: "#6c6150", fontSize: 13, fontWeight: 600, padding: "8px 16px", borderRadius: 999 }}>
|
||||
En ligne · {live.length}
|
||||
</span>
|
||||
@@ -119,6 +127,7 @@ export default async function PromotionsPage() {
|
||||
{ordered.map((p) => {
|
||||
const isPending = p.status === "pending";
|
||||
const isSuspended = p.status === "suspended";
|
||||
const isScheduled = p.status === "scheduled";
|
||||
const chip = STATUS_CHIP[p.status] ?? STATUS_CHIP.pending;
|
||||
const chosenNetworks = networks.filter((n) =>
|
||||
n === "facebook" ? p.shareFacebook : p.shareLinkedin
|
||||
@@ -188,6 +197,18 @@ export default async function PromotionsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{p.status === "live" && visibilityNote(p) && (
|
||||
<div style={{ background: "#faf7ef", border: "1px solid #f0e8d6", color: "#9a8d72", borderRadius: 10, padding: "9px 11px", fontSize: 12, lineHeight: 1.45, marginBottom: 11 }}>
|
||||
{visibilityNote(p)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isScheduled && (
|
||||
<div style={{ background: "#eaf0f6", border: "1px solid #d3e0ee", color: "#2C6FB3", borderRadius: 10, padding: "9px 11px", fontSize: 12, lineHeight: 1.45, marginBottom: 11, fontWeight: 600 }}>
|
||||
{scheduleLabel(p.publishAt)}. Rien n'est encore visible ni diffusé.
|
||||
</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"}
|
||||
@@ -226,6 +247,24 @@ export default async function PromotionsPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Date demandée par l'adhérent, modifiable ici. Vidée,
|
||||
la promotion part immédiatement. */}
|
||||
<div style={{ background: "#faf7ef", border: "1px solid #f0e8d6", borderRadius: 10, padding: "10px 11px", marginBottom: 8 }}>
|
||||
<label style={{ display: "block", fontSize: 10.5, letterSpacing: "0.08em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 800, marginBottom: 7 }}>
|
||||
Publication
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
name="publishAt"
|
||||
defaultValue={toScheduleInput(p.publishAt)}
|
||||
style={{ width: "100%", boxSizing: "border-box", border: "1px solid #e6dcc6", borderRadius: 8, padding: "7px 9px", fontSize: 12.5, fontFamily: "inherit", color: "#3c3322", background: "#fff" }}
|
||||
/>
|
||||
<div style={{ fontSize: 11, color: "#a99c82", marginTop: 7, lineHeight: 1.45 }}>
|
||||
{p.publishAt
|
||||
? `Demandé par l'adhérent ${formatSchedule(p.publishAt)}. Videz le champ pour publier tout de suite.`
|
||||
: "Vide : mise en ligne immédiate. Renseignez une date pour programmer."}
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" style={{ width: "100%", border: "none", background: "#1f8a5b", color: "#fff", fontWeight: 700, fontSize: 13, padding: 10, borderRadius: 9, cursor: "pointer" }}>
|
||||
Valider
|
||||
</button>
|
||||
@@ -242,6 +281,16 @@ export default async function PromotionsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isScheduled && (
|
||||
<form action={moderatePromo} style={{ marginBottom: 8 }}>
|
||||
<input type="hidden" name="id" value={p.id} />
|
||||
<input type="hidden" name="action" value="publish-now" />
|
||||
<button type="submit" style={{ width: "100%", border: "none", background: "#2C6FB3", color: "#fff", fontWeight: 700, fontSize: 13, padding: 10, borderRadius: 9, cursor: "pointer" }}>
|
||||
Publier maintenant
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{!isPending && (
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<form action={moderatePromo} style={{ flex: 1 }}>
|
||||
|
||||
Reference in new issue
Block a user