mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-24 15:48:05 +02:00
Affiche les coordonnées du référent aux visiteurs connectés
L'annuaire, les pages métier et la fiche adhérent montrent désormais la personne à joindre chez l'adhérent — nom, prénom, ligne directe — mais uniquement à un visiteur connecté. Trois colonnes sur `members` (`contact_first_name`, `contact_last_name`, `contact_phone`), saisies dans les deux formulaires de fiche via le composant partagé `MemberContactFields`. Le filtrage ne repose pas sur du CSS ni sur un rendu conditionnel tardif : aucune requête publique ne lit ces colonnes. Seules `getMemberContacts()` et `getMemberContact()` les rapatrient, et les pages ne les appellent qu'après un `getSession()` positif. Un visiteur anonyme ne reçoit donc rien — ni dans le HTML, ni dans le JSON-LD, ni dans les props du composant client de l'annuaire. `tests/security.test.ts` verrouille cette séparation. `src/lib/member-contact.ts` reste pur (composition du nom, lien `tel:`) et est couvert par `tests/member-contact.test.ts`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012TAK3c4jAUqxVMzy746wWQ
This commit is contained in:
20 files changed
+1854
-7
No files matched your search
@@ -248,6 +248,18 @@ même raison.
|
|||||||
(`src/lib/promo-categories.ts`, groupes pour `<optgroup>`), pas le métier de
|
(`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.
|
l'adhérent ; `defaultPromoCategory(slug)` pré-sélectionne depuis le métier.
|
||||||
|
|
||||||
|
- **Référent** : `members.contact_first_name` / `contact_last_name` /
|
||||||
|
`contact_phone` portent la personne à joindre et sa ligne directe. Ils sont
|
||||||
|
**réservés aux visiteurs connectés** : aucune requête publique ne les lit
|
||||||
|
(`tests/security.test.ts` le verrouille), seules `getMemberContacts()` /
|
||||||
|
`getMemberContact()` les rapatrient, et uniquement après un `getSession()`
|
||||||
|
positif. L'annuaire, les pages métier et la fiche affichent alors un bloc
|
||||||
|
« Contact adhérent » ; hors session la requête n'est pas faite, donc rien
|
||||||
|
n'est masqué en CSS, rien ne part dans le HTML ni dans le JSON-LD.
|
||||||
|
`src/lib/member-contact.ts` est **pur** (`tests/member-contact.test.ts`) :
|
||||||
|
`memberContact()` compose « Prénom Nom » et renvoie `null` s'il n'y a rien à
|
||||||
|
montrer, `telHref()` fabrique le lien `tel:`. Les deux formulaires de fiche
|
||||||
|
(espace adhérent et écran staff) partagent `MemberContactFields`.
|
||||||
- `members.email` est l'e-mail **administratif** (identifiant de connexion à la
|
- `members.email` est l'e-mail **administratif** (identifiant de connexion à la
|
||||||
création, échanges avec l'association) ; `members.contact_email` est l'e-mail
|
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
|
**public** de la fiche, saisi par l'adhérent ou le staff. La fiche et le
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE "members" ADD COLUMN "contact_first_name" varchar(120);--> statement-breakpoint
|
||||||
|
ALTER TABLE "members" ADD COLUMN "contact_last_name" varchar(120);--> statement-breakpoint
|
||||||
|
ALTER TABLE "members" ADD COLUMN "contact_phone" varchar(40);
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -120,6 +120,13 @@
|
|||||||
"when": 1788692576315,
|
"when": 1788692576315,
|
||||||
"tag": "0016_tiresome_korg",
|
"tag": "0016_tiresome_korg",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 17,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1789385157231,
|
||||||
|
"tag": "0017_regular_devos",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -7,7 +7,9 @@ import { SiteFooter } from "@/components/SiteFooter";
|
|||||||
import { VitrineImage } from "@/components/VitrineImage";
|
import { VitrineImage } from "@/components/VitrineImage";
|
||||||
import { PromoImage } from "@/components/PromoImage";
|
import { PromoImage } from "@/components/PromoImage";
|
||||||
import { JsonLd } from "@/components/JsonLd";
|
import { JsonLd } from "@/components/JsonLd";
|
||||||
import { getMemberLivePromotions, getPublicMember } from "@/lib/queries";
|
import { getMemberContact, getMemberLivePromotions, getPublicMember } from "@/lib/queries";
|
||||||
|
import { getSession } from "@/lib/session";
|
||||||
|
import { telHref } from "@/lib/member-contact";
|
||||||
import {
|
import {
|
||||||
NOINDEX,
|
NOINDEX,
|
||||||
breadcrumbJsonLd,
|
breadcrumbJsonLd,
|
||||||
@@ -144,7 +146,16 @@ export default async function FicheAdherentPage({
|
|||||||
const canonical = memberPath(member);
|
const canonical = memberPath(member);
|
||||||
if (`/adherents/${id}` !== canonical) permanentRedirect(canonical);
|
if (`/adherents/${id}` !== canonical) permanentRedirect(canonical);
|
||||||
|
|
||||||
const [promos, baseUrl] = await Promise.all([getMemberLivePromotions(memberId), publicBaseUrl()]);
|
const [promos, baseUrl, session] = await Promise.all([
|
||||||
|
getMemberLivePromotions(memberId),
|
||||||
|
publicBaseUrl(),
|
||||||
|
getSession(),
|
||||||
|
]);
|
||||||
|
// Nom, prénom et ligne directe du référent : réservés aux visiteurs
|
||||||
|
// connectés. La requête n'est pas faite pour un visiteur anonyme, et ces
|
||||||
|
// champs restent hors du JSON-LD et des métadonnées de la fiche.
|
||||||
|
const contact = session?.user ? await getMemberContact(memberId) : null;
|
||||||
|
const contactTel = telHref(contact?.phone);
|
||||||
const accent = member.accent ?? "#E0A63C";
|
const accent = member.accent ?? "#E0A63C";
|
||||||
|
|
||||||
const fullAddress = [member.address, [member.postalCode, member.city].filter(Boolean).join(" ")]
|
const fullAddress = [member.address, [member.postalCode, member.city].filter(Boolean).join(" ")]
|
||||||
@@ -384,6 +395,27 @@ export default async function FicheAdherentPage({
|
|||||||
<div className="practical-empty">Coordonnées non communiquées.</div>
|
<div className="practical-empty">Coordonnées non communiquées.</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{contact && (
|
||||||
|
<div className="contact-referent">
|
||||||
|
<div className="contact-referent__label">
|
||||||
|
<span style={{ width: 7, height: 7, borderRadius: "50%", background: accent }} />
|
||||||
|
Contact adhérent
|
||||||
|
</div>
|
||||||
|
{contact.name && <div className="contact-referent__name">{contact.name}</div>}
|
||||||
|
{contact.phone &&
|
||||||
|
(contactTel ? (
|
||||||
|
<a className="contact-referent__phone" href={contactTel}>
|
||||||
|
{contact.phone}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<div className="contact-referent__phone">{contact.phone}</div>
|
||||||
|
))}
|
||||||
|
<div className="contact-referent__hint">
|
||||||
|
Visible uniquement parce que vous êtes connecté : ces coordonnées ne sont pas
|
||||||
|
publiées sur le site.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -2,10 +2,13 @@
|
|||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { MemberCard, type MemberCardData } from "@/components/MemberCard";
|
import { MemberCard, type MemberCardData } from "@/components/MemberCard";
|
||||||
|
import type { MemberContact } from "@/lib/member-contact";
|
||||||
|
|
||||||
export type DirectoryMember = MemberCardData & {
|
export type DirectoryMember = MemberCardData & {
|
||||||
hasPromo: boolean;
|
hasPromo: boolean;
|
||||||
promoBadge: string | null;
|
promoBadge: string | null;
|
||||||
|
/** Référent de l'adhérent, `null` tant que le visiteur n'est pas connecté. */
|
||||||
|
contact: MemberContact | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AnnuaireClient({
|
export function AnnuaireClient({
|
||||||
@@ -176,7 +179,7 @@ export function AnnuaireClient({
|
|||||||
{list.length > 0 ? (
|
{list.length > 0 ? (
|
||||||
<div className="grid grid-3" style={{ gap: 20 }}>
|
<div className="grid grid-3" style={{ gap: 20 }}>
|
||||||
{list.map((m, i) => (
|
{list.map((m, i) => (
|
||||||
<MemberCard key={m.id} m={m} index={i} />
|
<MemberCard key={m.id} m={m} index={i} contact={m.contact} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ import {
|
|||||||
getCategoriesInUse,
|
getCategoriesInUse,
|
||||||
getCategoryBySlug,
|
getCategoryBySlug,
|
||||||
getLiveBadgesByMember,
|
getLiveBadgesByMember,
|
||||||
|
getMemberContacts,
|
||||||
} from "@/lib/queries";
|
} from "@/lib/queries";
|
||||||
|
import { getSession } from "@/lib/session";
|
||||||
import {
|
import {
|
||||||
NOINDEX,
|
NOINDEX,
|
||||||
breadcrumbJsonLd,
|
breadcrumbJsonLd,
|
||||||
@@ -77,12 +79,16 @@ export default async function CategoriePage({ params }: { params: Params }) {
|
|||||||
const category = await getCategoryBySlug(categorie);
|
const category = await getCategoryBySlug(categorie);
|
||||||
if (!category) notFound();
|
if (!category) notFound();
|
||||||
|
|
||||||
const [rawMembers, badges, categoriesInUse, baseUrl] = await Promise.all([
|
const [rawMembers, badges, categoriesInUse, baseUrl, session] = await Promise.all([
|
||||||
getActiveMembersByCategory(category.id),
|
getActiveMembersByCategory(category.id),
|
||||||
getLiveBadgesByMember(),
|
getLiveBadgesByMember(),
|
||||||
getCategoriesInUse(),
|
getCategoriesInUse(),
|
||||||
publicBaseUrl(),
|
publicBaseUrl(),
|
||||||
|
getSession(),
|
||||||
]);
|
]);
|
||||||
|
// Même règle que sur l'annuaire : le référent n'est lu que pour un visiteur
|
||||||
|
// connecté, et reste hors du JSON-LD (construit sur `members`).
|
||||||
|
const contacts = session?.user ? await getMemberContacts() : null;
|
||||||
const badgeByMember = new Map<number, string | null>();
|
const badgeByMember = new Map<number, string | null>();
|
||||||
for (const b of badges) {
|
for (const b of badges) {
|
||||||
if (b.memberId != null && !badgeByMember.has(b.memberId)) badgeByMember.set(b.memberId, b.badge);
|
if (b.memberId != null && !badgeByMember.has(b.memberId)) badgeByMember.set(b.memberId, b.badge);
|
||||||
@@ -148,7 +154,7 @@ export default async function CategoriePage({ params }: { params: Params }) {
|
|||||||
{members.length > 0 ? (
|
{members.length > 0 ? (
|
||||||
<div className="grid grid-3" style={{ gap: 20 }}>
|
<div className="grid grid-3" style={{ gap: 20 }}>
|
||||||
{members.map((m, i) => (
|
{members.map((m, i) => (
|
||||||
<MemberCard key={m.id} m={m} index={i} />
|
<MemberCard key={m.id} m={m} index={i} contact={contacts?.get(m.id) ?? null} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import {
|
|||||||
getAllCategories,
|
getAllCategories,
|
||||||
getCategoriesInUse,
|
getCategoriesInUse,
|
||||||
getLiveBadgesByMember,
|
getLiveBadgesByMember,
|
||||||
|
getMemberContacts,
|
||||||
} from "@/lib/queries";
|
} from "@/lib/queries";
|
||||||
|
import { getSession } from "@/lib/session";
|
||||||
import { CategoryLinks } from "@/components/CategoryLinks";
|
import { CategoryLinks } from "@/components/CategoryLinks";
|
||||||
import { JsonLd } from "@/components/JsonLd";
|
import { JsonLd } from "@/components/JsonLd";
|
||||||
import { breadcrumbJsonLd, memberListJsonLd, pageMetadata } from "@/lib/seo";
|
import { breadcrumbJsonLd, memberListJsonLd, pageMetadata } from "@/lib/seo";
|
||||||
@@ -32,14 +34,21 @@ export default async function AnnuairePage({
|
|||||||
searchParams: Promise<{ q?: string }>;
|
searchParams: Promise<{ q?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { q } = await searchParams;
|
const { q } = await searchParams;
|
||||||
const [rawMembers, categories, badges, baseUrl, categoriesInUse] = await Promise.all([
|
const [rawMembers, categories, badges, baseUrl, categoriesInUse, session] = await Promise.all([
|
||||||
getActiveMembersWithCategory(),
|
getActiveMembersWithCategory(),
|
||||||
getAllCategories(),
|
getAllCategories(),
|
||||||
getLiveBadgesByMember(),
|
getLiveBadgesByMember(),
|
||||||
publicBaseUrl(),
|
publicBaseUrl(),
|
||||||
getCategoriesInUse(),
|
getCategoriesInUse(),
|
||||||
|
getSession(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Coordonnées du référent : réservées aux visiteurs connectés. Pour un
|
||||||
|
// visiteur anonyme la requête n'est pas faite du tout, donc rien ne part
|
||||||
|
// dans le HTML ni vers le composant client.
|
||||||
|
const signedIn = Boolean(session?.user);
|
||||||
|
const contacts = signedIn ? await getMemberContacts() : null;
|
||||||
|
|
||||||
const badgeByMember = new Map<number, string | null>();
|
const badgeByMember = new Map<number, string | null>();
|
||||||
for (const b of badges) {
|
for (const b of badges) {
|
||||||
if (b.memberId != null && !badgeByMember.has(b.memberId)) {
|
if (b.memberId != null && !badgeByMember.has(b.memberId)) {
|
||||||
@@ -51,6 +60,7 @@ export default async function AnnuairePage({
|
|||||||
...m,
|
...m,
|
||||||
hasPromo: badgeByMember.has(m.id),
|
hasPromo: badgeByMember.has(m.id),
|
||||||
promoBadge: badgeByMember.get(m.id) ?? null,
|
promoBadge: badgeByMember.get(m.id) ?? null,
|
||||||
|
contact: contacts?.get(m.id) ?? null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -97,6 +107,12 @@ export default async function AnnuairePage({
|
|||||||
<p style={{ margin: 0, fontSize: 16.5, color: "#6c6150", maxWidth: 560 }}>
|
<p style={{ margin: 0, fontSize: 16.5, color: "#6c6150", maxWidth: 560 }}>
|
||||||
Trouvez un professionnel du Bassin de Pompey, découvrez sa fiche et ses bons plans.
|
Trouvez un professionnel du Bassin de Pompey, découvrez sa fiche et ses bons plans.
|
||||||
</p>
|
</p>
|
||||||
|
{signedIn && (
|
||||||
|
<p style={{ margin: "10px 0 0", fontSize: 13.5, color: "#9a8d72", maxWidth: 560 }}>
|
||||||
|
Vous êtes connecté : les coordonnées du référent de chaque adhérent (nom, prénom,
|
||||||
|
téléphone) sont affichées sur les fiches ci-dessous.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<AnnuaireClient members={members} categories={categories} initialQuery={q ?? ""} />
|
<AnnuaireClient members={members} categories={categories} initialQuery={q ?? ""} />
|
||||||
|
|||||||
@@ -525,6 +525,9 @@ export async function updateMember(formData: FormData) {
|
|||||||
name: String(formData.get("name") ?? "").trim(),
|
name: String(formData.get("name") ?? "").trim(),
|
||||||
email: String(formData.get("email") ?? "").trim(),
|
email: String(formData.get("email") ?? "").trim(),
|
||||||
contactEmail: String(formData.get("contactEmail") ?? "").trim().toLowerCase() || null,
|
contactEmail: String(formData.get("contactEmail") ?? "").trim().toLowerCase() || null,
|
||||||
|
contactFirstName: String(formData.get("contactFirstName") ?? "").trim() || null,
|
||||||
|
contactLastName: String(formData.get("contactLastName") ?? "").trim() || null,
|
||||||
|
contactPhone: String(formData.get("contactPhone") ?? "").trim() || null,
|
||||||
categoryId,
|
categoryId,
|
||||||
city,
|
city,
|
||||||
address: String(formData.get("address") ?? "").trim() || null,
|
address: String(formData.get("address") ?? "").trim() || null,
|
||||||
@@ -543,6 +546,7 @@ export async function updateMember(formData: FormData) {
|
|||||||
|
|
||||||
revalidatePath("/backend/adherents");
|
revalidatePath("/backend/adherents");
|
||||||
revalidatePath("/adherents/[id]", "page");
|
revalidatePath("/adherents/[id]", "page");
|
||||||
|
revalidatePath("/annuaire");
|
||||||
revalidatePath("/");
|
revalidatePath("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1095,6 +1099,9 @@ export async function updateOwnProfile(formData: FormData) {
|
|||||||
.set({
|
.set({
|
||||||
name,
|
name,
|
||||||
contactEmail: String(formData.get("contactEmail") ?? "").trim().toLowerCase() || null,
|
contactEmail: String(formData.get("contactEmail") ?? "").trim().toLowerCase() || null,
|
||||||
|
contactFirstName: String(formData.get("contactFirstName") ?? "").trim() || null,
|
||||||
|
contactLastName: String(formData.get("contactLastName") ?? "").trim() || null,
|
||||||
|
contactPhone: String(formData.get("contactPhone") ?? "").trim() || null,
|
||||||
description,
|
description,
|
||||||
address: String(formData.get("address") ?? "").trim() || null,
|
address: String(formData.get("address") ?? "").trim() || null,
|
||||||
postalCode: String(formData.get("postalCode") ?? "").trim() || null,
|
postalCode: String(formData.get("postalCode") ?? "").trim() || null,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { db } from "@/db";
|
|||||||
import { categories, members, users } from "@/db/schema";
|
import { categories, members, users } from "@/db/schema";
|
||||||
import { can } from "@/lib/rbac";
|
import { can } from "@/lib/rbac";
|
||||||
import { ImageField } from "@/components/ImageField";
|
import { ImageField } from "@/components/ImageField";
|
||||||
|
import { MemberContactFields } from "@/components/MemberContactFields";
|
||||||
import { HoursEditor } from "@/components/HoursEditor";
|
import { HoursEditor } from "@/components/HoursEditor";
|
||||||
import { TagsField } from "@/components/TagsField";
|
import { TagsField } from "@/components/TagsField";
|
||||||
import { communeOptions } from "@/lib/communes";
|
import { communeOptions } from "@/lib/communes";
|
||||||
@@ -132,6 +133,12 @@ export default async function EditMemberPage({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<MemberContactFields
|
||||||
|
firstName={member.contactFirstName}
|
||||||
|
lastName={member.contactLastName}
|
||||||
|
phone={member.contactPhone}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="grid grid-2" style={{ gap: 16, marginTop: 16 }}>
|
<div className="grid grid-2" style={{ gap: 16, marginTop: 16 }}>
|
||||||
<ImageField name="coverUrl" label="Image d'entête (bannière)" defaultValue={member.coverUrl ?? ""} height={130} />
|
<ImageField name="coverUrl" label="Image d'entête (bannière)" defaultValue={member.coverUrl ?? ""} height={130} />
|
||||||
<ImageField name="logoUrl" label="Logo" defaultValue={member.logoUrl ?? ""} height={130} fit="contain" />
|
<ImageField name="logoUrl" label="Logo" defaultValue={member.logoUrl ?? ""} height={130} fit="contain" />
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { ImageField } from "@/components/ImageField";
|
|||||||
import { ImageConsentForm } from "@/components/ImageConsentForm";
|
import { ImageConsentForm } from "@/components/ImageConsentForm";
|
||||||
import { HoursEditor } from "@/components/HoursEditor";
|
import { HoursEditor } from "@/components/HoursEditor";
|
||||||
import { TagsField } from "@/components/TagsField";
|
import { TagsField } from "@/components/TagsField";
|
||||||
|
import { MemberContactFields } from "@/components/MemberContactFields";
|
||||||
import { communeOptions } from "@/lib/communes";
|
import { communeOptions } from "@/lib/communes";
|
||||||
import { saveImageConsent, updateOwnProfile } from "../actions";
|
import { saveImageConsent, updateOwnProfile } from "../actions";
|
||||||
import { EspaceHeader } from "./EspaceHeader";
|
import { EspaceHeader } from "./EspaceHeader";
|
||||||
@@ -167,6 +168,12 @@ export default async function EspacePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<MemberContactFields
|
||||||
|
firstName={profile.contactFirstName}
|
||||||
|
lastName={profile.contactLastName}
|
||||||
|
phone={profile.contactPhone}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="grid grid-2" style={{ gap: 16, marginTop: 16 }}>
|
<div className="grid grid-2" style={{ gap: 16, marginTop: 16 }}>
|
||||||
<ImageField name="coverUrl" label="Image d'entête (bannière)" defaultValue={profile.coverUrl ?? ""} height={130} />
|
<ImageField name="coverUrl" label="Image d'entête (bannière)" defaultValue={profile.coverUrl ?? ""} height={130} />
|
||||||
<ImageField name="logoUrl" label="Logo" defaultValue={profile.logoUrl ?? ""} height={130} fit="contain" />
|
<ImageField name="logoUrl" label="Logo" defaultValue={profile.logoUrl ?? ""} height={130} fit="contain" />
|
||||||
|
|||||||
@@ -1403,6 +1403,48 @@ img {
|
|||||||
color: var(--muted-4);
|
color: var(--muted-4);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
/* Référent de l'adhérent : rendu seulement quand le visiteur est connecté —
|
||||||
|
le HTML public ne contient jamais ce bloc, rien n'est masqué en CSS. */
|
||||||
|
.contact-referent {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.contact-referent__label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
color: var(--muted-2);
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.contact-referent__name {
|
||||||
|
margin-top: 7px;
|
||||||
|
color: var(--ink-strong);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.contact-referent__phone {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 3px;
|
||||||
|
color: var(--blue);
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.contact-referent__phone:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.contact-referent__hint {
|
||||||
|
margin-top: 7px;
|
||||||
|
color: var(--muted-4);
|
||||||
|
font-size: 11.5px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- Éditeur hebdomadaire des horaires ---- */
|
/* ---- Éditeur hebdomadaire des horaires ---- */
|
||||||
.hours-editor {
|
.hours-editor {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { VitrineImage } from "@/components/VitrineImage";
|
import { VitrineImage } from "@/components/VitrineImage";
|
||||||
import { memberPath } from "@/lib/seo";
|
import { memberPath } from "@/lib/seo";
|
||||||
|
import type { MemberContact } from "@/lib/member-contact";
|
||||||
|
|
||||||
export type MemberCardData = {
|
export type MemberCardData = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -44,8 +45,21 @@ const clampLines = (lines: number) =>
|
|||||||
/**
|
/**
|
||||||
* Carte d'un adhérent dans une grille (annuaire, page métier). Composant sans
|
* Carte d'un adhérent dans une grille (annuaire, page métier). Composant sans
|
||||||
* état : il se rend aussi bien côté serveur que dans le filtre client.
|
* état : il se rend aussi bien côté serveur que dans le filtre client.
|
||||||
|
*
|
||||||
|
* `contact` (nom du référent, ligne directe) n'est passé qu'aux visiteurs
|
||||||
|
* connectés : la page ne le rapatrie pas du tout pour un visiteur anonyme, il
|
||||||
|
* n'y a donc rien à masquer en CSS. Le numéro reste en texte simple — un
|
||||||
|
* `tel:` imbriqué dans le lien de la carte serait un lien dans un lien.
|
||||||
*/
|
*/
|
||||||
export function MemberCard({ m, index = 0 }: { m: MemberCardData; index?: number }) {
|
export function MemberCard({
|
||||||
|
m,
|
||||||
|
index = 0,
|
||||||
|
contact = null,
|
||||||
|
}: {
|
||||||
|
m: MemberCardData;
|
||||||
|
index?: number;
|
||||||
|
contact?: MemberContact | null;
|
||||||
|
}) {
|
||||||
const location = [m.address, m.city].filter(Boolean).join(" · ");
|
const location = [m.address, m.city].filter(Boolean).join(" · ");
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
@@ -87,6 +101,21 @@ export function MemberCard({ m, index = 0 }: { m: MemberCardData; index?: number
|
|||||||
<span style={{ width: 7, height: 7, borderRadius: "50%", background: m.accent ?? "#E0A63C", flex: "0 0 auto" }} />
|
<span style={{ width: 7, height: 7, borderRadius: "50%", background: m.accent ?? "#E0A63C", flex: "0 0 auto" }} />
|
||||||
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{location}</span>
|
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{location}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{contact && (
|
||||||
|
<div style={{ marginTop: 10, borderTop: "1px dashed #e6dcc6", paddingTop: 10 }}>
|
||||||
|
<div style={{ fontSize: 10.5, letterSpacing: "0.1em", textTransform: "uppercase", fontWeight: 800, color: "#9a8d72", marginBottom: 4 }}>
|
||||||
|
Contact adhérent
|
||||||
|
</div>
|
||||||
|
{contact.name && (
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 700, color: "#3c3322", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||||
|
{contact.name}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{contact.phone && (
|
||||||
|
<div style={{ fontSize: 12.5, color: "#6c6150", marginTop: 2 }}>{contact.phone}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* Coordonnées du référent de l'adhérent (nom, prénom, ligne directe), dans les
|
||||||
|
* deux formulaires de fiche : l'espace adhérent et l'écran staff.
|
||||||
|
*
|
||||||
|
* Ces champs ne sont jamais publiés : l'annuaire et la fiche publique ne les
|
||||||
|
* affichent qu'à un visiteur connecté (`src/lib/member-contact.ts`).
|
||||||
|
*/
|
||||||
|
export function MemberContactFields({
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
phone,
|
||||||
|
}: {
|
||||||
|
firstName: string | null;
|
||||||
|
lastName: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<fieldset
|
||||||
|
style={{
|
||||||
|
marginTop: 16,
|
||||||
|
border: "1px solid #f0e8d6",
|
||||||
|
borderRadius: 12,
|
||||||
|
background: "#faf7ef",
|
||||||
|
padding: 16,
|
||||||
|
minWidth: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<legend className="field-label" style={{ padding: "0 6px" }}>
|
||||||
|
Contact adhérent
|
||||||
|
</legend>
|
||||||
|
<p className="field-hint" style={{ margin: "0 0 12px" }}>
|
||||||
|
La personne à joindre et sa ligne directe. Affiché sur l'annuaire et sur la fiche
|
||||||
|
<strong> uniquement aux visiteurs connectés</strong> : rien de tout cela n'apparaît sur
|
||||||
|
le site public ni dans les moteurs de recherche.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-2" style={{ gap: 16 }}>
|
||||||
|
<div>
|
||||||
|
<label className="field-label">Prénom</label>
|
||||||
|
<input name="contactFirstName" defaultValue={firstName ?? ""} className="field" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="field-label">Nom</label>
|
||||||
|
<input name="contactLastName" defaultValue={lastName ?? ""} className="field" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="field-label">Téléphone direct</label>
|
||||||
|
<input name="contactPhone" defaultValue={phone ?? ""} className="field" placeholder="06 12 ..." />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,6 +19,9 @@ export function demoMembers(catId: (slug: string) => number | null) {
|
|||||||
address: "12 rue de la République",
|
address: "12 rue de la République",
|
||||||
postalCode: "54390",
|
postalCode: "54390",
|
||||||
phone: "03 83 49 00 00",
|
phone: "03 83 49 00 00",
|
||||||
|
contactFirstName: "Julien",
|
||||||
|
contactLastName: "Marchal",
|
||||||
|
contactPhone: "06 12 34 56 78",
|
||||||
website: "https://www.aubonpain-frouard.fr",
|
website: "https://www.aubonpain-frouard.fr",
|
||||||
memberSince: 2021,
|
memberSince: 2021,
|
||||||
tags: "Levain naturel, Produits locaux, Fait maison, Sans conservateur",
|
tags: "Levain naturel, Produits locaux, Fait maison, Sans conservateur",
|
||||||
@@ -36,6 +39,9 @@ export function demoMembers(catId: (slug: string) => number | null) {
|
|||||||
address: "3 place Stanislas",
|
address: "3 place Stanislas",
|
||||||
postalCode: "54340",
|
postalCode: "54340",
|
||||||
phone: "03 83 24 00 00",
|
phone: "03 83 24 00 00",
|
||||||
|
contactFirstName: "Sophie",
|
||||||
|
contactLastName: "Renard",
|
||||||
|
contactPhone: "06 98 76 54 32",
|
||||||
memberSince: 2019,
|
memberSince: 2019,
|
||||||
tags: "Cuisine de saison, Brunch, Terrasse, Produits frais",
|
tags: "Cuisine de saison, Brunch, Terrasse, Produits frais",
|
||||||
hours: "Lundi – Vendredi|9h – 18h\nSamedi|9h – 19h\nDimanche|10h – 15h",
|
hours: "Lundi – Vendredi|9h – 18h\nSamedi|9h – 19h\nDimanche|10h – 15h",
|
||||||
@@ -49,6 +55,9 @@ export function demoMembers(catId: (slug: string) => number | null) {
|
|||||||
categoryId: catId("mode-beaute"),
|
categoryId: catId("mode-beaute"),
|
||||||
city: "Champigneulles",
|
city: "Champigneulles",
|
||||||
address: "7 av. des Tilleuls",
|
address: "7 av. des Tilleuls",
|
||||||
|
contactFirstName: "Émilie",
|
||||||
|
contactLastName: "Bertrand",
|
||||||
|
contactPhone: "06 45 67 89 01",
|
||||||
description: "Salon de coiffure & soins, sur rendez-vous du mardi au samedi.",
|
description: "Salon de coiffure & soins, sur rendez-vous du mardi au samedi.",
|
||||||
status: "active" as const,
|
status: "active" as const,
|
||||||
highlighted: true,
|
highlighted: true,
|
||||||
|
|||||||
@@ -62,6 +62,13 @@ export const members = pgTable("members", {
|
|||||||
email: varchar("email", { length: 200 }).notNull(),
|
email: varchar("email", { length: 200 }).notNull(),
|
||||||
// E-mail public de la fiche ; à vide, la fiche retombe sur `email`.
|
// E-mail public de la fiche ; à vide, la fiche retombe sur `email`.
|
||||||
contactEmail: varchar("contact_email", { length: 200 }),
|
contactEmail: varchar("contact_email", { length: 200 }),
|
||||||
|
// Référent de l'adhérent : la personne à joindre au sein du commerce, avec
|
||||||
|
// sa ligne directe. Contrairement aux coordonnées de l'établissement, ces
|
||||||
|
// trois champs ne sont montrés qu'aux visiteurs connectés (cf.
|
||||||
|
// `src/lib/member-contact.ts`), jamais dans le HTML public ni le JSON-LD.
|
||||||
|
contactFirstName: varchar("contact_first_name", { length: 120 }),
|
||||||
|
contactLastName: varchar("contact_last_name", { length: 120 }),
|
||||||
|
contactPhone: varchar("contact_phone", { length: 40 }),
|
||||||
categoryId: integer("category_id").references(() => categories.id),
|
categoryId: integer("category_id").references(() => categories.id),
|
||||||
city: varchar("city", { length: 120 }),
|
city: varchar("city", { length: 120 }),
|
||||||
address: varchar("address", { length: 240 }),
|
address: varchar("address", { length: 240 }),
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* Référent d'un adhérent : la personne à joindre, sa ligne directe.
|
||||||
|
*
|
||||||
|
* Ces informations sont **réservées aux visiteurs connectés** : elles ne
|
||||||
|
* doivent jamais partir dans le HTML public, le JSON-LD ou les métadonnées.
|
||||||
|
* Le module est pur — le filtrage par session se fait chez l'appelant, qui
|
||||||
|
* n'appelle `memberContact()` qu'après avoir vérifié la session, et verrouillé
|
||||||
|
* par `tests/member-contact.test.ts`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type MemberContactSource = {
|
||||||
|
contactFirstName?: string | null;
|
||||||
|
contactLastName?: string | null;
|
||||||
|
contactPhone?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MemberContact = {
|
||||||
|
/** « Prénom Nom », ou l'un des deux seulement. */
|
||||||
|
name: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function clean(value: string | null | undefined) {
|
||||||
|
const trimmed = (value ?? "").replace(/\s+/g, " ").trim();
|
||||||
|
return trimmed || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** « Prénom Nom » à partir des deux champs, `null` si les deux sont vides. */
|
||||||
|
export function contactName(source: MemberContactSource): string | null {
|
||||||
|
return clean([clean(source.contactFirstName), clean(source.contactLastName)].filter(Boolean).join(" "));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Le référent affichable, ou `null` s'il n'y a rien à montrer : le bloc
|
||||||
|
* « Contact adhérent » disparaît alors plutôt que d'afficher un cadre vide.
|
||||||
|
*/
|
||||||
|
export function memberContact(source: MemberContactSource): MemberContact | null {
|
||||||
|
const name = contactName(source);
|
||||||
|
const phone = clean(source.contactPhone);
|
||||||
|
if (!name && !phone) return null;
|
||||||
|
return { name, phone };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `tel:` à partir d'un numéro saisi librement (« 03 83 24 .. », « +33 3 83 … »).
|
||||||
|
* Seuls les chiffres sont conservés, avec l'indicatif international s'il ouvre
|
||||||
|
* le numéro ; une saisie sans aucun chiffre ne produit pas de lien.
|
||||||
|
*/
|
||||||
|
export function telHref(phone: string | null | undefined): string | null {
|
||||||
|
const raw = clean(phone);
|
||||||
|
if (!raw) return null;
|
||||||
|
const international = raw.startsWith("+");
|
||||||
|
const digits = raw.replace(/\D/g, "");
|
||||||
|
if (!digits) return null;
|
||||||
|
return `tel:${international ? "+" : ""}${digits}`;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { and, asc, count, desc, eq, sql } from "drizzle-orm";
|
import { and, asc, count, desc, eq, sql } from "drizzle-orm";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { categories, members, promotions } from "@/db/schema";
|
import { categories, members, promotions } from "@/db/schema";
|
||||||
|
import { memberContact, type MemberContact } from "@/lib/member-contact";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Promotion réellement visible par le public : validée **et** dans sa période
|
* Promotion réellement visible par le public : validée **et** dans sa période
|
||||||
@@ -135,6 +136,46 @@ export async function getPublicMember(id: number) {
|
|||||||
return m ?? null;
|
return m ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Référents des adhérents actifs, indexés par fiche.
|
||||||
|
*
|
||||||
|
* Volontairement séparé des lectures publiques (`getActiveMembersWithCategory`,
|
||||||
|
* `getPublicMember`) : nom, prénom et ligne directe ne sont réservés aux
|
||||||
|
* visiteurs connectés que parce qu'aucune requête publique ne les rapatrie.
|
||||||
|
* L'appelant vérifie la session **avant** d'appeler.
|
||||||
|
*/
|
||||||
|
export async function getMemberContacts(): Promise<Map<number, MemberContact>> {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: members.id,
|
||||||
|
contactFirstName: members.contactFirstName,
|
||||||
|
contactLastName: members.contactLastName,
|
||||||
|
contactPhone: members.contactPhone,
|
||||||
|
})
|
||||||
|
.from(members)
|
||||||
|
.where(eq(members.status, "active"));
|
||||||
|
|
||||||
|
const byMember = new Map<number, MemberContact>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const contact = memberContact(row);
|
||||||
|
if (contact) byMember.set(row.id, contact);
|
||||||
|
}
|
||||||
|
return byMember;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Référent d'une fiche. Même règle que `getMemberContacts()` : session vérifiée d'abord. */
|
||||||
|
export async function getMemberContact(id: number): Promise<MemberContact | null> {
|
||||||
|
const [row] = await db
|
||||||
|
.select({
|
||||||
|
contactFirstName: members.contactFirstName,
|
||||||
|
contactLastName: members.contactLastName,
|
||||||
|
contactPhone: members.contactPhone,
|
||||||
|
})
|
||||||
|
.from(members)
|
||||||
|
.where(eq(members.id, id));
|
||||||
|
return row ? memberContact(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getMemberLivePromotions(memberId: number) {
|
export async function getMemberLivePromotions(memberId: number) {
|
||||||
return db
|
return db
|
||||||
.select({
|
.select({
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
|
import { contactName, memberContact, telHref } from "../src/lib/member-contact";
|
||||||
|
|
||||||
|
test("le référent joint prénom et nom", () => {
|
||||||
|
assert.equal(contactName({ contactFirstName: "Marie", contactLastName: "Durand" }), "Marie Durand");
|
||||||
|
assert.equal(contactName({ contactFirstName: " Marie ", contactLastName: " Durand " }), "Marie Durand");
|
||||||
|
assert.equal(contactName({ contactFirstName: "Marie" }), "Marie");
|
||||||
|
assert.equal(contactName({ contactLastName: "Durand" }), "Durand");
|
||||||
|
assert.equal(contactName({}), null);
|
||||||
|
assert.equal(contactName({ contactFirstName: " ", contactLastName: null }), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un référent sans nom ni téléphone n'existe pas", () => {
|
||||||
|
assert.equal(memberContact({}), null);
|
||||||
|
assert.equal(memberContact({ contactFirstName: " ", contactPhone: "" }), null);
|
||||||
|
assert.deepEqual(memberContact({ contactPhone: "03 83 24 10 10" }), {
|
||||||
|
name: null,
|
||||||
|
phone: "03 83 24 10 10",
|
||||||
|
});
|
||||||
|
assert.deepEqual(memberContact({ contactLastName: "Durand", contactPhone: null }), {
|
||||||
|
name: "Durand",
|
||||||
|
phone: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("le lien tel: ne garde que les chiffres et l'indicatif", () => {
|
||||||
|
assert.equal(telHref("03 83 24 10 10"), "tel:0383241010");
|
||||||
|
assert.equal(telHref("+33 3 83 24 10 10"), "tel:+33383241010");
|
||||||
|
assert.equal(telHref("03.83.24.10.10"), "tel:0383241010");
|
||||||
|
assert.equal(telHref("sur rendez-vous"), null);
|
||||||
|
assert.equal(telHref(null), null);
|
||||||
|
assert.equal(telHref(" "), null);
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { strict as assert } from "node:assert";
|
import { strict as assert } from "node:assert";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
|
|
||||||
import { activityNodes, sanitizeActivityMessage } from "../src/lib/activity";
|
import { activityNodes, sanitizeActivityMessage } from "../src/lib/activity";
|
||||||
@@ -109,3 +110,46 @@ describe("Chiffrement des secrets réseaux", () => {
|
|||||||
assert.equal(tryDecryptSecret("pas-un-secret-chiffré"), null);
|
assert.equal(tryDecryptSecret("pas-un-secret-chiffré"), null);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Coordonnées du référent — jamais dans les lectures publiques", () => {
|
||||||
|
const source = readFileSync(new URL("../src/lib/queries.ts", import.meta.url), "utf8");
|
||||||
|
|
||||||
|
/** Corps d'une fonction exportée de `queries.ts`, jusqu'à la suivante. */
|
||||||
|
function bodyOf(name: string): string {
|
||||||
|
const start = source.indexOf(`export async function ${name}(`);
|
||||||
|
assert.notEqual(start, -1, `${name} introuvable dans src/lib/queries.ts`);
|
||||||
|
const next = source.indexOf("\nexport ", start + 1);
|
||||||
|
return source.slice(start, next === -1 ? source.length : next);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nom, prénom et ligne directe de l'adhérent ne sont montrés qu'à un visiteur
|
||||||
|
// connecté. La garantie tient à une seule chose : aucune requête servant une
|
||||||
|
// page publique ne les rapatrie — sinon ils partiraient dans le HTML, le
|
||||||
|
// JSON-LD ou les props du composant client de l'annuaire.
|
||||||
|
const publicReads = [
|
||||||
|
"getActiveMembersWithCategory",
|
||||||
|
"getActiveMembersByCategory",
|
||||||
|
"getRotatingActiveMembers",
|
||||||
|
"getPublicMember",
|
||||||
|
"getLivePromotions",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const name of publicReads) {
|
||||||
|
it(`${name} ne lit pas le référent`, () => {
|
||||||
|
const body = bodyOf(name);
|
||||||
|
for (const column of ["contactFirstName", "contactLastName", "contactPhone"]) {
|
||||||
|
assert.ok(
|
||||||
|
!body.includes(column),
|
||||||
|
`${name} sélectionne ${column} : ces coordonnées deviendraient publiques.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("les lectures réservées existent et sont séparées", () => {
|
||||||
|
for (const name of ["getMemberContacts", "getMemberContact"]) {
|
||||||
|
const body = bodyOf(name);
|
||||||
|
assert.ok(body.includes("contactPhone"), `${name} devrait lire le référent`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user