Espace adhérent en deux pages, e-mail de contact, logo sans débordement, types de produits pour les promos

- Espace adhérent : /backend/espace (profil, inscriptions, droit à l'image)
  et /backend/espace/promotions (dépôt et suivi). Bandeau et onglets communs
  (EspaceHeader) avec le nombre d'offres en ligne ; la page promotions ouvre
  sur « Mes promotions en cours », puis le formulaire, puis les offres en
  attente et l'historique. Deux entrées dans le menu, titres de page dédiés,
  revalidation des deux chemins.
- Fiche adhérent : colonne members.contact_email (migration 0013), saisie
  dans l'espace adhérent et la fiche staff ; la fiche publique et le JSON-LD
  affichent contact_email, sinon l'e-mail du compte.
- VitrineImage : sans photo de couverture, le logo est contenu à ~60 % d'un
  cadre à hauteur fixe sur un fond du logo flouté ; le conteneur sans hauteur
  qui laissait le logo déborder est supprimé.
- Promotions : la catégorie devient un type de produit ou de service
  (src/lib/promo-categories.ts, 10 groupes, ~85 entrées) pré-sélectionné
  depuis le métier de l'adhérent, à la place de la liste des métiers.
- Tests : catégories de promo (largeur, unicité, pré-sélection pour chaque
  métier du référentiel).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDbKbJrPWweXXYSxA8MXW7
This commit is contained in:
Claude committed 2026-09-06 09:02:27 +00:00
1 parent 8b46cd6316
commit 23d1d14a49
18 files changed
+2289 -278

No files matched your search

+62
View File
@@ -0,0 +1,62 @@
import Link from "next/link";
function initialsOf(name: string) {
return name.split(/\s+/).map((w) => w[0]).join("").slice(0, 2).toUpperCase();
}
const TABS = [
{ key: "profil", href: "/backend/espace", label: "Mon profil" },
{ key: "promotions", href: "/backend/espace/promotions", label: "Mes promotions" },
] as const;
export type EspaceTab = (typeof TABS)[number]["key"];
/**
* Bandeau de l'espace adhérent et onglets Profil / Promotions : deux pages
* distinctes pour que la fiche publique et la vie des promotions ne se
* mélangent pas dans une seule longue page.
*/
export function EspaceHeader({
memberName,
subtitle,
active,
publicPath,
promoBadge,
}: {
memberName: string;
subtitle: string;
active: EspaceTab;
publicPath?: string | null;
/** Nombre de promotions en ligne, affiché sur l'onglet. */
promoBadge?: number;
}) {
return (
<div style={{ marginBottom: 22 }}>
<div style={{ background: "#13324F", borderRadius: "16px 16px 0 0", padding: "20px 24px", display: "flex", alignItems: "center", gap: 15, flexWrap: "wrap" }}>
<span className="font-display" style={{ width: 46, height: 46, borderRadius: 12, background: "#E0A63C", color: "#33291D", display: "flex", alignItems: "center", justifyContent: "center", fontWeight: 800, fontSize: 16, flexShrink: 0 }}>
{initialsOf(memberName)}
</span>
<div style={{ flex: 1, minWidth: 200 }}>
<div className="font-display" style={{ fontWeight: 700, fontSize: 18, color: "#fff" }}>{memberName}</div>
<div style={{ fontSize: 13, color: "#9bb6cd" }}>{subtitle}</div>
</div>
{publicPath && (
<Link href={publicPath} target="_blank" style={{ color: "#fff", border: "1px solid rgba(255,255,255,0.35)", borderRadius: 10, padding: "8px 14px", fontSize: 13, fontWeight: 700, textDecoration: "none", whiteSpace: "nowrap" }}>
Voir ma fiche publique ↗
</Link>
)}
</div>
<nav className="espace-tabs" aria-label="Sections de l'espace adhérent">
{TABS.map((tab) => {
const isActive = tab.key === active;
return (
<Link key={tab.key} href={tab.href} className={`espace-tab${isActive ? " active" : ""}`} aria-current={isActive ? "page" : undefined}>
{tab.label}
{tab.key === "promotions" && promoBadge ? <span className="espace-tab__badge">{promoBadge}</span> : null}
</Link>
);
})}
</nav>
</div>
);
}
+19 -7
View File
@@ -6,6 +6,7 @@ import { publishPromo } from "../actions";
import { PromoImage } from "@/components/PromoImage";
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
import type { SocialNetwork } from "@/lib/social";
import type { PromoCategoryGroup } from "@/lib/promo-categories";
const STRIPE_WARM =
"repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)";
@@ -17,18 +18,23 @@ const NETWORK_LABELS: Record<SocialNetwork, string> = {
export function MemberSpaceForm({
memberName,
categories,
categoryGroups,
defaultCategory,
networks = [],
}: {
memberName: string;
categories: string[];
/** Types de produits / services, groupés pour le `<select>`. */
categoryGroups: PromoCategoryGroup[];
/** Pré-sélection déduite du métier de l'adhérent. */
defaultCategory?: string;
networks?: SocialNetwork[];
}) {
const CATEGORIES = categories.length > 0 ? categories : ["Autre"];
const allCategories = categoryGroups.flatMap((g) => g.items);
const initialCategory = defaultCategory && allCategories.includes(defaultCategory) ? defaultCategory : allCategories[0] ?? "Autre";
const router = useRouter();
const [title, setTitle] = useState("");
const [text, setText] = useState("");
const [cat, setCat] = useState(CATEGORIES[0]);
const [cat, setCat] = useState(initialCategory);
const [badge, setBadge] = useState("");
const [imgData, setImgData] = useState("");
const [shares, setShares] = useState<SocialNetwork[]>([]);
@@ -144,10 +150,16 @@ export function MemberSpaceForm({
<div style={{ display: "flex", gap: 12, marginBottom: 16 }}>
<div style={{ flex: 1 }}>
<label className="field-label">Catégorie</label>
<label className="field-label">Type de produit ou de service</label>
<select value={cat} onChange={(e) => setCat(e.target.value)} className="field">
{CATEGORIES.map((c) => (
<option key={c}>{c}</option>
{categoryGroups.map((group) => (
<optgroup key={group.label} label={group.label}>
{group.items.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</optgroup>
))}
</select>
</div>
+26 -238
View File
@@ -1,37 +1,21 @@
import Link from "next/link";
import { and, asc, desc, eq, gte, inArray, sql } from "drizzle-orm";
import { and, asc, desc, eq, gte, sql } from "drizzle-orm";
import { getSession } from "@/lib/session";
import { db } from "@/db";
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 { categories, imageConsents, meetingRegistrations, meetings, members, promotions } from "@/db/schema";
import type { Member } from "@/db/schema";
import { memberPath } from "@/lib/seo";
import { ImageField } from "@/components/ImageField";
import { ImageConsentForm } from "@/components/ImageConsentForm";
import { HoursEditor } from "@/components/HoursEditor";
import { TagsField } from "@/components/TagsField";
import { communeOptions } from "@/lib/communes";
import {
saveImageConsent,
setOwnPromoShareTargets,
setOwnPromoSuspension,
updateOwnProfile,
} from "../actions";
import { MemberSpaceForm } from "./MemberSpaceForm";
import { saveImageConsent, updateOwnProfile } from "../actions";
import { EspaceHeader } from "./EspaceHeader";
export const dynamic = "force-dynamic";
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" },
};
function initialsOf(name: string) {
return name.split(/\s+/).map((w) => w[0]).join("").slice(0, 2).toUpperCase();
}
/** Espace adhérent, onglet Profil : fiche publique, inscriptions, droit à l'image. */
export default async function EspacePage() {
const session = await getSession();
const memberId = session?.user.memberId ?? null;
@@ -40,18 +24,7 @@ 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;
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 livePromoCount = 0;
let myRegistrations: { meetingId: number; title: string; startsAt: Date; location: string | null; participants: number; confirmed: boolean }[] = [];
let latestConsent: { decision: string; createdAt: Date } | null = null;
let profileCategory: { slug: string; label: string } | null = null;
@@ -67,39 +40,11 @@ export default async function EspacePage() {
profileCategory = cat ?? null;
subtitle = ["Espace adhérent", cat?.label, m.city].filter(Boolean).join(" · ");
}
myPromos = await db
.select({
id: promotions.id,
title: promotions.title,
status: promotions.status,
createdAt: promotions.createdAt,
imageUrl: promotions.imageUrl,
suspendedBy: promotions.suspendedBy,
shareFacebook: promotions.shareFacebook,
shareLinkedin: promotions.shareLinkedin,
})
const [live] = await db
.select({ total: sql<number>`count(*)` })
.from(promotions)
.where(eq(promotions.memberId, memberId))
.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);
}
}
.where(and(eq(promotions.memberId, memberId), eq(promotions.status, "live")));
livePromoCount = Number(live?.total ?? 0);
myRegistrations = await db
.select({
meetingId: meetings.id,
@@ -123,13 +68,6 @@ export default async function EspacePage() {
latestConsent = consent ?? null;
}
const catRows = await db
.select({ label: categories.label })
.from(categories)
.orderBy(asc(categories.sort));
const categoryLabels = catRows.map((c) => c.label);
const networks = await configuredNetworks();
function fmtDate(d: Date) {
return new Date(d).toLocaleDateString("fr-FR", { day: "numeric", month: "long" });
}
@@ -143,17 +81,13 @@ export default async function EspacePage() {
</div>
)}
<div style={{ background: "#13324F", borderRadius: 16, padding: "20px 24px", marginBottom: 22, display: "flex", alignItems: "center", gap: 15 }}>
<span className="font-display" style={{ width: 46, height: 46, borderRadius: 12, background: "#E0A63C", color: "#33291D", display: "flex", alignItems: "center", justifyContent: "center", fontWeight: 800, fontSize: 16, flexShrink: 0 }}>
{initialsOf(memberName)}
</span>
<div>
<div className="font-display" style={{ fontWeight: 700, fontSize: 18, color: "#fff" }}>
{memberName}
</div>
<div style={{ fontSize: 13, color: "#9bb6cd" }}>{subtitle}</div>
</div>
</div>
<EspaceHeader
memberName={memberName}
subtitle={subtitle}
active="profil"
publicPath={profile && profile.status === "active" ? memberPath(profile) : null}
promoBadge={livePromoCount}
/>
{memberId && (
<section style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 24, marginBottom: 28 }}>
@@ -197,6 +131,13 @@ export default async function EspacePage() {
<label className="field-label">Nom</label>
<input name="name" required defaultValue={profile.name} className="field" />
</div>
<div>
<label className="field-label">E-mail de contact (affiché sur la fiche)</label>
<input name="contactEmail" type="email" defaultValue={profile.contactEmail ?? ""} className="field" placeholder={profile.email} />
<p className="field-hint">
Indépendant de votre identifiant de connexion. Laissé vide, la fiche affiche l&apos;e-mail de votre compte.
</p>
</div>
<div>
<label className="field-label">Commune</label>
<select name="city" className="field" defaultValue={profile.city ?? ""}>
@@ -287,159 +228,6 @@ export default async function EspacePage() {
</section>
)}
<MemberSpaceForm memberName={memberName} categories={categoryLabels} networks={networks} />
<div style={{ marginTop: 28 }}>
<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={{ 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>
<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>
{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>
)}
{networks.length > 0 && (
<PromoShareState
promo={mp}
networks={networks}
lastShare={lastShare}
editable={mp.status === "pending"}
/>
)}
</div>
);
})}
</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>
);
}
+366
View File
@@ -0,0 +1,366 @@
import Link from "next/link";
import { and, desc, eq, inArray } from "drizzle-orm";
import { getSession } from "@/lib/session";
import { db } from "@/db";
import { categories, members, promotions, socialPosts } from "@/db/schema";
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
import { configuredNetworks, SOCIAL_LABELS, type SocialNetwork } from "@/lib/social";
import { PROMO_CATEGORY_GROUPS, defaultPromoCategory } from "@/lib/promo-categories";
import { memberPath } from "@/lib/seo";
import { setOwnPromoShareTargets, setOwnPromoSuspension } from "../../actions";
import { EspaceHeader } from "../EspaceHeader";
import { MemberSpaceForm } from "../MemberSpaceForm";
export const dynamic = "force-dynamic";
const PROMO_STATUS: Record<string, { label: string; bg: string; color: string }> = {
live: { label: "En ligne", bg: "#e6f4ec", color: "#1f8a5b" },
pending: { label: "En attente de validation", 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" },
};
type MyPromo = {
id: number;
title: string;
status: string;
category: string | null;
badge: string | null;
validUntil: string | null;
createdAt: Date;
imageUrl: string | null;
suspendedBy: "member" | "staff" | null;
shareFacebook: boolean;
shareLinkedin: boolean;
};
type ShareState = Map<string, { status: string; url: string | null; error: string | null }>;
function fmtDate(d: Date) {
return new Date(d).toLocaleDateString("fr-FR", { day: "numeric", month: "long", year: "numeric" });
}
/** Espace adhérent, onglet Promotions : proposer une offre et suivre les siennes. */
export default async function EspacePromotionsPage() {
const session = await getSession();
const memberId = session?.user.memberId ?? null;
let memberName = session?.user.name ?? "Adhérent";
let subtitle = "Espace adhérent · Promotions";
let publicPath: string | null = null;
let categorySlug: string | null = null;
let myPromos: MyPromo[] = [];
let lastShare: ShareState = new Map();
if (memberId) {
const [m] = await db
.select({
id: members.id,
name: members.name,
city: members.city,
status: members.status,
categorySlug: categories.slug,
categoryLabel: categories.label,
})
.from(members)
.leftJoin(categories, eq(members.categoryId, categories.id))
.where(eq(members.id, memberId));
if (m) {
memberName = m.name;
categorySlug = m.categorySlug;
subtitle = ["Espace adhérent", m.categoryLabel, m.city].filter(Boolean).join(" · ");
publicPath = m.status === "active" ? memberPath(m) : null;
}
myPromos = await db
.select({
id: promotions.id,
title: promotions.title,
status: promotions.status,
category: promotions.category,
badge: promotions.badge,
validUntil: promotions.validUntil,
createdAt: promotions.createdAt,
imageUrl: promotions.imageUrl,
suspendedBy: promotions.suspendedBy,
shareFacebook: promotions.shareFacebook,
shareLinkedin: promotions.shareLinkedin,
})
.from(promotions)
.where(eq(promotions.memberId, memberId))
.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.
for (const row of rows) {
const key = `${row.promotionId}:${row.network}`;
if (!lastShare.has(key)) lastShare.set(key, row);
}
}
}
const networks = await configuredNetworks();
const live = myPromos.filter((p) => p.status === "live");
const pending = myPromos.filter((p) => p.status === "pending");
const others = myPromos.filter((p) => p.status !== "live" && p.status !== "pending");
return (
<div>
{!memberId && (
<div style={{ background: "#fbeede", border: "1px solid #ecd8b8", color: "#9a6638", borderRadius: 12, padding: "12px 16px", marginBottom: 18, fontSize: 13.5 }}>
Votre compte n&apos;est pas encore relié à une fiche adhérent. Les promotions publiées ne
seront pas rattachées à un commerce.
</div>
)}
<EspaceHeader memberName={memberName} subtitle={subtitle} active="promotions" publicPath={publicPath} promoBadge={live.length} />
{/* Résumé : ce qui est visible par le public aujourd'hui */}
<div className="grid grid-3" style={{ gap: 12, marginBottom: 24 }}>
<StatCard label="En ligne sur le site" value={live.length} color="#1f8a5b" bg="#e6f4ec" />
<StatCard label="En attente de validation" value={pending.length} color="#9a6638" bg="#fbeede" />
<StatCard label="Terminées ou suspendues" value={others.length} color="#8c8068" bg="#f1efe7" />
</div>
<PromoSection
title="Mes promotions en cours"
intro={
live.length > 0
? "Ces offres sont visibles sur le site et sur votre fiche. Vous pouvez en suspendre une à tout moment : elle disparaît aussitôt, et vous la remettez en ligne quand vous voulez."
: "Aucune promotion en ligne pour le moment. Proposez une offre ci-dessous : elle apparaîtra ici dès sa validation par l'association."
}
promos={live}
networks={networks}
lastShare={lastShare}
highlight
/>
<MemberSpaceForm
memberName={memberName}
categoryGroups={PROMO_CATEGORY_GROUPS}
defaultCategory={defaultPromoCategory(categorySlug)}
networks={networks}
/>
{pending.length > 0 && (
<PromoSection
title="En attente de validation"
intro="L'association relit chaque offre avant sa mise en ligne. Vous pouvez encore ajuster la diffusion sur les réseaux sociaux."
promos={pending}
networks={networks}
lastShare={lastShare}
/>
)}
{others.length > 0 && (
<PromoSection
title="Suspendues, refusées ou expirées"
intro="Historique de vos offres. Une promotion suspendue par vous peut être remise en ligne."
promos={others}
networks={networks}
lastShare={lastShare}
/>
)}
{myPromos.length > 0 && (
<p style={{ fontSize: 13, color: "#8c8068", marginTop: 18 }}>
Les offres en ligne apparaissent sur <Link href="/#promotions" style={{ color: "#9a6638", fontWeight: 700 }}>la page d&apos;accueil</Link>
{publicPath ? (
<>
{" "}et sur <Link href={publicPath} style={{ color: "#9a6638", fontWeight: 700 }}>votre fiche</Link>
</>
) : null}
.
</p>
)}
</div>
);
}
function StatCard({ label, value, color, bg }: { label: string; value: number; color: string; bg: string }) {
return (
<div style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 14, padding: "14px 16px", display: "flex", alignItems: "center", gap: 14 }}>
<span className="font-display" style={{ width: 44, height: 44, borderRadius: 12, background: bg, color, display: "flex", alignItems: "center", justifyContent: "center", fontWeight: 800, fontSize: 20, flexShrink: 0 }}>
{value}
</span>
<span style={{ fontSize: 13, color: "#6c6150", fontWeight: 600, lineHeight: 1.35 }}>{label}</span>
</div>
);
}
function PromoSection({
title,
intro,
promos,
networks,
lastShare,
highlight = false,
}: {
title: string;
intro: string;
promos: MyPromo[];
networks: SocialNetwork[];
lastShare: ShareState;
highlight?: boolean;
}) {
return (
<section
style={{
background: highlight ? "#fff" : "transparent",
border: highlight ? "1px solid #e6dcc6" : "none",
borderRadius: 16,
padding: highlight ? 22 : "0",
marginBottom: 28,
}}
>
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 18, margin: 0, color: "#26201a" }}>
{title}
<span style={{ marginLeft: 8, fontSize: 13, color: "#a99c82", fontWeight: 600 }}>{promos.length}</span>
</h3>
<p style={{ fontSize: 13, color: "#8c8068", margin: "4px 0 14px", lineHeight: 1.5 }}>{intro}</p>
{promos.map((mp) => (
<PromoRow key={mp.id} promo={mp} networks={networks} lastShare={lastShare} />
))}
</section>
);
}
function PromoRow({ promo: mp, networks, lastShare }: { promo: MyPromo; networks: SocialNetwork[]; lastShare: ShareState }) {
const st = PROMO_STATUS[mp.status] ?? PROMO_STATUS.expired;
const suspendedByStaff = mp.status === "suspended" && mp.suspendedBy === "staff";
const meta = [mp.category, mp.badge, mp.validUntil].filter(Boolean).join(" · ");
return (
<div style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 12, padding: "12px 14px", marginBottom: 10 }}>
<div style={{ display: "flex", alignItems: "center", gap: 13, flexWrap: "wrap" }}>
<span
style={{
width: 54,
height: 54,
borderRadius: 10,
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: 180 }}>
<div style={{ fontSize: 14.5, fontWeight: 700, color: "#26201a" }}>{mp.title}</div>
<div style={{ fontSize: 12, color: "#a99c82", marginTop: 2 }}>
{meta ? `${meta} · ` : ""}Déposée le {fmtDate(mp.createdAt)}
</div>
</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>
{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>
)}
{networks.length > 0 && <PromoShareState promo={mp} networks={networks} lastShare={lastShare} editable={mp.status === "pending"} />}
</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: ShareState;
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>
);
}