mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-11 17:27:54 +02:00
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:
18 files changed
+2284
-273
No files matched your search
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "members" ADD COLUMN "contact_email" varchar(200);
|
||||
File diff suppressed because it is too large.
Load diff
@@ -92,6 +92,13 @@
|
||||
"when": 1788675288708,
|
||||
"tag": "0012_referentiel_categories",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "7",
|
||||
"when": 1788680191881,
|
||||
"tag": "0013_contact_email",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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 ----
|
||||
|
||||
@@ -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'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'e-mail du compte.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Catégorie</label>
|
||||
<select name="categoryId" className="field" defaultValue={member.categoryId ?? ""}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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'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'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'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>
|
||||
);
|
||||
}
|
||||
@@ -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'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'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'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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user