import { redirect } from "next/navigation"; import { desc, eq, inArray } from "drizzle-orm"; import { getSession } from "@/lib/session"; import { db } from "@/db"; import { members, promotions, socialPosts, users } from "@/db/schema"; import { can } from "@/lib/rbac"; import { configuredNetworks, SOCIAL_LABELS } from "@/lib/social"; import { moderatePromo, retryPromoShare } from "../actions"; import { PromoImage } from "@/components/PromoImage"; import { MemberAvatar } from "@/components/MemberAvatar"; import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons"; import { formatValidity, visibilityNote } from "@/lib/promo-validity"; import { formatSchedule, scheduleLabel, toScheduleInput } from "@/lib/promo-schedule"; export const dynamic = "force-dynamic"; const STRIPE_WARM = "repeating-linear-gradient(45deg,#efe9da,#efe9da 11px,#e6ddc9 11px,#e6ddc9 22px)"; const STRIPE_COOL = "repeating-linear-gradient(45deg,#eef0ec,#eef0ec 11px,#e2e8e6 11px,#e2e8e6 22px)"; const STATUS_CHIP: Record = { 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) { return new Date(d).toLocaleDateString("fr-FR", { day: "numeric", month: "long", year: "numeric" }); } export default async function PromotionsPage() { const session = await getSession(); if (!can(session?.user.role, "moderatePromos")) { redirect("/backend"); } const canShare = can(session?.user.role, "publishSocial"); const networks = canShare ? await configuredNetworks() : []; const rows = await db .select({ id: promotions.id, title: promotions.title, text: promotions.text, category: promotions.category, status: promotions.status, publishAt: promotions.publishAt, validUntil: promotions.validUntil, startsOn: promotions.startsOn, endsOn: promotions.endsOn, imageUrl: promotions.imageUrl, suspendedBy: promotions.suspendedBy, suspendedAt: promotions.suspendedAt, shareFacebook: promotions.shareFacebook, shareLinkedin: promotions.shareLinkedin, memberName: members.name, memberLogoUrl: members.logoUrl, }) .from(promotions) .leftJoin(members, eq(promotions.memberId, members.id)) .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"); 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 .select({ promotionId: socialPosts.promotionId, network: socialPosts.network, status: socialPosts.status, url: socialPosts.url, error: socialPosts.error, createdAt: socialPosts.createdAt, authorName: users.name, }) .from(socialPosts) .leftJoin(users, eq(socialPosts.postedById, users.id)) .where(inArray(socialPosts.promotionId, ordered.map((r) => r.id))) .orderBy(desc(socialPosts.createdAt)) : []; // La liste est triée du plus récent au plus ancien : la première occurrence // rencontrée pour un couple (promo, réseau) est donc la plus récente. const lastShare = new Map(); for (const s of shares) { const key = `${s.promotionId}:${s.network}`; if (!lastShare.has(key)) lastShare.set(key, s); } return (
En attente · {pending.length} Programmées · {scheduled.length} En ligne · {live.length} Suspendues · {suspended.length}
{canShare && networks.length === 0 && (
Publication sur les réseaux indisponible : aucun jeton d'accès Facebook ou LinkedIn n'est configuré sur le serveur (voir .env.example).
)} {ordered.length === 0 && (
Aucune promotion pour le moment.
)}
{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 ); return (
{p.imageUrl && } {p.category && ( {p.category} )} {chip.label}

{p.title}

{p.memberName}

{p.text}

{/* 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) && (
{formatValidity(p)}
)} {p.status === "live" && visibilityNote(p) && (
{visibilityNote(p)}
)} {isScheduled && (
{scheduleLabel(p.publishAt)}. Rien n'est encore visible ni diffusé.
)} {isSuspended && (
Suspendue par {p.suspendedBy === "member" ? "l'adhérent" : "l'association"} {p.suspendedAt ? ` le ${fmtDate(p.suspendedAt)}` : ""}.
)} {isPending && (
{/* Les cases vivent dans le formulaire « Valider » : c'est le seul et dernier moment où la diffusion est ajustable. */}
{networks.length > 0 && (
Diffusion demandée
{networks.map((network) => ( ))}
Diffusé en même temps que la mise en ligne : tout de suite si le champ Publication est vide, à la date indiquée sinon. Ce choix n'est plus modifiable ensuite.
)} {/* Date demandée par l'adhérent, modifiable ici. Vidée, la promotion part immédiatement. */}
{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."}
{/* Aligné en bas pour rester au niveau du bouton Valider, que le bloc diffusion rend plus haut. */}
)} {isScheduled && (
)} {!isPending && (
)} {!isPending && chosenNetworks.length > 0 && (
Diffusion réseaux
{chosenNetworks.map((network) => { const last = lastShare.get(`${p.id}:${network}`); const posted = last?.status === "posted"; return (
{SOCIAL_LABELS[network]} ·{" "} {posted ? `publié le ${fmtDate(last!.createdAt)}` : "non publié"} {posted && last?.url && ( voir )}
{!posted && last?.error && (
{last.error.slice(0, 200)}
)} {!posted && p.status === "live" && (
)}
); })}
)}
); })}
); }