mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-23 23:28:04 +02:00
Refonte du bloc infos pratiques, suspension des promos et publication réseaux
Bloc « Informations pratiques » (fiche adhérent) - Suppression des dégradés hors palette (radial doré de la section, dégradés crème/bleu des deux sous-cartes) au profit du vocabulaire visuel du site : carte blanche bordée, sous-cartes plates sur le fond de champ, variables CSS. - La pastille « Préparer votre visite » devient une puce arrondie identique aux badges du reste du site ; le chip d'ouverture reprend les teintes de statut. - Les glyphes texte ◷ et ↗ sont remplacés par des icônes SVG cohérentes. Suspension des promotions - Nouveau statut `suspended` + colonnes `suspended_by` / `suspended_by_id` / `suspended_at`. - L'adhérent suspend et réactive ses propres promotions depuis Mon espace ; l'association suspend et réactive n'importe laquelle depuis le backoffice. - Une suspension décidée par l'association ne peut pas être levée par l'adhérent : le motif est affiché dans son espace. - Les lectures publiques filtrant déjà sur `live`, une promo suspendue quitte immédiatement l'accueil, l'annuaire et la fiche. Publication Facebook / LinkedIn - `src/lib/social.ts` : publication d'une promo en ligne sur la page Facebook (Graph API, upload multipart car les images sont en data-URI) et sur la page LinkedIn (Posts API + Images API en trois étapes). - Jetons d'accès exclusivement en variables d'environnement ; un réseau non configuré masque son bouton. - Table `social_posts` : chaque tentative est tracée, un échec est rattrapé et affiché sur la carte de la promo sans interrompre le backoffice. - Nouvelle capacité RBAC `publishSocial` (admin + modérateur). Liens sociaux publics - Réglages `association_facebook` / `association_linkedin` éditables dans Paramètres, préremplis avec les pages de l'association. - Section « Plein R sur les réseaux » sur l'accueil et icônes dans le pied de page, masquées si le réglage est vide. 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
+2370
-109
No files matched your search
+151
-7
@@ -20,9 +20,17 @@ import {
|
||||
pastMeetings,
|
||||
promotions,
|
||||
siteSettings,
|
||||
socialPosts,
|
||||
users,
|
||||
} from "@/db/schema";
|
||||
import { can, LABEL_TO_ROLE } from "@/lib/rbac";
|
||||
import {
|
||||
isNetworkConfigured,
|
||||
publishPromoToNetwork,
|
||||
SOCIAL_LABELS,
|
||||
SOCIAL_NETWORKS,
|
||||
type SocialNetwork,
|
||||
} from "@/lib/social";
|
||||
import { normalizeWebsite } from "@/lib/member-profile";
|
||||
import { SITE_SETTING_DEFAULTS } from "@/lib/site-settings";
|
||||
import type { AppRole } from "@/types/next-auth";
|
||||
@@ -62,13 +70,20 @@ function generateTempPassword(): string {
|
||||
return out;
|
||||
}
|
||||
|
||||
async function requireRole(): Promise<{ role: AppRole; memberId: number | null; name: string }> {
|
||||
async function requireRole(): Promise<{
|
||||
role: AppRole;
|
||||
memberId: number | null;
|
||||
name: string;
|
||||
userId: number | null;
|
||||
}> {
|
||||
const session = await auth();
|
||||
if (!session?.user) throw new Error("Non authentifié");
|
||||
const userId = Number(session.user.id);
|
||||
return {
|
||||
role: session.user.role,
|
||||
memberId: session.user.memberId,
|
||||
name: session.user.name ?? "Adhérent",
|
||||
userId: Number.isFinite(userId) ? userId : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -87,26 +102,155 @@ function asNullableString(formData: FormData, key: string) {
|
||||
return asString(formData, key) || null;
|
||||
}
|
||||
|
||||
function revalidatePromoPaths(memberId?: number | null) {
|
||||
revalidatePath("/backend/promotions");
|
||||
revalidatePath("/backend/espace");
|
||||
revalidatePath("/backend");
|
||||
revalidatePath("/");
|
||||
revalidatePath("/annuaire");
|
||||
if (memberId) revalidatePath(`/adherents/${memberId}`);
|
||||
}
|
||||
|
||||
// ---- Promotion moderation ----
|
||||
export async function moderatePromo(formData: FormData) {
|
||||
const { role } = await requireRole();
|
||||
const { role, userId, name } = await requireRole();
|
||||
if (!can(role, "moderatePromos")) throw new Error("Accès refusé");
|
||||
|
||||
const id = Number(formData.get("id"));
|
||||
const action = String(formData.get("action"));
|
||||
if (!id) return;
|
||||
|
||||
const [promo] = await db.select().from(promotions).where(eq(promotions.id, id));
|
||||
if (!promo) return;
|
||||
|
||||
if (action === "approve") {
|
||||
await db.update(promotions).set({ status: "live" }).where(eq(promotions.id, id));
|
||||
const [p] = await db.select().from(promotions).where(eq(promotions.id, id));
|
||||
if (p) await logActivity(`Promotion « ${p.title} » validée et mise en ligne`, "#1f8a5b");
|
||||
await db
|
||||
.update(promotions)
|
||||
.set({ status: "live", suspendedBy: null, suspendedById: null, suspendedAt: null })
|
||||
.where(eq(promotions.id, id));
|
||||
await logActivity(`Promotion « ${promo.title} » validée et mise en ligne`, "#1f8a5b");
|
||||
} 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.
|
||||
await db
|
||||
.update(promotions)
|
||||
.set({ status: "suspended", suspendedBy: "staff", suspendedById: userId, suspendedAt: new Date() })
|
||||
.where(eq(promotions.id, id));
|
||||
await logActivity(
|
||||
`Promotion « ${promo.title} » suspendue par <strong>${name}</strong>`,
|
||||
"#d8472b"
|
||||
);
|
||||
} else if (action === "restore") {
|
||||
await db
|
||||
.update(promotions)
|
||||
.set({ status: "live", suspendedBy: null, suspendedById: null, suspendedAt: null })
|
||||
.where(eq(promotions.id, id));
|
||||
await logActivity(`Promotion « ${promo.title} » remise en ligne`, "#1f8a5b");
|
||||
} else if (action === "reject" || action === "remove") {
|
||||
await db.delete(promotions).where(eq(promotions.id, id));
|
||||
}
|
||||
|
||||
revalidatePromoPaths(promo.memberId);
|
||||
}
|
||||
|
||||
// ---- Member space: suspendre / réactiver sa propre promotion ----
|
||||
export async function setOwnPromoSuspension(formData: FormData) {
|
||||
const { memberId, userId, name } = await requireRole();
|
||||
if (!memberId) throw new Error("Aucune fiche adhérent liée à votre compte.");
|
||||
|
||||
const id = Number(formData.get("id"));
|
||||
const action = String(formData.get("action"));
|
||||
if (!id) return;
|
||||
|
||||
const [promo] = await db
|
||||
.select()
|
||||
.from(promotions)
|
||||
.where(and(eq(promotions.id, id), eq(promotions.memberId, memberId)));
|
||||
if (!promo) throw new Error("Promotion introuvable.");
|
||||
|
||||
if (action === "suspend") {
|
||||
if (promo.status !== "live") return;
|
||||
await db
|
||||
.update(promotions)
|
||||
.set({ status: "suspended", suspendedBy: "member", suspendedById: userId, suspendedAt: new Date() })
|
||||
.where(eq(promotions.id, id));
|
||||
await logActivity(`<strong>${name}</strong> a suspendu sa promotion « ${promo.title} »`, "#9a6638");
|
||||
} else if (action === "restore") {
|
||||
if (promo.status !== "suspended") return;
|
||||
// Une suspension décidée par l'association ne se lève que par elle.
|
||||
if (promo.suspendedBy === "staff") {
|
||||
throw new Error(
|
||||
"Cette promotion a été suspendue par l'association : contactez-la pour la remettre en ligne."
|
||||
);
|
||||
}
|
||||
await db
|
||||
.update(promotions)
|
||||
.set({ status: "live", suspendedBy: null, suspendedById: null, suspendedAt: null })
|
||||
.where(eq(promotions.id, id));
|
||||
await logActivity(`<strong>${name}</strong> a réactivé sa promotion « ${promo.title} »`, "#1f8a5b");
|
||||
}
|
||||
|
||||
revalidatePromoPaths(memberId);
|
||||
}
|
||||
|
||||
// ---- Publication d'une promotion sur Facebook / LinkedIn ----
|
||||
export async function sharePromoToSocial(formData: FormData) {
|
||||
const { role, userId, name } = await requireRole();
|
||||
if (!can(role, "publishSocial")) throw new Error("Accès refusé");
|
||||
|
||||
const id = Number(formData.get("id"));
|
||||
const network = String(formData.get("network")) as SocialNetwork;
|
||||
if (!id || !SOCIAL_NETWORKS.includes(network)) return;
|
||||
if (!isNetworkConfigured(network)) {
|
||||
throw new Error(`${SOCIAL_LABELS[network]} n'est pas configuré sur ce serveur.`);
|
||||
}
|
||||
|
||||
const [promo] = await db
|
||||
.select({
|
||||
id: promotions.id,
|
||||
title: promotions.title,
|
||||
text: promotions.text,
|
||||
badge: promotions.badge,
|
||||
validUntil: promotions.validUntil,
|
||||
imageUrl: promotions.imageUrl,
|
||||
status: promotions.status,
|
||||
memberId: promotions.memberId,
|
||||
memberName: members.name,
|
||||
})
|
||||
.from(promotions)
|
||||
.leftJoin(members, eq(promotions.memberId, members.id))
|
||||
.where(eq(promotions.id, id));
|
||||
if (!promo) throw new Error("Promotion introuvable.");
|
||||
if (promo.status !== "live") {
|
||||
throw new Error("Seule une promotion en ligne peut être publiée sur les réseaux.");
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await publishPromoToNetwork(network, promo);
|
||||
await db.insert(socialPosts).values({
|
||||
promotionId: promo.id,
|
||||
network,
|
||||
status: "posted",
|
||||
externalId: result.externalId,
|
||||
url: result.url,
|
||||
postedById: userId,
|
||||
});
|
||||
await logActivity(
|
||||
`Promotion « ${promo.title} » publiée sur ${SOCIAL_LABELS[network]} par <strong>${name}</strong>`,
|
||||
"#2C6FB3"
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Erreur inconnue";
|
||||
await db.insert(socialPosts).values({
|
||||
promotionId: promo.id,
|
||||
network,
|
||||
status: "failed",
|
||||
error: message.slice(0, 2000),
|
||||
postedById: userId,
|
||||
});
|
||||
}
|
||||
|
||||
revalidatePath("/backend/promotions");
|
||||
revalidatePath("/backend");
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
// ---- Member space: publish a promotion ----
|
||||
|
||||
@@ -7,7 +7,7 @@ import { ImageField } from "@/components/ImageField";
|
||||
import { ImageConsentForm } from "@/components/ImageConsentForm";
|
||||
import { HoursEditor } from "@/components/HoursEditor";
|
||||
import { communeOptions } from "@/lib/communes";
|
||||
import { saveImageConsent, updateOwnProfile } from "../actions";
|
||||
import { saveImageConsent, setOwnPromoSuspension, updateOwnProfile } from "../actions";
|
||||
import { MemberSpaceForm } from "./MemberSpaceForm";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -16,6 +16,7 @@ const PROMO_STATUS: Record<string, { label: string; bg: string; color: string }>
|
||||
live: { label: "En ligne", bg: "#e6f4ec", color: "#1f8a5b" },
|
||||
pending: { label: "En attente", bg: "#fbeede", color: "#9a6638" },
|
||||
rejected: { label: "Refusée", bg: "#fbe9e6", color: "#d8472b" },
|
||||
suspended: { label: "Suspendue", bg: "#fbe9e6", color: "#d8472b" },
|
||||
expired: { label: "Expirée", bg: "#f1efe7", color: "#a99c82" },
|
||||
};
|
||||
|
||||
@@ -31,7 +32,14 @@ export default async function EspacePage() {
|
||||
let memberName = fallbackName;
|
||||
let subtitle = "Espace adhérent";
|
||||
let profile: Member | null = null;
|
||||
let myPromos: { id: number; title: string; status: string; createdAt: Date; imageUrl: string | null }[] = [];
|
||||
let myPromos: {
|
||||
id: number;
|
||||
title: string;
|
||||
status: string;
|
||||
createdAt: Date;
|
||||
imageUrl: string | null;
|
||||
suspendedBy: "member" | "staff" | null;
|
||||
}[] = [];
|
||||
let myRegistrations: { meetingId: number; title: string; startsAt: Date; location: string | null; participants: number; confirmed: boolean }[] = [];
|
||||
let latestConsent: { decision: string; createdAt: Date } | null = null;
|
||||
|
||||
@@ -52,6 +60,7 @@ export default async function EspacePage() {
|
||||
status: promotions.status,
|
||||
createdAt: promotions.createdAt,
|
||||
imageUrl: promotions.imageUrl,
|
||||
suspendedBy: promotions.suspendedBy,
|
||||
})
|
||||
.from(promotions)
|
||||
.where(eq(promotions.memberId, memberId))
|
||||
@@ -241,38 +250,70 @@ export default async function EspacePage() {
|
||||
<MemberSpaceForm memberName={memberName} categories={categoryLabels} />
|
||||
|
||||
<div style={{ marginTop: 28 }}>
|
||||
<div style={{ fontSize: 12.5, letterSpacing: "0.08em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 700, marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 12.5, letterSpacing: "0.08em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 700, marginBottom: 4 }}>
|
||||
Mes promotions
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: "#8c8068", marginBottom: 12 }}>
|
||||
Vous pouvez suspendre une promotion en ligne à tout moment : elle disparaît du site et
|
||||
vous la remettez en ligne quand vous voulez.
|
||||
</div>
|
||||
{myPromos.length === 0 && (
|
||||
<div style={{ fontSize: 13.5, color: "#a99c82" }}>Vous n'avez pas encore publié de promotion.</div>
|
||||
)}
|
||||
{myPromos.map((mp) => {
|
||||
const st = PROMO_STATUS[mp.status] ?? PROMO_STATUS.expired;
|
||||
const suspendedByStaff = mp.status === "suspended" && mp.suspendedBy === "staff";
|
||||
return (
|
||||
<div key={mp.id} style={{ display: "flex", alignItems: "center", gap: 13, background: "#fff", border: "1px solid #e6dcc6", borderRadius: 12, padding: "12px 14px", marginBottom: 10 }}>
|
||||
<span
|
||||
style={{
|
||||
width: 42,
|
||||
height: 42,
|
||||
borderRadius: 9,
|
||||
background: mp.imageUrl
|
||||
? `url(${mp.imageUrl})`
|
||||
: "repeating-linear-gradient(45deg,#efe9da,#efe9da 7px,#e6ddc9 7px,#e6ddc9 14px)",
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: "#26201a", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
{mp.title}
|
||||
<div key={mp.id} style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 12, padding: "12px 14px", marginBottom: 10 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 13 }}>
|
||||
<span
|
||||
style={{
|
||||
width: 42,
|
||||
height: 42,
|
||||
borderRadius: 9,
|
||||
background: mp.imageUrl
|
||||
? `url(${mp.imageUrl})`
|
||||
: "repeating-linear-gradient(45deg,#efe9da,#efe9da 7px,#e6ddc9 7px,#e6ddc9 14px)",
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
flexShrink: 0,
|
||||
opacity: mp.status === "suspended" ? 0.5 : 1,
|
||||
}}
|
||||
/>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: "#26201a", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
{mp.title}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "#a99c82" }}>Publié le {fmtDate(mp.createdAt)}</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "#a99c82" }}>Publié le {fmtDate(mp.createdAt)}</div>
|
||||
<span style={{ display: "inline-block", fontSize: 11.5, fontWeight: 700, padding: "4px 11px", borderRadius: 999, background: st.bg, color: st.color, flexShrink: 0 }}>
|
||||
{st.label}
|
||||
</span>
|
||||
{mp.status === "live" && (
|
||||
<form action={setOwnPromoSuspension}>
|
||||
<input type="hidden" name="id" value={mp.id} />
|
||||
<input type="hidden" name="action" value="suspend" />
|
||||
<button type="submit" style={{ border: "1px solid #e0c3bb", background: "#fff", color: "#d8472b", fontWeight: 700, fontSize: 12.5, padding: "7px 13px", borderRadius: 9, cursor: "pointer", whiteSpace: "nowrap" }}>
|
||||
Suspendre
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
{mp.status === "suspended" && !suspendedByStaff && (
|
||||
<form action={setOwnPromoSuspension}>
|
||||
<input type="hidden" name="id" value={mp.id} />
|
||||
<input type="hidden" name="action" value="restore" />
|
||||
<button type="submit" style={{ border: "none", background: "#1f8a5b", color: "#fff", fontWeight: 700, fontSize: 12.5, padding: "8px 14px", borderRadius: 9, cursor: "pointer", whiteSpace: "nowrap" }}>
|
||||
Remettre en ligne
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
<span style={{ display: "inline-block", fontSize: 11.5, fontWeight: 700, padding: "4px 11px", borderRadius: 999, background: st.bg, color: st.color }}>
|
||||
{st.label}
|
||||
</span>
|
||||
{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.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -25,12 +25,30 @@ export default async function ParametresPage() {
|
||||
<Field label="Téléphone" name="association_phone" value={settings.association_phone} />
|
||||
<Field label="Site web" name="association_website" value={settings.association_website} />
|
||||
<Field label="Contact principal" name="association_contact" value={settings.association_contact} />
|
||||
<Field
|
||||
label="Page Facebook"
|
||||
name="association_facebook"
|
||||
value={settings.association_facebook}
|
||||
placeholder="https://www.facebook.com/..."
|
||||
/>
|
||||
<Field
|
||||
label="Page LinkedIn"
|
||||
name="association_linkedin"
|
||||
value={settings.association_linkedin}
|
||||
placeholder="https://www.linkedin.com/company/..."
|
||||
/>
|
||||
<div style={{ gridColumn: "1 / -1" }}>
|
||||
<Field label="Adresse" name="association_address" value={settings.association_address} />
|
||||
</div>
|
||||
<Area label="Texte d'introduction Association" name="association_intro" value={settings.association_intro} />
|
||||
<Area label="Mission" name="association_mission" value={settings.association_mission} />
|
||||
</div>
|
||||
<div style={{ marginTop: 14, color: "#9a6638", fontSize: 13, lineHeight: 1.6 }}>
|
||||
Les pages Facebook et LinkedIn s'affichent sur la page d'accueil et dans le pied
|
||||
de page. Laissez un champ vide pour masquer le réseau correspondant. La publication
|
||||
automatique des promotions, elle, se configure côté serveur (jetons d'accès dans les
|
||||
variables d'environnement).
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style={panel}>
|
||||
|
||||
@@ -2,9 +2,10 @@ import { redirect } from "next/navigation";
|
||||
import { desc, eq, inArray } from "drizzle-orm";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/db";
|
||||
import { members, promotions } from "@/db/schema";
|
||||
import { members, promotions, socialPosts, users } from "@/db/schema";
|
||||
import { can } from "@/lib/rbac";
|
||||
import { moderatePromo } from "../actions";
|
||||
import { configuredNetworks, SOCIAL_LABELS, type SocialNetwork } from "@/lib/social";
|
||||
import { moderatePromo, sharePromoToSocial } from "../actions";
|
||||
import { PromoImage } from "@/components/PromoImage";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -14,11 +15,23 @@ const STRIPE_WARM =
|
||||
const STRIPE_COOL =
|
||||
"repeating-linear-gradient(45deg,#eef0ec,#eef0ec 11px,#e2e8e6 11px,#e2e8e6 22px)";
|
||||
|
||||
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" },
|
||||
};
|
||||
|
||||
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 auth();
|
||||
if (!can(session?.user.role, "moderatePromos")) {
|
||||
redirect("/backend");
|
||||
}
|
||||
const canShare = can(session?.user.role, "publishSocial");
|
||||
const networks = canShare ? configuredNetworks() : [];
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
@@ -28,17 +41,45 @@ export default async function PromotionsPage() {
|
||||
category: promotions.category,
|
||||
status: promotions.status,
|
||||
imageUrl: promotions.imageUrl,
|
||||
suspendedBy: promotions.suspendedBy,
|
||||
suspendedAt: promotions.suspendedAt,
|
||||
memberName: members.name,
|
||||
})
|
||||
.from(promotions)
|
||||
.leftJoin(members, eq(promotions.memberId, members.id))
|
||||
.where(inArray(promotions.status, ["pending", "live"]))
|
||||
.orderBy(desc(promotions.status), desc(promotions.createdAt));
|
||||
.where(inArray(promotions.status, ["pending", "live", "suspended"]))
|
||||
.orderBy(desc(promotions.createdAt));
|
||||
|
||||
const pending = rows.filter((r) => r.status === "pending");
|
||||
const live = rows.filter((r) => r.status === "live");
|
||||
// pending first, then live
|
||||
const ordered = [...pending, ...live];
|
||||
const suspended = rows.filter((r) => r.status === "suspended");
|
||||
// En attente d'abord, puis en ligne, puis suspendues.
|
||||
const ordered = [...pending, ...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<string, (typeof shares)[number]>();
|
||||
for (const s of shares) {
|
||||
const key = `${s.promotionId}:${s.network}`;
|
||||
if (!lastShare.has(key)) lastShare.set(key, s);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -49,8 +90,18 @@ export default async function PromotionsPage() {
|
||||
<span style={{ background: "#fff", border: "1px solid #e6dcc6", color: "#6c6150", fontSize: 13, fontWeight: 600, padding: "8px 16px", borderRadius: 999 }}>
|
||||
En ligne · {live.length}
|
||||
</span>
|
||||
<span style={{ background: "#fff", border: "1px solid #e6dcc6", color: "#6c6150", fontSize: 13, fontWeight: 600, padding: "8px 16px", borderRadius: 999 }}>
|
||||
Suspendues · {suspended.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{canShare && networks.length === 0 && (
|
||||
<div style={{ background: "#fbeede", border: "1px solid #ecd8b8", color: "#9a6638", borderRadius: 12, padding: "12px 16px", marginBottom: 18, fontSize: 13.5, lineHeight: 1.55 }}>
|
||||
Publication sur les réseaux indisponible : aucun jeton d'accès Facebook ou LinkedIn
|
||||
n'est configuré sur le serveur (voir <code>.env.example</code>).
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ordered.length === 0 && (
|
||||
<div style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 16, padding: 28, color: "#a99c82", fontSize: 14 }}>
|
||||
Aucune promotion pour le moment.
|
||||
@@ -60,6 +111,8 @@ export default async function PromotionsPage() {
|
||||
<div className="grid grid-3" style={{ gap: 16, width: "100%", maxWidth: 960 }}>
|
||||
{ordered.map((p) => {
|
||||
const isPending = p.status === "pending";
|
||||
const isSuspended = p.status === "suspended";
|
||||
const chip = STATUS_CHIP[p.status] ?? STATUS_CHIP.pending;
|
||||
return (
|
||||
<article
|
||||
key={p.id}
|
||||
@@ -81,6 +134,7 @@ export default async function PromotionsPage() {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
opacity: isSuspended ? 0.55 : 1,
|
||||
}}
|
||||
>
|
||||
{p.imageUrl && <PromoImage src={p.imageUrl} alt={p.title ?? ""} />}
|
||||
@@ -98,11 +152,11 @@ export default async function PromotionsPage() {
|
||||
padding: "4px 11px",
|
||||
fontSize: 11,
|
||||
fontWeight: 800,
|
||||
background: isPending ? "#fbeede" : "#e6f4ec",
|
||||
color: isPending ? "#9a6638" : "#1f8a5b",
|
||||
background: chip.bg,
|
||||
color: chip.color,
|
||||
}}
|
||||
>
|
||||
{isPending ? "En attente" : "En ligne"}
|
||||
{chip.label}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ padding: "13px 14px 14px", display: "flex", flexDirection: "column", flex: 1 }}>
|
||||
@@ -116,7 +170,14 @@ export default async function PromotionsPage() {
|
||||
{p.text}
|
||||
</p>
|
||||
|
||||
{isPending ? (
|
||||
{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"}
|
||||
{p.suspendedAt ? ` le ${fmtDate(p.suspendedAt)}` : ""}.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isPending && (
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<form action={moderatePromo} style={{ flex: 1 }}>
|
||||
<input type="hidden" name="id" value={p.id} />
|
||||
@@ -133,18 +194,82 @@ export default async function PromotionsPage() {
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", borderTop: "1px solid #f0e8d6", paddingTop: 11 }}>
|
||||
<span style={{ fontSize: 12.5, color: "#1f8a5b", fontWeight: 700 }}>● En ligne</span>
|
||||
)}
|
||||
|
||||
{!isPending && (
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<form action={moderatePromo} style={{ flex: 1 }}>
|
||||
<input type="hidden" name="id" value={p.id} />
|
||||
<input type="hidden" name="action" value={isSuspended ? "restore" : "suspend"} />
|
||||
<button
|
||||
type="submit"
|
||||
style={{
|
||||
width: "100%",
|
||||
border: isSuspended ? "none" : "1px solid #e0c3bb",
|
||||
background: isSuspended ? "#1f8a5b" : "#fff",
|
||||
color: isSuspended ? "#fff" : "#d8472b",
|
||||
fontWeight: 700,
|
||||
fontSize: 13,
|
||||
padding: 10,
|
||||
borderRadius: 9,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{isSuspended ? "Remettre en ligne" : "Suspendre"}
|
||||
</button>
|
||||
</form>
|
||||
<form action={moderatePromo}>
|
||||
<input type="hidden" name="id" value={p.id} />
|
||||
<input type="hidden" name="action" value="remove" />
|
||||
<button type="submit" style={{ border: "none", background: "transparent", color: "#a99c82", fontWeight: 700, fontSize: 12.5, cursor: "pointer" }}>
|
||||
Retirer
|
||||
<button type="submit" style={{ border: "1px solid #e6dcc6", background: "#fff", color: "#a99c82", fontWeight: 700, fontSize: 13, padding: "10px 12px", borderRadius: 9, cursor: "pointer" }}>
|
||||
Supprimer
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{networks.length > 0 && (
|
||||
<div style={{ borderTop: "1px solid #f0e8d6", marginTop: 12, paddingTop: 11 }}>
|
||||
<div style={{ fontSize: 10.5, letterSpacing: "0.1em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 800, marginBottom: 8 }}>
|
||||
Partager sur les réseaux
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
{networks.map((network) => (
|
||||
<ShareButton
|
||||
key={network}
|
||||
network={network}
|
||||
promoId={p.id}
|
||||
disabled={p.status !== "live"}
|
||||
last={lastShare.get(`${p.id}:${network}`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{networks.map((network) => {
|
||||
const last = lastShare.get(`${p.id}:${network}`);
|
||||
if (!last) return null;
|
||||
return (
|
||||
<div key={network} style={{ marginTop: 8, fontSize: 11.5, lineHeight: 1.45, color: last.status === "posted" ? "#6c6150" : "#d8472b" }}>
|
||||
{last.status === "posted" ? (
|
||||
<>
|
||||
✓ {SOCIAL_LABELS[network]} · {fmtDate(last.createdAt)}
|
||||
{last.authorName ? ` · ${last.authorName}` : ""}
|
||||
{last.url && (
|
||||
<>
|
||||
{" "}
|
||||
<a href={last.url} target="_blank" rel="noopener noreferrer" style={{ color: "#2C6FB3", fontWeight: 700 }}>
|
||||
voir
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>✕ {SOCIAL_LABELS[network]} — échec : {last.error?.slice(0, 160)}</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
@@ -153,3 +278,43 @@ export default async function PromotionsPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ShareButton({
|
||||
network,
|
||||
promoId,
|
||||
disabled,
|
||||
last,
|
||||
}: {
|
||||
network: SocialNetwork;
|
||||
promoId: number;
|
||||
disabled: boolean;
|
||||
last?: { status: string };
|
||||
}) {
|
||||
const alreadyPosted = last?.status === "posted";
|
||||
const brand = network === "facebook" ? "#1877F2" : "#0A66C2";
|
||||
return (
|
||||
<form action={sharePromoToSocial} style={{ flex: "1 1 120px" }}>
|
||||
<input type="hidden" name="id" value={promoId} />
|
||||
<input type="hidden" name="network" value={network} />
|
||||
<button
|
||||
type="submit"
|
||||
disabled={disabled}
|
||||
title={disabled ? "Seule une promotion en ligne peut être publiée." : undefined}
|
||||
style={{
|
||||
width: "100%",
|
||||
border: `1px solid ${brand}`,
|
||||
background: alreadyPosted ? "#fff" : brand,
|
||||
color: alreadyPosted ? brand : "#fff",
|
||||
fontWeight: 700,
|
||||
fontSize: 12.5,
|
||||
padding: "9px 10px",
|
||||
borderRadius: 9,
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
opacity: disabled ? 0.45 : 1,
|
||||
}}
|
||||
>
|
||||
{alreadyPosted ? `Republier sur ${SOCIAL_LABELS[network]}` : `Publier sur ${SOCIAL_LABELS[network]}`}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user