Diffusion réseaux pilotée par la validation de la promotion

Le bouton libre « Publier sur X » permettait de diffuser n'importe quelle promo
en ligne, à tout moment. Le choix appartient désormais à l'adhérent, la
publication part à la validation, et le choix est figé ensuite.

- Colonnes `promotions.share_facebook` / `share_linkedin` : réseaux demandés à la
  soumission, modifiables tant que la promo est `pending`.
- L'adhérent coche les réseaux dans le formulaire de soumission et peut encore
  les changer depuis Mes promotions tant qu'elle est en attente ; une fois
  validée, il ne voit plus qu'un état en lecture seule.
- Le modérateur retrouve ces cases pré-cochées dans le formulaire « Valider » et
  peut les ajuster : c'est le dernier moment où la diffusion est modifiable.
  Valider met en ligne puis publie, dans cet ordre, afin qu'un réseau en panne
  ne bloque pas la mise en ligne.
- `publishPromoShares` devient le seul point de publication. Elle ne lève jamais
  et ignore tout réseau ayant déjà une publication réussie : une promo n'est
  jamais postée deux fois, y compris après un cycle suspension → remise en ligne.
- `sharePromoToSocial` est remplacée par `retryPromoShare`, limitée au rattrapage
  d'un échec sur un réseau déjà choisi, sur une promo en ligne uniquement.
- Un réseau non configuré sur le serveur enregistre un échec explicite plutôt
  que d'échouer en silence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
This commit is contained in:
Claude committed 2026-07-24 22:41:25 +00:00
1 parent e3a4771edf
commit 8bc1b17fc5
10 files changed
+1703 -131

No files matched your search

@@ -4,16 +4,25 @@ import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { publishPromo } from "../actions";
import { PromoImage } from "@/components/PromoImage";
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
import type { SocialNetwork } from "@/lib/social";
const STRIPE_WARM =
"repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)";
const NETWORK_LABELS: Record<SocialNetwork, string> = {
facebook: "Facebook",
linkedin: "LinkedIn",
};
export function MemberSpaceForm({
memberName,
categories,
networks = [],
}: {
memberName: string;
categories: string[];
networks?: SocialNetwork[];
}) {
const CATEGORIES = categories.length > 0 ? categories : ["Autre"];
const router = useRouter();
@@ -22,10 +31,16 @@ export function MemberSpaceForm({
const [cat, setCat] = useState(CATEGORIES[0]);
const [badge, setBadge] = useState("");
const [imgData, setImgData] = useState("");
const [shares, setShares] = useState<SocialNetwork[]>([]);
const [submitted, setSubmitted] = useState(false);
const [pending, setPending] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
function toggleShare(network: SocialNetwork) {
setShares((s) => (s.includes(network) ? s.filter((n) => n !== network) : [...s, network]));
setSubmitted(false);
}
function onFile(e: React.ChangeEvent<HTMLInputElement>) {
const f = e.target.files?.[0];
if (!f) return;
@@ -42,6 +57,7 @@ export function MemberSpaceForm({
setText("");
setBadge("");
setImgData("");
setShares([]);
setSubmitted(false);
if (fileRef.current) fileRef.current.value = "";
}
@@ -55,6 +71,8 @@ export function MemberSpaceForm({
fd.set("category", cat);
fd.set("badge", badge);
fd.set("imageUrl", imgData);
if (shares.includes("facebook")) fd.set("shareFacebook", "on");
if (shares.includes("linkedin")) fd.set("shareLinkedin", "on");
await publishPromo(fd);
setPending(false);
setSubmitted(true);
@@ -153,6 +171,50 @@ export function MemberSpaceForm({
/>
<div style={{ fontSize: 12, color: "#a99c82", marginBottom: 18 }}>{text.length} / 240 caractères</div>
{networks.length > 0 && (
<div style={{ border: "1px solid #f0e8d6", background: "#faf7ef", borderRadius: 12, padding: "14px 15px", marginBottom: 18 }}>
<div className="field-label" style={{ marginBottom: 4 }}>
Diffusion sur les réseaux Plein R
</div>
<div style={{ fontSize: 12.5, color: "#8c8068", lineHeight: 1.5, marginBottom: 11 }}>
Choisissez où l&apos;association relaiera votre offre. La publication n&apos;a lieu
qu&apos;une fois la promotion validée — et le choix n&apos;est plus modifiable ensuite.
</div>
<div style={{ display: "flex", gap: 9, flexWrap: "wrap" }}>
{networks.map((network) => {
const active = shares.includes(network);
return (
<label
key={network}
style={{
display: "inline-flex",
alignItems: "center",
gap: 9,
cursor: "pointer",
padding: "9px 14px",
borderRadius: 11,
border: `1px solid ${active ? SOCIAL_BRAND[network] : "#e6dcc6"}`,
background: active ? "#fff" : "transparent",
color: active ? SOCIAL_BRAND[network] : "#6c6150",
fontSize: 13.5,
fontWeight: 700,
}}
>
<input
type="checkbox"
checked={active}
onChange={() => toggleShare(network)}
style={{ accentColor: SOCIAL_BRAND[network], margin: 0 }}
/>
<SocialIcon network={network} size={16} />
{NETWORK_LABELS[network]}
</label>
);
})}
</div>
</div>
)}
<div style={{ display: "flex", gap: 10 }}>
<button
onClick={submit}