mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-11 17:27:54 +02:00
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:
7 files changed
+126
-25
No files matched your search
@@ -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'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>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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" }}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
Reference in new issue
Block a user