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:
Claude committed 2026-07-24 22:20:51 +00:00
1 parent dd9697019c
commit e3a4771edf
20 files changed
+2370 -109

No files matched your search

+6 -4
View File
@@ -38,7 +38,7 @@ function initialsOf(name: string) {
return name.split(/\s+/).map((w) => w[0]).join("").slice(0, 2).toUpperCase();
}
type ContactIconName = "phone" | "mail" | "website" | "pin";
type ContactIconName = "phone" | "mail" | "website" | "pin" | "clock" | "chat";
function ContactIcon({ name }: { name: ContactIconName }) {
const paths: Record<ContactIconName, React.ReactNode> = {
@@ -46,6 +46,8 @@ function ContactIcon({ name }: { name: ContactIconName }) {
mail: <><rect x="2.5" y="4.5" width="19" height="15" rx="2.5" /><path d="m4 6 8 6 8-6" /></>,
website: <><circle cx="12" cy="12" r="9.5" /><path d="M2.8 12h18.4M12 2.5c2.4 2.6 3.6 5.8 3.6 9.5S14.4 18.9 12 21.5M12 2.5C9.6 5.1 8.4 8.3 8.4 12s1.2 6.9 3.6 9.5" /></>,
pin: <><path d="M20 10c0 5.5-8 11.5-8 11.5S4 15.5 4 10a8 8 0 1 1 16 0Z" /><circle cx="12" cy="10" r="2.5" /></>,
clock: <><circle cx="12" cy="12" r="9.5" /><path d="M12 6.6V12l3.6 2.4" /></>,
chat: <><path d="M20.5 12.6c0 3.9-3.8 7.1-8.5 7.1-.9 0-1.8-.1-2.6-.3l-5.4 1.7 1.7-4.2a6.6 6.6 0 0 1-2.2-4.8c0-3.9 3.8-7.1 8.5-7.1s8.5 3.2 8.5 7.1Z" /><path d="M8.8 11.9h.01M12 11.9h.01M15.2 11.9h.01" /></>,
};
return (
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
@@ -247,7 +249,7 @@ export default async function FicheAdherentPage({
<div className="practical-section__heading">
<div>
<span className="eyebrow">Préparer votre visite</span>
<h2 className="font-display" style={{ ...h2, margin: "5px 0 0" }}>Informations pratiques</h2>
<h2 className="font-display" style={{ ...h2, margin: "10px 0 0" }}>Informations pratiques</h2>
</div>
{openStatus && (
<div className={`open-status${openStatus.isOpen ? " is-open" : ""}`}>
@@ -263,7 +265,7 @@ export default async function FicheAdherentPage({
<div className="practical-grid">
<section className="schedule-card" aria-labelledby="schedule-title">
<div className="practical-card-title">
<span className="practical-card-title__icon" aria-hidden="true">◷</span>
<span className="practical-card-title__icon" aria-hidden="true"><ContactIcon name="clock" /></span>
<div>
<span>Cette semaine</span>
<h3 id="schedule-title" className="font-display">Horaires d&apos;ouverture</h3>
@@ -297,7 +299,7 @@ export default async function FicheAdherentPage({
<section className="contact-card" aria-labelledby="contact-title">
<div className="practical-card-title">
<span className="practical-card-title__icon practical-card-title__icon--blue" aria-hidden="true">↗</span>
<span className="practical-card-title__icon practical-card-title__icon--blue" aria-hidden="true"><ContactIcon name="chat" /></span>
<div>
<span>Contact direct</span>
<h3 id="contact-title" className="font-display">Joindre l&apos;établissement</h3>
+151 -7
View File
@@ -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 ----
+65 -24
View File
@@ -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&apos;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&apos;association. Contactez-la pour la
remettre en ligne.
</div>
)}
</div>
);
})}
+18
View File
@@ -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&apos;affichent sur la page d&apos;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&apos;accès dans les
variables d&apos;environnement).
</div>
</section>
<section style={panel}>
+180 -15
View File
@@ -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&apos;accès Facebook ou LinkedIn
n&apos;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>
);
}
+68 -55
View File
@@ -580,11 +580,9 @@ img {
.practical-section {
overflow: hidden;
padding: 26px 28px 28px;
border: 1px solid #e6dcc6;
border: 1px solid var(--border);
border-radius: 18px;
background:
radial-gradient(circle at 100% 0, rgba(224, 166, 60, 0.12), transparent 190px),
#fff;
background: #fff;
}
.practical-section__heading {
display: flex;
@@ -593,31 +591,44 @@ img {
gap: 18px;
margin-bottom: 20px;
}
/* Pastille de section, identique aux badges « Bons plans » du reste du site. */
.eyebrow {
color: #9a6638;
font-size: 10.5px;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 5px 12px;
border-radius: 999px;
background: #fdeccd;
color: var(--brown);
font-size: 11.5px;
font-weight: 800;
letter-spacing: 0.14em;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.eyebrow::before {
content: "";
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--gold);
}
.open-status {
display: flex;
align-items: center;
gap: 9px;
min-width: 154px;
padding: 8px 12px;
border: 1px solid #e7d3ce;
border-radius: 12px;
background: #fff8f6;
color: #9f3e2e;
padding: 8px 13px;
border: 1px solid #f2d5cf;
border-radius: 999px;
background: #fbe9e6;
color: var(--red);
}
.open-status > span {
width: 9px;
height: 9px;
width: 8px;
height: 8px;
flex: 0 0 auto;
border-radius: 50%;
background: #d8472b;
box-shadow: 0 0 0 4px rgba(216, 71, 43, 0.12);
background: var(--red);
}
.open-status div {
display: flex;
@@ -626,23 +637,25 @@ img {
}
.open-status strong {
font-size: 12.5px;
font-weight: 800;
line-height: 1.25;
}
.open-status small {
margin-top: 2px;
color: #916a61;
color: #b1786c;
font-size: 10.5px;
}
.open-status.is-open {
border-color: #cce7d7;
background: #f1faf5;
color: #176c48;
border-color: #c7e6d5;
background: #e6f4ec;
color: var(--green);
}
.open-status.is-open > span {
background: #1f8a5b;
box-shadow: 0 0 0 4px rgba(31, 138, 91, 0.12);
background: var(--green);
}
.open-status.is-open small {
color: #6d9d86;
}
.open-status.is-open small { color: #5d7f6e; }
.practical-grid {
display: grid;
grid-template-columns: minmax(0, 1.08fr) minmax(0, 0.92fr);
@@ -651,22 +664,17 @@ img {
.schedule-card,
.contact-card {
min-width: 0;
border: 1px solid #eadfca;
border-radius: 16px;
padding: 18px;
}
.schedule-card {
background: linear-gradient(145deg, #fffdf8, #fbf5e8);
}
.contact-card {
background: linear-gradient(145deg, #fff, #f3f7fa);
border: 1px solid var(--border-2);
border-radius: 16px;
background: var(--field-bg);
}
.practical-card-title {
display: flex;
align-items: center;
gap: 11px;
padding-bottom: 14px;
border-bottom: 1px solid rgba(216, 205, 180, 0.68);
border-bottom: 1px solid var(--border-2);
}
.practical-card-title__icon {
display: grid;
@@ -675,19 +683,21 @@ img {
flex: 0 0 auto;
place-items: center;
border-radius: 11px;
background: #f3dfb6;
color: #8c5f2e;
font-size: 20px;
font-weight: 800;
background: #f6efdc;
color: var(--brown);
}
.practical-card-title__icon svg {
width: 18px;
height: 18px;
}
.practical-card-title__icon--blue {
background: #dfeef5;
color: #2c6fb3;
background: #eaf0f6;
color: var(--blue);
}
.practical-card-title span:not(.practical-card-title__icon) {
display: block;
margin-bottom: 2px;
color: #a09279;
color: var(--muted-3);
font-size: 9.5px;
font-weight: 800;
letter-spacing: 0.12em;
@@ -695,8 +705,9 @@ img {
}
.practical-card-title h3 {
margin: 0;
color: #26201a;
color: var(--ink-strong);
font-size: 15.5px;
font-weight: 700;
line-height: 1.25;
}
.schedule-list {
@@ -710,14 +721,15 @@ img {
align-items: center;
gap: 12px;
min-height: 38px;
padding: 7px 8px;
border-radius: 9px;
color: #655b4c;
padding: 7px 9px;
border: 1px solid transparent;
border-radius: 10px;
color: var(--muted);
font-size: 12.5px;
}
.schedule-row.is-today {
border-color: var(--border);
background: #fff;
box-shadow: 0 5px 16px -12px rgba(80, 55, 22, 0.58);
}
.schedule-row__day {
display: flex;
@@ -727,20 +739,20 @@ img {
}
.schedule-row__day small {
margin-top: 1px;
color: #9a6638;
color: var(--brown);
font-size: 9px;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.schedule-row > strong {
color: #352d22;
color: var(--ink-soft);
font-size: 12px;
font-weight: 700;
text-align: right;
}
.schedule-row > strong.is-closed {
color: #b16a5e;
color: var(--muted-4);
font-weight: 600;
}
.contact-actions {
@@ -758,12 +770,12 @@ img {
padding: 10px;
border: 1px solid transparent;
border-radius: 11px;
color: #4d4437;
color: var(--ink);
text-decoration: none;
transition: background 160ms ease, border-color 160ms ease, transform 160ms ease;
}
.contact-action:hover {
border-color: #d7e4eb;
border-color: var(--border);
background: #fff;
transform: translateX(2px);
}
@@ -773,8 +785,8 @@ img {
height: 34px;
place-items: center;
border-radius: 10px;
background: #eaf2f7;
color: #2c6fb3;
background: #eaf0f6;
color: var(--blue);
}
.contact-action__icon svg {
width: 17px;
@@ -786,24 +798,25 @@ img {
flex-direction: column;
}
.contact-action__copy strong {
color: #33291d;
color: var(--ink-strong);
font-size: 12.5px;
font-weight: 700;
}
.contact-action__copy > span {
overflow: hidden;
margin-top: 2px;
color: #7d715f;
color: var(--muted-2);
font-size: 10.5px;
text-overflow: ellipsis;
white-space: nowrap;
}
.contact-action__arrow {
color: #7f9db5;
color: var(--muted-4);
font-size: 14px;
}
.practical-empty {
padding: 20px 4px 8px;
color: #9b8e78;
color: var(--muted-4);
font-size: 13px;
}
+101 -1
View File
@@ -7,7 +7,9 @@ import { MembershipModalButton } from "@/components/MembershipModalButton";
import { MembersCarousel } from "@/components/MembersCarousel";
import { StarField } from "@/components/StarField";
import { PromoImage } from "@/components/PromoImage";
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
import { getLivePromotions, getRotatingActiveMembers } from "@/lib/queries";
import { getSiteSettings, socialLinks } from "@/lib/site-settings";
export const dynamic = "force-dynamic";
@@ -20,10 +22,12 @@ function badgeColor(badge: string | null) {
}
export default async function AccueilPage() {
const [promos, rotatingMembers] = await Promise.all([
const [promos, rotatingMembers, settings] = await Promise.all([
getLivePromotions(6),
getRotatingActiveMembers(),
getSiteSettings(),
]);
const socials = socialLinks(settings);
return (
<div
@@ -318,6 +322,102 @@ export default async function AccueilPage() {
<MembersCarousel members={rotatingMembers} />
</section>
{/* réseaux sociaux */}
{socials.length > 0 && (
<section
style={{
position: "relative",
margin: "0 0 46px",
background: "#fff",
border: "1px solid #e6dcc6",
borderRadius: 24,
padding: "34px 36px 36px",
overflow: "hidden",
boxShadow: "0 26px 60px -40px rgba(40,30,15,0.5)",
}}
>
<Sparkle color="#6FB0C6" size={18} style={{ top: 26, right: 42 }} duration={3.1} />
<div
style={{
display: "inline-flex",
alignItems: "center",
gap: 8,
background: "#eaf0f6",
color: "#2C6FB3",
fontSize: 12,
letterSpacing: "0.12em",
textTransform: "uppercase",
fontWeight: 800,
padding: "6px 13px",
borderRadius: 999,
marginBottom: 12,
}}
>
<span style={{ width: 8, height: 8, borderRadius: "50%", background: "#2C6FB3" }} />
Suivez-nous
</div>
<h2
className="font-display"
style={{ fontWeight: 800, fontSize: "clamp(22px, 5vw, 30px)", letterSpacing: "-0.02em", margin: 0, color: "#26201a" }}
>
Plein R sur les réseaux
</h2>
<p style={{ margin: "7px 0 0", color: "#8c8068", fontSize: 15, maxWidth: 560 }}>
Bons plans, rencontres et actualités des commerçants du Bassin de Pompey : retrouvez-nous
sur Facebook et LinkedIn.
</p>
<div className="grid grid-2" style={{ gap: 16, marginTop: 24 }}>
{socials.map((s) => (
<a
key={s.network}
href={s.href}
target="_blank"
rel="noopener noreferrer"
className="lift-card"
style={{
display: "flex",
alignItems: "center",
gap: 14,
textDecoration: "none",
color: "inherit",
background: "#faf7ef",
border: "1px solid #f0e8d6",
borderRadius: 16,
padding: "16px 18px",
}}
>
<span
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 44,
height: 44,
borderRadius: 13,
background: SOCIAL_BRAND[s.network],
color: "#fff",
flexShrink: 0,
}}
>
<SocialIcon network={s.network} size={22} />
</span>
<span style={{ minWidth: 0, flex: 1 }}>
<span className="font-display" style={{ display: "block", fontWeight: 700, fontSize: 16, color: "#26201a" }}>
{s.label}
</span>
<span style={{ display: "block", fontSize: 12.5, color: "#8c8068", marginTop: 2, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{s.handle}
</span>
</span>
<span style={{ color: "#2C6FB3", fontWeight: 700, fontSize: 14, flexShrink: 0 }}>↗</span>
</a>
))}
</div>
</section>
)}
{/* warm CTA */}
<section
style={{