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
+2284 -273

No files matched your search

+19
View File
@@ -154,6 +154,25 @@ site sans traitement supplémentaire.
sans adhérent elle passe en `NOINDEX` et sort du sitemap. L'annuaire et ces
pages listent les métiers via `CategoryLinks` pour le maillage interne.
## Adhérents
- L'espace adhérent est en deux pages : `/backend/espace` (profil, inscriptions,
droit à l'image) et `/backend/espace/promotions` (dépôt et suivi des promos,
les offres **en ligne** en tête). `EspaceHeader` porte le bandeau et les
onglets ; toute action qui touche l'espace revalide les deux chemins.
- La catégorie d'une promotion est un **type de produit ou de service**
(`src/lib/promo-categories.ts`, groupes pour `<optgroup>`), pas le métier de
l'adhérent ; `defaultPromoCategory(slug)` pré-sélectionne depuis le métier.
- `members.email` est l'e-mail **administratif** (identifiant de connexion à la
création, échanges avec l'association) ; `members.contact_email` est l'e-mail
**public** de la fiche, saisi par l'adhérent ou le staff. La fiche et le
JSON-LD affichent `contact_email || email` : ne montrez jamais `email` seul.
- `VitrineImage` rend la couverture en `cover`, sinon le logo en `contain`
(~60 % d'un cadre à hauteur fixe) sur un fond du logo flouté, sinon un
placeholder rayé. Ne pas l'entourer d'un conteneur sans hauteur : les
`max-height` en % ne seraient plus résolus et le logo déborderait.
## Roles
`admin` > `moderator` > `editor` are staff; `member` is an adhérent linked to a
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "members" ADD COLUMN "contact_email" varchar(200);
File diff suppressed because it is too large. Load diff
+7
View File
@@ -92,6 +92,13 @@
"when": 1788675288708,
"tag": "0012_referentiel_categories",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1788680191881,
"tag": "0013_contact_email",
"breakpoints": true
}
]
}
+11 -9
View File
@@ -167,6 +167,8 @@ export default async function FicheAdherentPage({
const openStatus = getOpenStatus(hours);
const normalizedWebsite = normalizeWebsite(member.website);
const shortWebsite = websiteLabel(member.website);
// E-mail public : celui saisi pour la fiche, sinon l'e-mail du compte.
const contactEmail = (member.contactEmail ?? "").trim() || member.email;
const phoneHref = member.phone ? `tel:${member.phone.replace(/[^\d+]/g, "")}` : null;
const sectionCard: React.CSSProperties = {
@@ -192,7 +194,7 @@ export default async function FicheAdherentPage({
{ name: "Annuaire", path: "/annuaire" },
{ name: member.name, path: canonical },
]),
localBusinessJsonLd(baseUrl, { ...member, tags: formatTags(tags) }, hours, normalizedWebsite),
localBusinessJsonLd(baseUrl, { ...member, email: contactEmail, tags: formatTags(tags) }, hours, normalizedWebsite),
]}
/>
@@ -362,8 +364,8 @@ export default async function FicheAdherentPage({
{phoneHref && member.phone && (
<ContactAction href={phoneHref} icon="phone" label="Appeler" detail={member.phone} />
)}
{member.email && (
<ContactAction href={`mailto:${member.email}`} icon="mail" label="Envoyer un e-mail" detail={member.email} />
{contactEmail && (
<ContactAction href={`mailto:${contactEmail}`} icon="mail" label="Envoyer un e-mail" detail={contactEmail} />
)}
{normalizedWebsite && shortWebsite && (
<ContactAction href={normalizedWebsite} icon="website" label="Visiter le site" detail={shortWebsite} external />
@@ -377,7 +379,7 @@ export default async function FicheAdherentPage({
external
/>
)}
{!phoneHref && !member.email && !normalizedWebsite && !fullAddress && (
{!phoneHref && !contactEmail && !normalizedWebsite && !fullAddress && (
<div className="practical-empty">Coordonnées non communiquées.</div>
)}
</div>
@@ -445,18 +447,18 @@ export default async function FicheAdherentPage({
</section>
{/* contact CTA */}
{(member.email || phoneHref || normalizedWebsite) && (
{(contactEmail || phoneHref || normalizedWebsite) && (
<section style={{ background: "linear-gradient(120deg,#13324F,#1d4a72)", borderRadius: 18, padding: 22, color: "#fff" }}>
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 17, margin: "0 0 6px" }}>Une question pour ce commerçant ?</h3>
<p style={{ margin: "0 0 14px", fontSize: 13.5, color: "#bcd3e6" }}>Contactez {member.name} directement.</p>
<a
href={member.email ? `mailto:${member.email}` : phoneHref ?? normalizedWebsite ?? undefined}
target={!member.email && !phoneHref ? "_blank" : undefined}
rel={!member.email && !phoneHref ? "noopener noreferrer" : undefined}
href={contactEmail ? `mailto:${contactEmail}` : phoneHref ?? normalizedWebsite ?? undefined}
target={!contactEmail && !phoneHref ? "_blank" : undefined}
rel={!contactEmail && !phoneHref ? "noopener noreferrer" : undefined}
className="font-display"
style={{ textDecoration: "none", display: "block", textAlign: "center", background: "#E0A63C", color: "#33291D", fontWeight: 700, fontSize: 14.5, padding: 12, borderRadius: 11 }}
>
{member.email ? "Envoyer un message" : phoneHref ? "Appeler maintenant" : "Visiter le site"}
{contactEmail ? "Envoyer un message" : phoneHref ? "Appeler maintenant" : "Visiter le site"}
</a>
</section>
)}
+6 -2
View File
@@ -24,7 +24,8 @@ const TITLES: Record<string, [string, string]> = {
"/backend/administrateurs": ["Administrateurs", "Gérer les accès à l'administration"],
"/backend/categories": ["Catégories", "Gérer les métiers de l'annuaire"],
"/backend/parametres": ["Paramètres du site", "Configurer l'association et les mentions légales"],
"/backend/espace": ["Mon espace adhérent", "Publiez et suivez vos promotions"],
"/backend/espace": ["Mon profil", "Votre fiche publique, vos inscriptions et votre droit à l'image"],
"/backend/espace/promotions": ["Mes promotions", "Proposez une offre et suivez celles en ligne"],
"/backend/changer-mot-de-passe": ["Mot de passe", "Sécurisez votre compte"],
};
@@ -85,7 +86,10 @@ const SECTIONS: NavSection[] = [
},
{
label: "Côté adhérent",
items: [{ href: "/backend/espace", label: "Mon espace", icon: "space" }],
items: [
{ href: "/backend/espace", label: "Mon profil", icon: "space" },
{ href: "/backend/espace/promotions", label: "Mes promotions", icon: "promos" },
],
},
];
+10
View File
@@ -117,6 +117,7 @@ function asImageDataUri(formData: FormData, key: string): string | null {
function revalidatePromoPaths(memberId?: number | null) {
revalidatePath("/backend/promotions");
revalidatePath("/backend/espace");
revalidatePath("/backend/espace/promotions");
revalidatePath("/backend");
revalidatePath("/");
revalidatePath("/annuaire");
@@ -334,6 +335,7 @@ export async function retryPromoShare(formData: FormData) {
await publishPromoShares(id, [network], userId);
revalidatePath("/backend/promotions");
revalidatePath("/backend/espace");
revalidatePath("/backend/espace/promotions");
}
// ---- Member space: choisir les réseaux tant que la promo est en attente ----
@@ -355,6 +357,7 @@ export async function setOwnPromoShareTargets(formData: FormData) {
await db.update(promotions).set(readShareTargets(formData)).where(eq(promotions.id, id));
revalidatePath("/backend/espace");
revalidatePath("/backend/espace/promotions");
revalidatePath("/backend/promotions");
}
@@ -385,6 +388,7 @@ export async function publishPromo(formData: FormData) {
await logActivity(`<strong>${name}</strong> a soumis une promotion « ${title} »`, "#E0A63C");
revalidatePath("/backend/espace");
revalidatePath("/backend/espace/promotions");
revalidatePath("/backend");
revalidatePath("/backend/promotions");
}
@@ -555,6 +559,7 @@ export async function updateMember(formData: FormData) {
.set({
name: String(formData.get("name") ?? "").trim(),
email: String(formData.get("email") ?? "").trim(),
contactEmail: String(formData.get("contactEmail") ?? "").trim().toLowerCase() || null,
categoryId,
city,
address: String(formData.get("address") ?? "").trim() || null,
@@ -860,6 +865,7 @@ function revalidateMeetingPaths(meetingId?: number) {
revalidatePath("/backend/inscriptions");
revalidatePath("/backend/rencontres-passees");
revalidatePath("/backend/espace");
revalidatePath("/backend/espace/promotions");
revalidatePath("/rencontres-passees");
if (meetingId) revalidatePath(`/inscription/${meetingId}`);
}
@@ -1052,6 +1058,7 @@ export async function saveImageConsent(formData: FormData) {
revalidatePath("/backend");
revalidatePath("/backend/espace");
revalidatePath("/backend/espace/promotions");
redirect("/backend/espace");
}
@@ -1120,6 +1127,7 @@ export async function updateOwnProfile(formData: FormData) {
.update(members)
.set({
name,
contactEmail: String(formData.get("contactEmail") ?? "").trim().toLowerCase() || null,
description,
address: String(formData.get("address") ?? "").trim() || null,
postalCode: String(formData.get("postalCode") ?? "").trim() || null,
@@ -1137,6 +1145,7 @@ export async function updateOwnProfile(formData: FormData) {
.where(eq(members.id, memberId));
revalidatePath("/backend/espace");
revalidatePath("/backend/espace/promotions");
revalidatePath("/adherents/[id]", "page");
revalidatePath("/annuaire");
revalidatePath("/");
@@ -1304,6 +1313,7 @@ export async function disconnectSocial(formData: FormData) {
revalidatePath("/backend/reseaux");
revalidatePath("/backend/promotions");
revalidatePath("/backend/espace");
revalidatePath("/backend/espace/promotions");
}
// ---- Sign out ----
+6 -1
View File
@@ -73,9 +73,14 @@ export default async function EditMemberPage({
<input name="name" required defaultValue={member.name} className="field" />
</div>
<div>
<label className="field-label">E-mail</label>
<label className="field-label">E-mail du compte (identifiant, échanges avec l&apos;association)</label>
<input name="email" type="email" defaultValue={member.email} className="field" />
</div>
<div>
<label className="field-label">E-mail de contact (affiché sur la fiche)</label>
<input name="contactEmail" type="email" defaultValue={member.contactEmail ?? ""} className="field" placeholder={member.email} />
<p className="field-hint">Laissé vide, la fiche publique affiche l&apos;e-mail du compte.</p>
</div>
<div>
<label className="field-label">Catégorie</label>
<select name="categoryId" className="field" defaultValue={member.categoryId ?? ""}>
+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>
);
}
+50
View File
@@ -948,6 +948,56 @@ img {
color: var(--muted);
margin-bottom: 6px;
}
/* Onglets de l'espace adhérent (Profil / Promotions), sous le bandeau. */
.espace-tabs {
display: flex;
gap: 4px;
background: #fff;
border: 1px solid #e6dcc6;
border-top: none;
border-radius: 0 0 16px 16px;
padding: 6px;
}
.espace-tab {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 11px 14px;
border-radius: 11px;
font-family: "Bricolage Grotesque", "Public Sans", system-ui, sans-serif;
font-weight: 700;
font-size: 14.5px;
color: var(--muted);
text-decoration: none;
transition: background 0.15s ease, color 0.15s ease;
}
.espace-tab:hover {
background: #faf7ef;
color: var(--brown);
}
.espace-tab.active {
background: var(--gold);
color: #33291D;
}
.espace-tab__badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
height: 22px;
padding: 0 7px;
border-radius: 999px;
background: #1f8a5b;
color: #fff;
font-size: 12px;
font-weight: 800;
}
.espace-tab.active .espace-tab__badge {
background: #33291D;
color: #fff;
}
/* Suggestions de tags : pastilles cliquables sous le champ. */
.tag-suggestions {
display: flex;
+31 -16
View File
@@ -6,7 +6,9 @@ const STRIPE_WARM =
/**
* Zone d'image "vitrine" d'un adhérent.
* - Photo de couverture présente -> affichée en plein cadre (cover).
* - Sinon, le logo est affiché centré et réduit (contain ~80%), pas étiré.
* - Sinon, le logo est affiché centré et réduit (contain, ~60 % du cadre) sur
* un fond fait du logo lui-même flouté : le cadre prend les couleurs de la
* marque sans que le logo soit étiré ni ne déborde.
* - Sinon, fond rayé + texte placeholder.
*/
export function VitrineImage({
@@ -43,30 +45,43 @@ export function VitrineImage({
}
if (logoOnly) {
// `height` est fixe sur le cadre : les max-height en % du logo sont donc
// résolus, là où un conteneur intermédiaire sans hauteur les laissait sans
// effet et le logo débordait.
return (
<div style={{ ...base, background: "linear-gradient(160deg,#fffdf8 0%,#f3ecdc 100%)", padding: "min(8%, 22px)" }}>
{/* Cadre thème autour du logo (évite le bloc blanc nu) */}
<div style={{ ...base, background: "linear-gradient(160deg,#fffdf8 0%,#f3ecdc 100%)" }}>
<div
aria-hidden
style={{
background: "#fff",
border: "1px solid #e6dcc6",
borderRadius: 14,
boxShadow: "0 14px 30px -20px rgba(40,30,15,0.35)",
padding: "min(6%, 18px) min(9%, 28px)",
maxWidth: "84%",
maxHeight: "84%",
display: "flex",
alignItems: "center",
justifyContent: "center",
position: "absolute",
inset: 0,
backgroundImage: `url(${logoOnly})`,
backgroundSize: "cover",
backgroundPosition: "center",
filter: "blur(28px) saturate(1.25)",
transform: "scale(1.25)",
opacity: 0.32,
}}
>
/>
<div
aria-hidden
style={{ position: "absolute", inset: 0, background: "linear-gradient(180deg, rgba(255,253,248,0.35) 0%, rgba(246,242,232,0.6) 100%)" }}
/>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={logoOnly}
alt=""
style={{ maxWidth: "100%", maxHeight: "100%", objectFit: "contain", display: "block" }}
style={{
position: "relative",
maxWidth: "62%",
maxHeight: "62%",
width: "auto",
height: "auto",
objectFit: "contain",
display: "block",
filter: "drop-shadow(0 12px 24px rgba(40,30,15,0.18))",
}}
/>
</div>
{children}
</div>
);
+4
View File
@@ -52,7 +52,11 @@ export const categories = pgTable("categories", {
export const members = pgTable("members", {
id: serial("id").primaryKey(),
name: varchar("name", { length: 200 }).notNull(),
// E-mail administratif : identifiant de connexion à la création, échanges
// avec l'association. Jamais forcément celui que le commerce veut afficher.
email: varchar("email", { length: 200 }).notNull(),
// E-mail public de la fiche ; à vide, la fiche retombe sur `email`.
contactEmail: varchar("contact_email", { length: 200 }),
categoryId: integer("category_id").references(() => categories.id),
city: varchar("city", { length: 120 }),
address: varchar("address", { length: 240 }),
+262
View File
@@ -0,0 +1,262 @@
/**
* Catégories de promotion : le **type de produit ou de service** mis en avant,
* indépendant du métier de l'adhérent (une boulangerie peut promouvoir un
* goûter, un garage des pneus). Affichées en pastille sur la carte de la promo.
*
* Module pur, groupé pour un `<select>` à `<optgroup>`.
*/
export type PromoCategoryGroup = { label: string; items: string[] };
export const PROMO_CATEGORY_GROUPS: PromoCategoryGroup[] = [
{
label: "Alimentation",
items: [
"Pain & viennoiseries",
"Pâtisserie & desserts",
"Chocolats & confiserie",
"Viandes & charcuterie",
"Poissons & fruits de mer",
"Fruits & légumes",
"Fromages & produits laitiers",
"Épicerie & produits du terroir",
"Produits bio & locaux",
"Vins, bières & spiritueux",
"Café, thé & boissons",
"Plats préparés & traiteur",
],
},
{
label: "Restauration & sorties",
items: [
"Menu & formule du midi",
"Repas & dîner",
"Pizzas & restauration rapide",
"Brunch, goûter & glaces",
"Apéritif & afterwork",
"Soirée & animation",
],
},
{
label: "Mode & accessoires",
items: [
"Vêtements femme",
"Vêtements homme",
"Vêtements enfant",
"Lingerie & maillots",
"Chaussures",
"Sacs & maroquinerie",
"Bijoux & montres",
"Lunettes & solaires",
],
},
{
label: "Beauté & bien-être",
items: [
"Coiffure",
"Barbier",
"Soins visage & corps",
"Épilation",
"Ongles & manucure",
"Maquillage & parfums",
"Massage & spa",
"Tatouage & piercing",
],
},
{
label: "Maison & équipement",
items: [
"Meubles & décoration",
"Literie",
"Cuisine & salle de bain",
"Électroménager",
"TV, high-tech & téléphonie",
"Linge de maison & arts de la table",
"Luminaires",
"Bazar & petit équipement",
"Fleurs & plantes",
"Jardin & extérieur",
"Bricolage & outillage",
],
},
{
label: "Auto & mobilité",
items: [
"Entretien & révision",
"Pneus",
"Carrosserie & pare-brise",
"Contrôle technique",
"Carburant & lavage",
"Vélos & motos",
"Location de véhicule",
"Auto-école & permis",
],
},
{
label: "Santé, famille & animaux",
items: [
"Pharmacie & parapharmacie",
"Optique & audition",
"Puériculture & bébé",
"Jouets & jeux",
"Animalerie & alimentation animale",
"Vétérinaire & toilettage",
],
},
{
label: "Services",
items: [
"Travaux & rénovation",
"Plomberie, chauffage & électricité",
"Nettoyage & entretien",
"Aide à domicile",
"Pressing, retouches & cordonnerie",
"Photographe & vidéo",
"Informatique & réparation",
"Communication, web & impression",
"Comptabilité, juridique & conseil",
"Banque, assurance & crédit",
"Immobilier",
"Déménagement & transport",
"Événementiel & location de matériel",
],
},
{
label: "Loisirs, culture & formation",
items: [
"Sport & fitness",
"Articles de sport",
"Livres, presse & papeterie",
"Culture, arts & spectacles",
"Cours & formation",
"Voyages & séjours",
"Hôtel & hébergement",
],
},
{
label: "Offres spéciales",
items: [
"Offre de bienvenue",
"Carte de fidélité",
"Parrainage",
"Déstockage & fin de série",
"Nouveauté",
"Autre",
],
},
];
export const PROMO_CATEGORIES: string[] = PROMO_CATEGORY_GROUPS.flatMap((g) => g.items);
/** Catégorie de promotion proposée par défaut selon le métier de l'adhérent. */
const DEFAULT_BY_MEMBER_CATEGORY: Record<string, string> = {
alimentation: "Épicerie & produits du terroir",
"epicerie-fine": "Épicerie & produits du terroir",
supermarche: "Épicerie & produits du terroir",
boulangerie: "Pain & viennoiseries",
"boucherie-charcuterie": "Viandes & charcuterie",
poissonnerie: "Poissons & fruits de mer",
primeur: "Fruits & légumes",
fromagerie: "Fromages & produits laitiers",
cave: "Vins, bières & spiritueux",
"brasserie-artisanale": "Vins, bières & spiritueux",
chocolaterie: "Chocolats & confiserie",
"glacier-salon-de-the": "Brunch, goûter & glaces",
traiteur: "Plats préparés & traiteur",
restauration: "Menu & formule du midi",
pizzeria: "Pizzas & restauration rapide",
"restauration-rapide": "Pizzas & restauration rapide",
"cafe-bar": "Apéritif & afterwork",
"mode-beaute": "Vêtements femme",
"mode-vetements": "Vêtements femme",
lingerie: "Lingerie & maillots",
"vetements-enfants": "Vêtements enfant",
"chaussures-maroquinerie": "Chaussures",
bijouterie: "Bijoux & montres",
coiffure: "Coiffure",
barbier: "Barbier",
esthetique: "Soins visage & corps",
onglerie: "Ongles & manucure",
parfumerie: "Maquillage & parfums",
"bien-etre": "Massage & spa",
optique: "Lunettes & solaires",
tatouage: "Tatouage & piercing",
"bazar-discount": "Bazar & petit équipement",
decoration: "Meubles & décoration",
cuisiniste: "Cuisine & salle de bain",
literie: "Literie",
"arts-de-la-table": "Linge de maison & arts de la table",
"electromenager-multimedia": "Électroménager",
fleuriste: "Fleurs & plantes",
jardinerie: "Jardin & extérieur",
"bricolage-jardinage": "Bricolage & outillage",
animalerie: "Animalerie & alimentation animale",
artisanat: "Meubles & décoration",
batiment: "Travaux & rénovation",
architecte: "Travaux & rénovation",
maconnerie: "Travaux & rénovation",
"couverture-toiture": "Travaux & rénovation",
plomberie: "Plomberie, chauffage & électricité",
electricite: "Plomberie, chauffage & électricité",
"isolation-energie": "Travaux & rénovation",
menuiserie: "Travaux & rénovation",
"serrurerie-metallerie": "Travaux & rénovation",
"carrelage-sols": "Travaux & rénovation",
peinture: "Travaux & rénovation",
paysagiste: "Jardin & extérieur",
"depannage-multiservices": "Travaux & rénovation",
automobile: "Entretien & révision",
carrosserie: "Carrosserie & pare-brise",
"controle-technique": "Contrôle technique",
"station-service-lavage": "Carburant & lavage",
"location-vehicules": "Location de véhicule",
"cycles-motos": "Vélos & motos",
"auto-ecole": "Auto-école & permis",
"taxi-vtc": "Déménagement & transport",
sante: "Pharmacie & parapharmacie",
medical: "Pharmacie & parapharmacie",
audioprothese: "Optique & audition",
veterinaire: "Vétérinaire & toilettage",
"banque-assurance": "Banque, assurance & crédit",
assurance: "Banque, assurance & crédit",
"courtier-credit": "Banque, assurance & crédit",
immobilier: "Immobilier",
"diagnostic-immobilier": "Immobilier",
services: "Autre",
"services-a-la-personne": "Aide à domicile",
"pressing-retouches": "Pressing, retouches & cordonnerie",
photographe: "Photographe & vidéo",
"pompes-funebres": "Autre",
"agence-voyages": "Voyages & séjours",
demenagement: "Déménagement & transport",
comptabilite: "Comptabilité, juridique & conseil",
juridique: "Comptabilité, juridique & conseil",
conseil: "Comptabilité, juridique & conseil",
communication: "Communication, web & impression",
"imprimerie-enseignes": "Communication, web & impression",
informatique: "Informatique & réparation",
nettoyage: "Nettoyage & entretien",
transport: "Déménagement & transport",
securite: "Autre",
coworking: "Autre",
"interim-rh": "Autre",
"recyclage-dechets": "Autre",
"librairie-papeterie": "Livres, presse & papeterie",
"tabac-presse": "Livres, presse & papeterie",
"jouets-puericulture": "Jouets & jeux",
"sport-loisirs": "Sport & fitness",
"articles-de-sport": "Articles de sport",
fitness: "Sport & fitness",
"culture-arts": "Culture, arts & spectacles",
"education-formation": "Cours & formation",
"tourisme-hotellerie": "Hôtel & hébergement",
evenementiel: "Événementiel & location de matériel",
industrie: "Autre",
agriculture: "Produits bio & locaux",
"partenaire-institutionnel": "Autre",
};
export function defaultPromoCategory(memberCategorySlug: string | null | undefined): string {
const found = memberCategorySlug ? DEFAULT_BY_MEMBER_CATEGORY[memberCategorySlug] : undefined;
return found && PROMO_CATEGORIES.includes(found) ? found : "Autre";
}
+1
View File
@@ -85,6 +85,7 @@ export async function getPublicMember(id: number) {
id: members.id,
name: members.name,
email: members.email,
contactEmail: members.contactEmail,
description: members.description,
city: members.city,
address: members.address,
+26
View File
@@ -0,0 +1,26 @@
import { strict as assert } from "node:assert";
import { describe, it } from "node:test";
import { CATEGORY_REFERENTIAL } from "../src/db/categories";
import { PROMO_CATEGORIES, PROMO_CATEGORY_GROUPS, defaultPromoCategory } from "../src/lib/promo-categories";
describe("Catégories de promotion (types de produits)", () => {
it("propose une liste large, sans doublon, groupée", () => {
assert.ok(PROMO_CATEGORIES.length >= 70, `liste trop courte : ${PROMO_CATEGORIES.length}`);
assert.equal(new Set(PROMO_CATEGORIES).size, PROMO_CATEGORIES.length, "doublon");
assert.ok(PROMO_CATEGORY_GROUPS.every((g) => g.items.length > 0 && g.label));
assert.ok(PROMO_CATEGORIES.includes("Autre"));
for (const c of PROMO_CATEGORIES) assert.ok(c.length <= 120, `trop long pour la colonne : ${c}`);
});
it("pré-sélectionne un type cohérent pour chaque métier du référentiel", () => {
for (const c of CATEGORY_REFERENTIAL) {
const d = defaultPromoCategory(c.slug);
assert.ok(PROMO_CATEGORIES.includes(d), `${c.slug} → ${d} inconnu`);
}
assert.equal(defaultPromoCategory("boulangerie"), "Pain & viennoiseries");
assert.equal(defaultPromoCategory("bazar-discount"), "Bazar & petit équipement");
assert.equal(defaultPromoCategory("inconnu"), "Autre");
assert.equal(defaultPromoCategory(null), "Autre");
});
});