mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-24 07:38:04 +02:00
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:
10 files changed
+1703
-131
No files matched your search
@@ -53,12 +53,20 @@ site sans traitement supplémentaire.
|
|||||||
|
|
||||||
- `src/lib/social.ts` publie une promo sur la page Facebook (Graph API) ou
|
- `src/lib/social.ts` publie une promo sur la page Facebook (Graph API) ou
|
||||||
LinkedIn (Posts API). Jetons **uniquement** en variables d'environnement, jamais
|
LinkedIn (Posts API). Jetons **uniquement** en variables d'environnement, jamais
|
||||||
en base ni dans le backoffice. Réseau non configuré = bouton masqué.
|
en base ni dans le backoffice. Réseau non configuré = case masquée.
|
||||||
- Les images de promo sont stockées en data-URI : l'upload se fait donc en
|
- Les images de promo sont stockées en data-URI : l'upload se fait donc en
|
||||||
binaire (multipart pour Facebook, Images API en 3 étapes pour LinkedIn), pas
|
binaire (multipart pour Facebook, Images API en 3 étapes pour LinkedIn), pas
|
||||||
par URL.
|
par URL.
|
||||||
- Chaque tentative est journalisée dans `social_posts` (succès ou échec) ; un
|
- La diffusion est déclenchée **par la validation**, pas par un bouton :
|
||||||
échec n'interrompt pas l'action, il s'affiche sur la carte de la promo.
|
`promotions.share_facebook` / `share_linkedin` sont choisis par l'adhérent,
|
||||||
|
ajustables par le modérateur dans le formulaire « Valider », puis figés
|
||||||
|
(`status !== 'pending'`).
|
||||||
|
- `publishPromoShares()` dans `backend/actions.ts` est le seul point de
|
||||||
|
publication. Elle ne lève jamais et ignore tout réseau ayant déjà une ligne
|
||||||
|
`social_posts` en `posted` : c'est la garde anti-republication, qui couvre
|
||||||
|
aussi le cycle suspension → remise en ligne.
|
||||||
|
- `retryPromoShare` ne sert qu'au rattrapage d'un échec sur un réseau déjà
|
||||||
|
choisi ; il ne peut pas élargir la diffusion.
|
||||||
- Les URLs publiques des pages FB/LinkedIn sont des `site_settings`
|
- Les URLs publiques des pages FB/LinkedIn sont des `site_settings`
|
||||||
(`association_facebook`, `association_linkedin`), éditables dans Paramètres.
|
(`association_facebook`, `association_linkedin`), éditables dans Paramètres.
|
||||||
|
|
||||||
|
|||||||
@@ -83,11 +83,24 @@ charge des données de démonstration. Ensuite :
|
|||||||
|
|
||||||
## Publication sur Facebook / LinkedIn
|
## Publication sur Facebook / LinkedIn
|
||||||
|
|
||||||
Depuis **Backend › Promotions**, une promotion `live` peut être publiée sur la
|
La diffusion est **pilotée par la validation**, jamais déclenchée à la main :
|
||||||
page Facebook et/ou la page LinkedIn de l'association (image + texte + lien vers
|
|
||||||
la fiche adhérent). Chaque tentative est tracée dans la table `social_posts`
|
1. L'adhérent coche Facebook et/ou LinkedIn en soumettant sa promotion. Rien
|
||||||
(succès avec le lien du post, ou échec avec le message d'erreur affiché sur la
|
n'est publié à ce stade ; il peut modifier son choix tant que la promo est
|
||||||
carte).
|
`pending`.
|
||||||
|
2. Le modérateur voit ces cases pré-cochées sur la carte en attente et peut les
|
||||||
|
ajuster — c'est le dernier moment où le choix est modifiable.
|
||||||
|
3. **Valider** met la promo en ligne *et* publie sur les réseaux retenus (image +
|
||||||
|
texte + lien vers la fiche adhérent). Après validation, le choix est figé.
|
||||||
|
|
||||||
|
Chaque tentative est tracée dans `social_posts` : succès avec le lien du post, ou
|
||||||
|
échec avec le message d'erreur affiché sur la carte. En cas d'échec, un bouton
|
||||||
|
**Réessayer** apparaît, limité aux réseaux déjà choisis — il ne permet pas
|
||||||
|
d'élargir la diffusion.
|
||||||
|
|
||||||
|
Une promotion n'est jamais publiée deux fois : un réseau ayant déjà une
|
||||||
|
publication réussie est systématiquement ignoré, y compris sur un cycle
|
||||||
|
suspension → remise en ligne.
|
||||||
|
|
||||||
Les jetons d'accès sont des **secrets** : ils se configurent uniquement par
|
Les jetons d'accès sont des **secrets** : ils se configurent uniquement par
|
||||||
variables d'environnement (`FACEBOOK_PAGE_ID`, `FACEBOOK_PAGE_ACCESS_TOKEN`,
|
variables d'environnement (`FACEBOOK_PAGE_ID`, `FACEBOOK_PAGE_ACCESS_TOKEN`,
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "promotions" ADD COLUMN "share_facebook" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "promotions" ADD COLUMN "share_linkedin" boolean DEFAULT false NOT NULL;
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -50,6 +50,13 @@
|
|||||||
"when": 1784930674056,
|
"when": 1784930674056,
|
||||||
"tag": "0006_tiresome_clea",
|
"tag": "0006_tiresome_clea",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 7,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1784931989372,
|
||||||
|
"tag": "0007_foamy_vindicator",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+139
-45
@@ -111,6 +111,97 @@ function revalidatePromoPaths(memberId?: number | null) {
|
|||||||
if (memberId) revalidatePath(`/adherents/${memberId}`);
|
if (memberId) revalidatePath(`/adherents/${memberId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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",
|
||||||
|
shareLinkedin: formData.get("shareLinkedin") === "on",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
imageUrl: promotions.imageUrl,
|
||||||
|
memberId: promotions.memberId,
|
||||||
|
memberName: members.name,
|
||||||
|
})
|
||||||
|
.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 (!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 ----
|
// ---- Promotion moderation ----
|
||||||
export async function moderatePromo(formData: FormData) {
|
export async function moderatePromo(formData: FormData) {
|
||||||
const { role, userId, name } = await requireRole();
|
const { role, userId, name } = await requireRole();
|
||||||
@@ -124,11 +215,23 @@ export async function moderatePromo(formData: FormData) {
|
|||||||
if (!promo) return;
|
if (!promo) return;
|
||||||
|
|
||||||
if (action === "approve") {
|
if (action === "approve") {
|
||||||
|
// 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);
|
||||||
await db
|
await db
|
||||||
.update(promotions)
|
.update(promotions)
|
||||||
.set({ status: "live", suspendedBy: null, suspendedById: null, suspendedAt: null })
|
.set({
|
||||||
|
status: "live",
|
||||||
|
...targets,
|
||||||
|
suspendedBy: null,
|
||||||
|
suspendedById: null,
|
||||||
|
suspendedAt: null,
|
||||||
|
})
|
||||||
.where(eq(promotions.id, id));
|
.where(eq(promotions.id, id));
|
||||||
await logActivity(`Promotion « ${promo.title} » validée et mise en ligne`, "#1f8a5b");
|
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 if (action === "suspend") {
|
} else if (action === "suspend") {
|
||||||
// Suspension par l'association : la promotion sort du site public et
|
// Suspension par l'association : la promotion sort du site public et
|
||||||
// l'adhérent ne peut pas la remettre en ligne lui-même.
|
// l'adhérent ne peut pas la remettre en ligne lui-même.
|
||||||
@@ -193,63 +296,52 @@ export async function setOwnPromoSuspension(formData: FormData) {
|
|||||||
revalidatePromoPaths(memberId);
|
revalidatePromoPaths(memberId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Publication d'une promotion sur Facebook / LinkedIn ----
|
// ---- Rattrapage d'une publication en échec ----
|
||||||
export async function sharePromoToSocial(formData: FormData) {
|
// Ne permet pas d'élargir la diffusion : uniquement de retenter un réseau déjà
|
||||||
const { role, userId, name } = await requireRole();
|
// choisi avant la validation, et qui n'est pas encore passé.
|
||||||
|
export async function retryPromoShare(formData: FormData) {
|
||||||
|
const { role, userId } = await requireRole();
|
||||||
if (!can(role, "publishSocial")) throw new Error("Accès refusé");
|
if (!can(role, "publishSocial")) throw new Error("Accès refusé");
|
||||||
|
|
||||||
const id = Number(formData.get("id"));
|
const id = Number(formData.get("id"));
|
||||||
const network = String(formData.get("network")) as SocialNetwork;
|
const network = String(formData.get("network")) as SocialNetwork;
|
||||||
if (!id || !SOCIAL_NETWORKS.includes(network)) return;
|
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
|
const [promo] = await db.select().from(promotions).where(eq(promotions.id, id));
|
||||||
.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) throw new Error("Promotion introuvable.");
|
||||||
if (promo.status !== "live") {
|
if (promo.status !== "live") {
|
||||||
throw new Error("Seule une promotion en ligne peut être publiée sur les réseaux.");
|
throw new Error("Seule une promotion en ligne peut être publiée sur les réseaux.");
|
||||||
}
|
}
|
||||||
|
if (!requestedNetworks(promo).includes(network)) {
|
||||||
try {
|
throw new Error(
|
||||||
const result = await publishPromoToNetwork(network, promo);
|
`${SOCIAL_LABELS[network]} n'a pas été demandé pour cette promotion : le choix est figé depuis sa validation.`
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await publishPromoShares(id, [network], userId);
|
||||||
|
revalidatePath("/backend/promotions");
|
||||||
|
revalidatePath("/backend/espace");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Member space: choisir les réseaux tant que la promo est en attente ----
|
||||||
|
export async function setOwnPromoShareTargets(formData: FormData) {
|
||||||
|
const { memberId } = await requireRole();
|
||||||
|
if (!memberId) throw new Error("Aucune fiche adhérent liée à votre compte.");
|
||||||
|
|
||||||
|
const id = Number(formData.get("id"));
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
const [promo] = await db
|
||||||
|
.select({ status: promotions.status })
|
||||||
|
.from(promotions)
|
||||||
|
.where(and(eq(promotions.id, id), eq(promotions.memberId, memberId)));
|
||||||
|
if (!promo) throw new Error("Promotion introuvable.");
|
||||||
|
if (promo.status !== "pending") {
|
||||||
|
throw new Error("La diffusion n'est plus modifiable une fois la promotion validée.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.update(promotions).set(readShareTargets(formData)).where(eq(promotions.id, id));
|
||||||
|
revalidatePath("/backend/espace");
|
||||||
revalidatePath("/backend/promotions");
|
revalidatePath("/backend/promotions");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,6 +357,7 @@ export async function publishPromo(formData: FormData) {
|
|||||||
const badge = String(formData.get("badge") ?? "").trim() || null;
|
const badge = String(formData.get("badge") ?? "").trim() || null;
|
||||||
const imageUrl = String(formData.get("imageUrl") ?? "") || null;
|
const imageUrl = String(formData.get("imageUrl") ?? "") || null;
|
||||||
|
|
||||||
|
// Réseaux souhaités : rien n'est publié ici, la diffusion attend la validation.
|
||||||
await db.insert(promotions).values({
|
await db.insert(promotions).values({
|
||||||
title,
|
title,
|
||||||
text,
|
text,
|
||||||
@@ -273,6 +366,7 @@ export async function publishPromo(formData: FormData) {
|
|||||||
imageUrl,
|
imageUrl,
|
||||||
memberId: memberId ?? null,
|
memberId: memberId ?? null,
|
||||||
status: "pending",
|
status: "pending",
|
||||||
|
...readShareTargets(formData),
|
||||||
});
|
});
|
||||||
|
|
||||||
await logActivity(`<strong>${name}</strong> a soumis une promotion « ${title} »`, "#E0A63C");
|
await logActivity(`<strong>${name}</strong> a soumis une promotion « ${title} »`, "#E0A63C");
|
||||||
|
|||||||
@@ -4,16 +4,25 @@ import { useRef, useState } from "react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { publishPromo } from "../actions";
|
import { publishPromo } from "../actions";
|
||||||
import { PromoImage } from "@/components/PromoImage";
|
import { PromoImage } from "@/components/PromoImage";
|
||||||
|
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
|
||||||
|
import type { SocialNetwork } from "@/lib/social";
|
||||||
|
|
||||||
const STRIPE_WARM =
|
const STRIPE_WARM =
|
||||||
"repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)";
|
"repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)";
|
||||||
|
|
||||||
|
const NETWORK_LABELS: Record<SocialNetwork, string> = {
|
||||||
|
facebook: "Facebook",
|
||||||
|
linkedin: "LinkedIn",
|
||||||
|
};
|
||||||
|
|
||||||
export function MemberSpaceForm({
|
export function MemberSpaceForm({
|
||||||
memberName,
|
memberName,
|
||||||
categories,
|
categories,
|
||||||
|
networks = [],
|
||||||
}: {
|
}: {
|
||||||
memberName: string;
|
memberName: string;
|
||||||
categories: string[];
|
categories: string[];
|
||||||
|
networks?: SocialNetwork[];
|
||||||
}) {
|
}) {
|
||||||
const CATEGORIES = categories.length > 0 ? categories : ["Autre"];
|
const CATEGORIES = categories.length > 0 ? categories : ["Autre"];
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -22,10 +31,16 @@ export function MemberSpaceForm({
|
|||||||
const [cat, setCat] = useState(CATEGORIES[0]);
|
const [cat, setCat] = useState(CATEGORIES[0]);
|
||||||
const [badge, setBadge] = useState("");
|
const [badge, setBadge] = useState("");
|
||||||
const [imgData, setImgData] = useState("");
|
const [imgData, setImgData] = useState("");
|
||||||
|
const [shares, setShares] = useState<SocialNetwork[]>([]);
|
||||||
const [submitted, setSubmitted] = useState(false);
|
const [submitted, setSubmitted] = useState(false);
|
||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
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>) {
|
function onFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
const f = e.target.files?.[0];
|
const f = e.target.files?.[0];
|
||||||
if (!f) return;
|
if (!f) return;
|
||||||
@@ -42,6 +57,7 @@ export function MemberSpaceForm({
|
|||||||
setText("");
|
setText("");
|
||||||
setBadge("");
|
setBadge("");
|
||||||
setImgData("");
|
setImgData("");
|
||||||
|
setShares([]);
|
||||||
setSubmitted(false);
|
setSubmitted(false);
|
||||||
if (fileRef.current) fileRef.current.value = "";
|
if (fileRef.current) fileRef.current.value = "";
|
||||||
}
|
}
|
||||||
@@ -55,6 +71,8 @@ export function MemberSpaceForm({
|
|||||||
fd.set("category", cat);
|
fd.set("category", cat);
|
||||||
fd.set("badge", badge);
|
fd.set("badge", badge);
|
||||||
fd.set("imageUrl", imgData);
|
fd.set("imageUrl", imgData);
|
||||||
|
if (shares.includes("facebook")) fd.set("shareFacebook", "on");
|
||||||
|
if (shares.includes("linkedin")) fd.set("shareLinkedin", "on");
|
||||||
await publishPromo(fd);
|
await publishPromo(fd);
|
||||||
setPending(false);
|
setPending(false);
|
||||||
setSubmitted(true);
|
setSubmitted(true);
|
||||||
@@ -153,6 +171,50 @@ export function MemberSpaceForm({
|
|||||||
/>
|
/>
|
||||||
<div style={{ fontSize: 12, color: "#a99c82", marginBottom: 18 }}>{text.length} / 240 caractères</div>
|
<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'association relaiera votre offre. La publication n'a lieu
|
||||||
|
qu'une fois la promotion validée — et le choix n'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 }}>
|
<div style={{ display: "flex", gap: 10 }}>
|
||||||
<button
|
<button
|
||||||
onClick={submit}
|
onClick={submit}
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { and, asc, desc, eq, gte, sql } from "drizzle-orm";
|
import { and, asc, desc, eq, gte, inArray, sql } from "drizzle-orm";
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { categories, imageConsents, meetingRegistrations, meetings, members, promotions, type Member } from "@/db/schema";
|
import { categories, imageConsents, meetingRegistrations, meetings, members, promotions, socialPosts, type Member } from "@/db/schema";
|
||||||
|
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
|
||||||
|
import { configuredNetworks, SOCIAL_LABELS, type SocialNetwork } from "@/lib/social";
|
||||||
import { ImageField } from "@/components/ImageField";
|
import { ImageField } from "@/components/ImageField";
|
||||||
import { ImageConsentForm } from "@/components/ImageConsentForm";
|
import { ImageConsentForm } from "@/components/ImageConsentForm";
|
||||||
import { HoursEditor } from "@/components/HoursEditor";
|
import { HoursEditor } from "@/components/HoursEditor";
|
||||||
import { communeOptions } from "@/lib/communes";
|
import { communeOptions } from "@/lib/communes";
|
||||||
import { saveImageConsent, setOwnPromoSuspension, updateOwnProfile } from "../actions";
|
import {
|
||||||
|
saveImageConsent,
|
||||||
|
setOwnPromoShareTargets,
|
||||||
|
setOwnPromoSuspension,
|
||||||
|
updateOwnProfile,
|
||||||
|
} from "../actions";
|
||||||
import { MemberSpaceForm } from "./MemberSpaceForm";
|
import { MemberSpaceForm } from "./MemberSpaceForm";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -39,7 +46,11 @@ export default async function EspacePage() {
|
|||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
imageUrl: string | null;
|
imageUrl: string | null;
|
||||||
suspendedBy: "member" | "staff" | null;
|
suspendedBy: "member" | "staff" | null;
|
||||||
|
shareFacebook: boolean;
|
||||||
|
shareLinkedin: boolean;
|
||||||
}[] = [];
|
}[] = [];
|
||||||
|
// Dernière tentative de publication par (promo, réseau).
|
||||||
|
let lastShare = new Map<string, { status: string; url: string | null; error: string | null }>();
|
||||||
let myRegistrations: { meetingId: number; title: string; startsAt: Date; location: string | null; participants: number; confirmed: boolean }[] = [];
|
let myRegistrations: { meetingId: number; title: string; startsAt: Date; location: string | null; participants: number; confirmed: boolean }[] = [];
|
||||||
let latestConsent: { decision: string; createdAt: Date } | null = null;
|
let latestConsent: { decision: string; createdAt: Date } | null = null;
|
||||||
|
|
||||||
@@ -61,10 +72,31 @@ export default async function EspacePage() {
|
|||||||
createdAt: promotions.createdAt,
|
createdAt: promotions.createdAt,
|
||||||
imageUrl: promotions.imageUrl,
|
imageUrl: promotions.imageUrl,
|
||||||
suspendedBy: promotions.suspendedBy,
|
suspendedBy: promotions.suspendedBy,
|
||||||
|
shareFacebook: promotions.shareFacebook,
|
||||||
|
shareLinkedin: promotions.shareLinkedin,
|
||||||
})
|
})
|
||||||
.from(promotions)
|
.from(promotions)
|
||||||
.where(eq(promotions.memberId, memberId))
|
.where(eq(promotions.memberId, memberId))
|
||||||
.orderBy(desc(promotions.createdAt));
|
.orderBy(desc(promotions.createdAt));
|
||||||
|
if (myPromos.length > 0) {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
promotionId: socialPosts.promotionId,
|
||||||
|
network: socialPosts.network,
|
||||||
|
status: socialPosts.status,
|
||||||
|
url: socialPosts.url,
|
||||||
|
error: socialPosts.error,
|
||||||
|
})
|
||||||
|
.from(socialPosts)
|
||||||
|
.where(inArray(socialPosts.promotionId, myPromos.map((p) => p.id)))
|
||||||
|
.orderBy(desc(socialPosts.createdAt));
|
||||||
|
// Trié du plus récent au plus ancien : la première occurrence gagne.
|
||||||
|
lastShare = new Map();
|
||||||
|
for (const row of rows) {
|
||||||
|
const key = `${row.promotionId}:${row.network}`;
|
||||||
|
if (!lastShare.has(key)) lastShare.set(key, row);
|
||||||
|
}
|
||||||
|
}
|
||||||
myRegistrations = await db
|
myRegistrations = await db
|
||||||
.select({
|
.select({
|
||||||
meetingId: meetings.id,
|
meetingId: meetings.id,
|
||||||
@@ -93,6 +125,7 @@ export default async function EspacePage() {
|
|||||||
.from(categories)
|
.from(categories)
|
||||||
.orderBy(asc(categories.sort));
|
.orderBy(asc(categories.sort));
|
||||||
const categoryLabels = catRows.map((c) => c.label);
|
const categoryLabels = catRows.map((c) => c.label);
|
||||||
|
const networks = configuredNetworks();
|
||||||
|
|
||||||
function fmtDate(d: Date) {
|
function fmtDate(d: Date) {
|
||||||
return new Date(d).toLocaleDateString("fr-FR", { day: "numeric", month: "long" });
|
return new Date(d).toLocaleDateString("fr-FR", { day: "numeric", month: "long" });
|
||||||
@@ -247,7 +280,7 @@ export default async function EspacePage() {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<MemberSpaceForm memberName={memberName} categories={categoryLabels} />
|
<MemberSpaceForm memberName={memberName} categories={categoryLabels} networks={networks} />
|
||||||
|
|
||||||
<div style={{ marginTop: 28 }}>
|
<div style={{ marginTop: 28 }}>
|
||||||
<div style={{ fontSize: 12.5, letterSpacing: "0.08em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 700, marginBottom: 4 }}>
|
<div style={{ fontSize: 12.5, letterSpacing: "0.08em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 700, marginBottom: 4 }}>
|
||||||
@@ -314,6 +347,14 @@ export default async function EspacePage() {
|
|||||||
remettre en ligne.
|
remettre en ligne.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{networks.length > 0 && (
|
||||||
|
<PromoShareState
|
||||||
|
promo={mp}
|
||||||
|
networks={networks}
|
||||||
|
lastShare={lastShare}
|
||||||
|
editable={mp.status === "pending"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -321,3 +362,77 @@ export default async function EspacePage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* État de diffusion d'une promo côté adhérent : modifiable tant qu'elle est en
|
||||||
|
* attente, purement informatif dès qu'elle est validée.
|
||||||
|
*/
|
||||||
|
function PromoShareState({
|
||||||
|
promo,
|
||||||
|
networks,
|
||||||
|
lastShare,
|
||||||
|
editable,
|
||||||
|
}: {
|
||||||
|
promo: { id: number; shareFacebook: boolean; shareLinkedin: boolean };
|
||||||
|
networks: SocialNetwork[];
|
||||||
|
lastShare: Map<string, { status: string; url: string | null; error: string | null }>;
|
||||||
|
editable: boolean;
|
||||||
|
}) {
|
||||||
|
const wanted = (n: SocialNetwork) => (n === "facebook" ? promo.shareFacebook : promo.shareLinkedin);
|
||||||
|
const chosen = networks.filter(wanted);
|
||||||
|
|
||||||
|
if (editable) {
|
||||||
|
return (
|
||||||
|
<form action={setOwnPromoShareTargets} style={{ marginTop: 11, borderTop: "1px solid #f0e8d6", paddingTop: 11 }}>
|
||||||
|
<input type="hidden" name="id" value={promo.id} />
|
||||||
|
<div style={{ fontSize: 11.5, color: "#8c8068", marginBottom: 8, lineHeight: 1.5 }}>
|
||||||
|
Diffusion prévue après validation — modifiable tant que la promotion est en attente.
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 10, flexWrap: "wrap", alignItems: "center" }}>
|
||||||
|
{networks.map((network) => (
|
||||||
|
<label key={network} style={{ display: "inline-flex", alignItems: "center", gap: 7, fontSize: 12.5, fontWeight: 700, color: SOCIAL_BRAND[network], cursor: "pointer" }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name={network === "facebook" ? "shareFacebook" : "shareLinkedin"}
|
||||||
|
defaultChecked={wanted(network)}
|
||||||
|
style={{ accentColor: SOCIAL_BRAND[network], margin: 0 }}
|
||||||
|
/>
|
||||||
|
<SocialIcon network={network} size={15} />
|
||||||
|
{SOCIAL_LABELS[network]}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
<button type="submit" style={{ border: "1px solid #d8cdb4", background: "#fff", color: "#6c6150", fontWeight: 700, fontSize: 12, padding: "6px 12px", borderRadius: 8, cursor: "pointer" }}>
|
||||||
|
Mettre à jour
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chosen.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 11, borderTop: "1px solid #f0e8d6", paddingTop: 11, display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||||
|
{chosen.map((network) => {
|
||||||
|
const last = lastShare.get(`${promo.id}:${network}`);
|
||||||
|
const posted = last?.status === "posted";
|
||||||
|
const failed = last?.status === "failed";
|
||||||
|
const detail = posted ? "publié" : failed ? "échec de publication" : "en attente";
|
||||||
|
const color = posted ? "#1f8a5b" : failed ? "#d8472b" : "#9a6638";
|
||||||
|
const chip = (
|
||||||
|
<span style={{ display: "inline-flex", alignItems: "center", gap: 7, background: "#faf7ef", border: "1px solid #f0e8d6", borderRadius: 999, padding: "5px 12px", fontSize: 11.5, fontWeight: 700, color }}>
|
||||||
|
<SocialIcon network={network} size={13} />
|
||||||
|
{SOCIAL_LABELS[network]} · {detail}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
return posted && last?.url ? (
|
||||||
|
<a key={network} href={last.url} target="_blank" rel="noopener noreferrer" style={{ textDecoration: "none" }}>
|
||||||
|
{chip}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span key={network}>{chip}</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,9 +4,10 @@ import { auth } from "@/auth";
|
|||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { members, promotions, socialPosts, users } from "@/db/schema";
|
import { members, promotions, socialPosts, users } from "@/db/schema";
|
||||||
import { can } from "@/lib/rbac";
|
import { can } from "@/lib/rbac";
|
||||||
import { configuredNetworks, SOCIAL_LABELS, type SocialNetwork } from "@/lib/social";
|
import { configuredNetworks, SOCIAL_LABELS } from "@/lib/social";
|
||||||
import { moderatePromo, sharePromoToSocial } from "../actions";
|
import { moderatePromo, retryPromoShare } from "../actions";
|
||||||
import { PromoImage } from "@/components/PromoImage";
|
import { PromoImage } from "@/components/PromoImage";
|
||||||
|
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -43,6 +44,8 @@ export default async function PromotionsPage() {
|
|||||||
imageUrl: promotions.imageUrl,
|
imageUrl: promotions.imageUrl,
|
||||||
suspendedBy: promotions.suspendedBy,
|
suspendedBy: promotions.suspendedBy,
|
||||||
suspendedAt: promotions.suspendedAt,
|
suspendedAt: promotions.suspendedAt,
|
||||||
|
shareFacebook: promotions.shareFacebook,
|
||||||
|
shareLinkedin: promotions.shareLinkedin,
|
||||||
memberName: members.name,
|
memberName: members.name,
|
||||||
})
|
})
|
||||||
.from(promotions)
|
.from(promotions)
|
||||||
@@ -113,6 +116,9 @@ export default async function PromotionsPage() {
|
|||||||
const isPending = p.status === "pending";
|
const isPending = p.status === "pending";
|
||||||
const isSuspended = p.status === "suspended";
|
const isSuspended = p.status === "suspended";
|
||||||
const chip = STATUS_CHIP[p.status] ?? STATUS_CHIP.pending;
|
const chip = STATUS_CHIP[p.status] ?? STATUS_CHIP.pending;
|
||||||
|
const chosenNetworks = networks.filter((n) =>
|
||||||
|
n === "facebook" ? p.shareFacebook : p.shareLinkedin
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
key={p.id}
|
key={p.id}
|
||||||
@@ -178,15 +184,43 @@ export default async function PromotionsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isPending && (
|
{isPending && (
|
||||||
<div style={{ display: "flex", gap: 8 }}>
|
<div style={{ display: "flex", gap: 8, alignItems: "stretch" }}>
|
||||||
|
{/* Les cases vivent dans le formulaire « Valider » : c'est le
|
||||||
|
seul et dernier moment où la diffusion est ajustable. */}
|
||||||
<form action={moderatePromo} style={{ flex: 1 }}>
|
<form action={moderatePromo} style={{ flex: 1 }}>
|
||||||
<input type="hidden" name="id" value={p.id} />
|
<input type="hidden" name="id" value={p.id} />
|
||||||
<input type="hidden" name="action" value="approve" />
|
<input type="hidden" name="action" value="approve" />
|
||||||
|
{networks.length > 0 && (
|
||||||
|
<div style={{ background: "#faf7ef", border: "1px solid #f0e8d6", borderRadius: 10, padding: "10px 11px", marginBottom: 8 }}>
|
||||||
|
<div style={{ fontSize: 10.5, letterSpacing: "0.08em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 800, marginBottom: 7 }}>
|
||||||
|
Diffusion demandée
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||||
|
{networks.map((network) => (
|
||||||
|
<label key={network} style={{ display: "flex", alignItems: "center", gap: 7, fontSize: 12.5, fontWeight: 700, color: SOCIAL_BRAND[network], cursor: "pointer" }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name={network === "facebook" ? "shareFacebook" : "shareLinkedin"}
|
||||||
|
defaultChecked={network === "facebook" ? p.shareFacebook : p.shareLinkedin}
|
||||||
|
style={{ accentColor: SOCIAL_BRAND[network], margin: 0 }}
|
||||||
|
/>
|
||||||
|
<SocialIcon network={network} size={14} />
|
||||||
|
{SOCIAL_LABELS[network]}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: "#a99c82", marginTop: 7, lineHeight: 1.45 }}>
|
||||||
|
Publié dès la validation. Ce choix n'est plus modifiable ensuite.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<button type="submit" style={{ width: "100%", border: "none", background: "#1f8a5b", color: "#fff", fontWeight: 700, fontSize: 13, padding: 10, borderRadius: 9, cursor: "pointer" }}>
|
<button type="submit" style={{ width: "100%", border: "none", background: "#1f8a5b", color: "#fff", fontWeight: 700, fontSize: 13, padding: 10, borderRadius: 9, cursor: "pointer" }}>
|
||||||
Valider
|
Valider
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<form action={moderatePromo} style={{ flex: 1 }}>
|
{/* Aligné en bas pour rester au niveau du bouton Valider,
|
||||||
|
que le bloc diffusion rend plus haut. */}
|
||||||
|
<form action={moderatePromo} style={{ flex: 1, display: "flex", flexDirection: "column", justifyContent: "flex-end" }}>
|
||||||
<input type="hidden" name="id" value={p.id} />
|
<input type="hidden" name="id" value={p.id} />
|
||||||
<input type="hidden" name="action" value="reject" />
|
<input type="hidden" name="action" value="reject" />
|
||||||
<button type="submit" style={{ width: "100%", border: "1px solid #e0c3bb", background: "#fff", color: "#d8472b", fontWeight: 700, fontSize: 13, padding: 10, borderRadius: 9, cursor: "pointer" }}>
|
<button type="submit" style={{ width: "100%", border: "1px solid #e0c3bb", background: "#fff", color: "#d8472b", fontWeight: 700, fontSize: 13, padding: 10, borderRadius: 9, cursor: "pointer" }}>
|
||||||
@@ -228,42 +262,53 @@ export default async function PromotionsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{networks.length > 0 && (
|
{!isPending && chosenNetworks.length > 0 && (
|
||||||
<div style={{ borderTop: "1px solid #f0e8d6", marginTop: 12, paddingTop: 11 }}>
|
<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 }}>
|
<div style={{ fontSize: 10.5, letterSpacing: "0.1em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 800, marginBottom: 8 }}>
|
||||||
Partager sur les réseaux
|
Diffusion réseaux
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
{chosenNetworks.map((network) => {
|
||||||
{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}`);
|
const last = lastShare.get(`${p.id}:${network}`);
|
||||||
if (!last) return null;
|
const posted = last?.status === "posted";
|
||||||
return (
|
return (
|
||||||
<div key={network} style={{ marginTop: 8, fontSize: 11.5, lineHeight: 1.45, color: last.status === "posted" ? "#6c6150" : "#d8472b" }}>
|
<div key={network} style={{ marginBottom: 8 }}>
|
||||||
{last.status === "posted" ? (
|
<div style={{ display: "flex", alignItems: "center", gap: 7, fontSize: 12, fontWeight: 700, color: posted ? "#1f8a5b" : "#d8472b" }}>
|
||||||
<>
|
<span style={{ color: SOCIAL_BRAND[network], display: "inline-flex" }}>
|
||||||
✓ {SOCIAL_LABELS[network]} · {fmtDate(last.createdAt)}
|
<SocialIcon network={network} size={14} />
|
||||||
{last.authorName ? ` · ${last.authorName}` : ""}
|
</span>
|
||||||
{last.url && (
|
{SOCIAL_LABELS[network]} ·{" "}
|
||||||
<>
|
{posted ? `publié le ${fmtDate(last!.createdAt)}` : "non publié"}
|
||||||
{" "}
|
{posted && last?.url && (
|
||||||
<a href={last.url} target="_blank" rel="noopener noreferrer" style={{ color: "#2C6FB3", fontWeight: 700 }}>
|
<a href={last.url} target="_blank" rel="noopener noreferrer" style={{ color: "#2C6FB3", fontWeight: 700 }}>
|
||||||
voir
|
voir
|
||||||
</a>
|
</a>
|
||||||
</>
|
)}
|
||||||
)}
|
</div>
|
||||||
</>
|
{!posted && last?.error && (
|
||||||
) : (
|
<div style={{ fontSize: 11, color: "#a8503c", lineHeight: 1.45, marginTop: 3 }}>
|
||||||
<>✕ {SOCIAL_LABELS[network]} — échec : {last.error?.slice(0, 160)}</>
|
{last.error.slice(0, 200)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!posted && p.status === "live" && (
|
||||||
|
<form action={retryPromoShare} style={{ marginTop: 6 }}>
|
||||||
|
<input type="hidden" name="id" value={p.id} />
|
||||||
|
<input type="hidden" name="network" value={network} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
style={{
|
||||||
|
border: `1px solid ${SOCIAL_BRAND[network]}`,
|
||||||
|
background: "#fff",
|
||||||
|
color: SOCIAL_BRAND[network],
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: 12,
|
||||||
|
padding: "7px 12px",
|
||||||
|
borderRadius: 8,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Réessayer sur {SOCIAL_LABELS[network]}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -279,42 +324,3 @@ export default async function PromotionsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -100,6 +100,10 @@ export const promotions = pgTable("promotions", {
|
|||||||
memberId: integer("member_id").references(() => members.id),
|
memberId: integer("member_id").references(() => members.id),
|
||||||
status: promoStatusEnum("status").notNull().default("pending"),
|
status: promoStatusEnum("status").notNull().default("pending"),
|
||||||
validUntil: varchar("valid_until", { length: 120 }),
|
validUntil: varchar("valid_until", { length: 120 }),
|
||||||
|
// Réseaux demandés à la soumission. Le choix reste modifiable tant que la
|
||||||
|
// promo est « pending » ; la validation le fige ET déclenche la publication.
|
||||||
|
shareFacebook: boolean("share_facebook").notNull().default(false),
|
||||||
|
shareLinkedin: boolean("share_linkedin").notNull().default(false),
|
||||||
suspendedBy: promoSuspendedByEnum("suspended_by"),
|
suspendedBy: promoSuspendedByEnum("suspended_by"),
|
||||||
suspendedById: integer("suspended_by_id").references(() => users.id, { onDelete: "set null" }),
|
suspendedById: integer("suspended_by_id").references(() => users.id, { onDelete: "set null" }),
|
||||||
suspendedAt: timestamp("suspended_at", { withTimezone: true }),
|
suspendedAt: timestamp("suspended_at", { withTimezone: true }),
|
||||||
|
|||||||
Reference in new issue
Block a user