Promotions : logo de l'adhérent dans l'angle des cartes

Le logo du commerce identifie l'offre avant même qu'on lise le nom. Il est
posé en pastille ronde dans l'angle haut-gauche de la carte, la catégorie se
décalant juste à côté sur la même ligne.

- `src/components/MemberAvatar.tsx` : logo en `contain` sur fond blanc (les
  logos d'adhérents sont des marques sur fond clair, un `cover` les rognerait),
  repli sur les initiales colorées quand la fiche n'a pas de logo.
- Appliqué à la carte d'accueil, à la carte de la fiche adhérent, à l'aperçu
  en direct de l'espace adhérent et à la carte de modération : même composition
  partout, l'aperçu montre donc bien ce que verra le visiteur.
- `getLivePromotions` remonte `members.logo_url` ; la page espace le passe au
  formulaire.

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-09-06 14:02:53 +00:00
1 parent e980ab6e04
commit d99ee7ec07
7 files changed
+126 -25

No files matched your search

+6
View File
@@ -6,6 +6,7 @@ import { SiteHeader } from "@/components/SiteHeader";
import { SiteFooter } from "@/components/SiteFooter";
import { VitrineImage } from "@/components/VitrineImage";
import { PromoImage } from "@/components/PromoImage";
import { MemberAvatar } from "@/components/MemberAvatar";
import { JsonLd } from "@/components/JsonLd";
import { getMemberLivePromotions, getPublicMember } from "@/lib/queries";
import {
@@ -431,6 +432,11 @@ export default async function FicheAdherentPage({
<div style={{ position: "relative", aspectRatio: "1 / 1", background: STRIPE_WARM, overflow: "hidden", display: "flex", alignItems: "center", justifyContent: "center" }}>
{p.imageUrl && <PromoImage src={p.imageUrl} alt={p.title} />}
{!p.imageUrl && (<span style={{ fontSize: 10, letterSpacing: "0.12em", color: "#a99c82", textTransform: "uppercase" }}>photo de l&apos;offre</span>)}
{/* Même repère que sur l'accueil : le logo du commerce
dans l'angle haut-gauche de la carte. */}
<span style={{ position: "absolute", top: 10, left: 10 }}>
<MemberAvatar name={member.name} logoUrl={member.logoUrl} size={32} accent={accent} />
</span>
{p.badge && (
<span className="font-display" style={{ position: "absolute", top: 0, right: 0, background: badgeColor(p.badge), color: "#fff", fontWeight: 800, fontSize: 16, padding: "6px 13px", borderRadius: "0 0 0 14px" }}>{p.badge}</span>
)}
+11 -3
View File
@@ -4,6 +4,7 @@ import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { publishPromo } from "../actions";
import { PromoImage } from "@/components/PromoImage";
import { MemberAvatar } from "@/components/MemberAvatar";
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
import type { SocialNetwork } from "@/lib/social";
import type { PromoCategoryGroup } from "@/lib/promo-categories";
@@ -20,11 +21,14 @@ const NETWORK_LABELS: Record<SocialNetwork, string> = {
export function MemberSpaceForm({
memberName,
memberLogoUrl,
categoryGroups,
defaultCategory,
networks = [],
}: {
memberName: string;
/** Logo de l'adhérent, affiché dans l'angle de l'aperçu comme sur le site. */
memberLogoUrl?: string | null;
/** Types de produits / services, groupés pour le `<select>`. */
categoryGroups: PromoCategoryGroup[];
/** Pré-sélection déduite du métier de l'adhérent. */
@@ -353,9 +357,13 @@ export function MemberSpaceForm({
{badge}
</span>
)}
<span style={{ position: "absolute", top: 12, left: 12, background: "#9a6638", color: "#fff", borderRadius: 999, padding: "5px 12px", fontSize: 11, fontWeight: 700 }}>
{cat}
</span>
{/* Même composition que sur le site : logo puis catégorie. */}
<div style={{ position: "absolute", top: 12, left: 12, display: "flex", alignItems: "center", gap: 8 }}>
<MemberAvatar name={memberName} logoUrl={memberLogoUrl} size={34} />
<span style={{ background: "#9a6638", color: "#fff", borderRadius: 999, padding: "5px 12px", fontSize: 11, fontWeight: 700 }}>
{cat}
</span>
</div>
</div>
<div style={{ padding: "17px 18px 18px" }}>
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 18, margin: "0 0 5px", color: "#26201a" }}>
@@ -52,6 +52,7 @@ export default async function EspacePromotionsPage() {
const session = await getSession();
const memberId = session?.user.memberId ?? null;
let memberName = session?.user.name ?? "Adhérent";
let memberLogoUrl: string | null = null;
let subtitle = "Espace adhérent · Promotions";
let publicPath: string | null = null;
let categorySlug: string | null = null;
@@ -65,6 +66,7 @@ export default async function EspacePromotionsPage() {
name: members.name,
city: members.city,
status: members.status,
logoUrl: members.logoUrl,
categorySlug: categories.slug,
categoryLabel: categories.label,
})
@@ -73,6 +75,7 @@ export default async function EspacePromotionsPage() {
.where(eq(members.id, memberId));
if (m) {
memberName = m.name;
memberLogoUrl = m.logoUrl;
categorySlug = m.categorySlug;
subtitle = ["Espace adhérent", m.categoryLabel, m.city].filter(Boolean).join(" · ");
publicPath = m.status === "active" ? memberPath(m) : null;
@@ -160,6 +163,7 @@ export default async function EspacePromotionsPage() {
<MemberSpaceForm
memberName={memberName}
memberLogoUrl={memberLogoUrl}
categoryGroups={PROMO_CATEGORY_GROUPS}
defaultCategory={defaultPromoCategory(categorySlug)}
networks={networks}
+11 -5
View File
@@ -7,6 +7,7 @@ import { can } from "@/lib/rbac";
import { configuredNetworks, SOCIAL_LABELS } from "@/lib/social";
import { moderatePromo, retryPromoShare } from "../actions";
import { PromoImage } from "@/components/PromoImage";
import { MemberAvatar } from "@/components/MemberAvatar";
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
import { formatValidity, visibilityNote } from "@/lib/promo-validity";
import { formatSchedule, scheduleLabel, toScheduleInput } from "@/lib/promo-schedule";
@@ -54,6 +55,7 @@ export default async function PromotionsPage() {
shareFacebook: promotions.shareFacebook,
shareLinkedin: promotions.shareLinkedin,
memberName: members.name,
memberLogoUrl: members.logoUrl,
})
.from(promotions)
.leftJoin(members, eq(promotions.memberId, members.id))
@@ -157,11 +159,15 @@ export default async function PromotionsPage() {
}}
>
{p.imageUrl && <PromoImage src={p.imageUrl} alt={p.title ?? ""} />}
{p.category && (
<span style={{ position: "absolute", top: 10, left: 10, background: "#9a6638", color: "#fff", borderRadius: 999, padding: "4px 11px", fontSize: 11, fontWeight: 700 }}>
{p.category}
</span>
)}
{/* Comme sur le site : logo de l'adhérent puis catégorie. */}
<div style={{ position: "absolute", top: 10, left: 10, display: "flex", alignItems: "center", gap: 7 }}>
<MemberAvatar name={p.memberName ?? ""} logoUrl={p.memberLogoUrl} size={30} />
{p.category && (
<span style={{ background: "#9a6638", color: "#fff", borderRadius: 999, padding: "4px 11px", fontSize: 11, fontWeight: 700 }}>
{p.category}
</span>
)}
</div>
<span
style={{
position: "absolute",
+20 -17
View File
@@ -8,6 +8,7 @@ import { MembershipModalButton } from "@/components/MembershipModalButton";
import { MembersCarousel } from "@/components/MembersCarousel";
import { StarField } from "@/components/StarField";
import { PromoImage } from "@/components/PromoImage";
import { MemberAvatar } from "@/components/MemberAvatar";
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
import { getActiveMemberCount, getLivePromotions, getRotatingActiveMembers } from "@/lib/queries";
import { getSiteSettings, socialLinks } from "@/lib/site-settings";
@@ -284,23 +285,25 @@ export default async function AccueilPage() {
{p.badge}
</span>
)}
{p.category && (
<span
style={{
position: "absolute",
top: 12,
left: 12,
background: "#9a6638",
color: "#fff",
borderRadius: 999,
padding: "5px 12px",
fontSize: 11,
fontWeight: 700,
}}
>
{p.category}
</span>
)}
{/* Angle haut-gauche : le logo de l'adhérent d'abord, la
catégorie ensuite — on identifie le commerce avant l'offre. */}
<div style={{ position: "absolute", top: 12, left: 12, display: "flex", alignItems: "center", gap: 8 }}>
<MemberAvatar name={p.memberName ?? ""} logoUrl={p.memberLogoUrl} size={34} />
{p.category && (
<span
style={{
background: "#9a6638",
color: "#fff",
borderRadius: 999,
padding: "5px 12px",
fontSize: 11,
fontWeight: 700,
}}
>
{p.category}
</span>
)}
</div>
</div>
<div style={{ padding: "17px 18px 18px", display: "flex", flexDirection: "column", flex: 1 }}>
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 18, margin: "0 0 5px", color: "#26201a" }}>
+73
View File
@@ -0,0 +1,73 @@
/**
* Pastille ronde d'un adhérent : son logo, sinon ses initiales.
*
* Sert à identifier le commerce d'un coup d'œil devant son nom, notamment sur
* les cartes de promotion. Le logo est rendu en `contain` sur fond blanc : la
* plupart des logos d'adhérents sont des marques sur fond clair, un `cover` les
* rognerait.
*/
export function memberInitials(name: string): string {
return name
.split(/\s+/)
.filter(Boolean)
.map((w) => w[0])
.join("")
.slice(0, 2)
.toUpperCase();
}
export function MemberAvatar({
name,
logoUrl,
size = 26,
accent = "#E0A63C",
}: {
name: string;
logoUrl?: string | null;
size?: number;
accent?: string;
}) {
const common: React.CSSProperties = {
width: size,
height: size,
borderRadius: "50%",
flexShrink: 0,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
overflow: "hidden",
};
if (logoUrl) {
return (
<span
aria-hidden="true"
style={{ ...common, background: "#fff", border: "1px solid #eee3cf" }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={logoUrl}
alt=""
style={{ width: "100%", height: "100%", objectFit: "contain", padding: 2, boxSizing: "border-box" }}
/>
</span>
);
}
return (
<span
aria-hidden="true"
style={{
...common,
background: accent,
color: "#fff",
fontWeight: 800,
fontSize: Math.round(size * 0.4),
letterSpacing: "0.02em",
}}
>
{memberInitials(name)}
</span>
);
}
+1
View File
@@ -87,6 +87,7 @@ export async function getLivePromotions(limit = 6) {
memberId: promotions.memberId,
memberName: members.name,
memberCity: members.city,
memberLogoUrl: members.logoUrl,
})
.from(promotions)
.leftJoin(members, eq(promotions.memberId, members.id))