mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-22 14:48:04 +02:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67cd9d6684 | ||
|
|
fb09bf0e3a | ||
|
|
7d72016bfb | ||
|
|
e59ae3a908 | ||
|
|
146250f973 | ||
|
|
dd16e05b47 | ||
|
|
e9b6fdfd91 | ||
|
|
503adcb20e | ||
|
|
50b8732080 | ||
|
|
4b12dc5164 | ||
|
|
353d38bebf | ||
|
|
a52e13e623 | ||
|
|
cf8cf5c8e2 | ||
|
|
d99ee7ec07 | ||
|
|
e980ab6e04 | ||
|
|
02c5378a47 | ||
|
|
81b99ed5fb | ||
|
|
17511a73c3 |
No files matched your search
@@ -50,11 +50,68 @@ docker compose up --build # full stack
|
||||
|
||||
## Promotions
|
||||
|
||||
Statuts : `pending` → `live` → `suspended` ⇄ `live` (+ `rejected` / `expired`).
|
||||
Statuts : `pending` → (`scheduled` →) `live` → `suspended` ⇄ `live`
|
||||
(+ `rejected` / `expired`).
|
||||
`promotions.suspended_by` retient qui a suspendu (`member` ou `staff`) : une
|
||||
suspension par le staff ne peut être levée que par le staff. Les lectures
|
||||
publiques filtrent sur `status = 'live'`, donc une promo suspendue disparaît du
|
||||
site sans traitement supplémentaire.
|
||||
publiques filtrent sur le statut `live`, donc une promo suspendue — ou encore
|
||||
programmée — disparaît du site sans traitement supplémentaire.
|
||||
|
||||
**Période de validité** : `promotions.starts_on` / `ends_on` (colonnes `date`,
|
||||
les deux facultatives et indépendantes). `src/lib/promo-validity.ts` est **pur**
|
||||
et verrouillé par `tests/promo-validity.test.ts` : `formatValidity()` rend
|
||||
« Valable du 1er au 15 mars 2027 » / « Valable jusqu'au … » / « Valable à partir
|
||||
du … », `formatValidityShort()` la variante sans « Valable » pour les lignes de
|
||||
méta, `isRangeInvalid()` garde le formulaire et l'action. Sans date, rien n'est
|
||||
affiché ; l'ancien texte libre `valid_until` (jamais écrit par l'application)
|
||||
ne sert plus que de repli d'affichage. La même phrase part dans le message
|
||||
Facebook / LinkedIn (`buildPromoMessage`) : un seul formateur pour le site,
|
||||
le backoffice et les réseaux.
|
||||
|
||||
La liste publique vit sur **`/promotions`** (toutes les offres en cours) ;
|
||||
l'accueil n'en montre que les six dernières et renvoie vers elle. Les deux
|
||||
listes partagent `src/components/PromoCard.tsx` : un seul rendu, pas deux
|
||||
cartes à maintenir. `getLivePromotions(limit)` accepte `null` pour « tout »,
|
||||
la limite restant appliquée en SQL. Les liens « Promotions » de l'en-tête, du
|
||||
pied de page et des publications réseaux pointent sur cette page, plus sur
|
||||
l'ancre `/#promotions`.
|
||||
|
||||
Une promotion n'est **affichée que pendant sa période** : les lectures de
|
||||
`src/lib/queries.ts` passent par `VISIBLE_PROMO` (statut `live` **et** fenêtre
|
||||
de dates, journée calculée en `Europe/Paris` — un conteneur en UTC retirerait
|
||||
sinon une offre deux heures trop tôt). Le statut n'est pas modifié : hors
|
||||
période, le backoffice affiche `visibilityNote()` (« pas encore affichée » /
|
||||
« période terminée »), et l'offre revient d'elle-même si les dates changent.
|
||||
|
||||
**Publication programmée** : `promotions.publish_at` (`timestamptz`, vide =
|
||||
mise en ligne immédiate). L'adhérent propose une date dans son formulaire, le
|
||||
modérateur la garde, la déplace ou la vide dans « Valider » — comme les cases
|
||||
réseaux, c'est le dernier moment où elle est ajustable. Une promotion validée
|
||||
avec une échéance future passe au statut `scheduled` : invisible du site et
|
||||
**rien n'est diffusé**. `releaseDuePromotions()` (`src/lib/promo-publish.ts`)
|
||||
fait la bascule `scheduled → live` par un `UPDATE … RETURNING` filtré sur le
|
||||
statut — atomique, donc sans double publication même à plusieurs instances —
|
||||
puis appelle `publishPromoShares()`. Le bouton « Publier maintenant » de la
|
||||
modération court-circuite l'attente.
|
||||
|
||||
`src/lib/promo-schedule.ts` est **pur** (`tests/promo-schedule.test.ts`) :
|
||||
il convertit la saisie `datetime-local` depuis/vers l'heure de l'association
|
||||
(`Europe/Paris`, changements d'heure compris) et refuse une saisie illisible
|
||||
plutôt que de la transformer en publication immédiate.
|
||||
|
||||
Le déclencheur est `src/instrumentation.ts` : une boucle d'une minute démarrée
|
||||
avec le serveur, avec un premier passage au démarrage pour rattraper les
|
||||
échéances tombées pendant un redéploiement. `PROMO_SCHEDULER=off` la désactive.
|
||||
Le travail vit dans `src/instrumentation-node.ts` parce que Next compile aussi
|
||||
`instrumentation.ts` pour le runtime edge du middleware : `next.config.mjs`
|
||||
l'écarte de ce bundle (`IgnorePlugin`), sans quoi webpack tente d'y embarquer
|
||||
`pg` et ses dépendances Node.
|
||||
|
||||
`src/lib/promo-publish.ts` porte `publishPromoShares()` — et non plus
|
||||
`backend/actions.ts` — parce qu'il a deux appelants : une action serveur et la
|
||||
boucle de libération, qui n'a pas de requête et ne peut donc pas appeler
|
||||
`revalidatePath()`. `src/lib/activity-log.ts` porte `logActivity()` pour la
|
||||
même raison.
|
||||
|
||||
## Réseaux sociaux
|
||||
|
||||
@@ -74,24 +131,51 @@ site sans traitement supplémentaire.
|
||||
anti-CSRF passe par un cookie httpOnly ; aucun jeton ne transite par une URL.
|
||||
- Facebook : le jeton de page n'expire pas. LinkedIn : 60 jours, rafraîchissement
|
||||
programmatique réservé à certains partenaires, d'où le bandeau de reconnexion.
|
||||
- « N'expire pas » ≠ « ne meurt jamais » : `checkTokenHealth()` interroge la
|
||||
plateforme et persiste le verdict dans `social_accounts.last_check_*`.
|
||||
L'écran Réseaux contrôle à chaque affichage ; le tableau de bord passe par
|
||||
`tokenHealthCached()` (6 h) et croise le résultat avec la dernière tentative
|
||||
de publication en échec. Une date d'expiration seule ne suffit pas à alerter.
|
||||
- Les images de promo sont stockées en data-URI : l'upload se fait donc en
|
||||
binaire (multipart pour Facebook, Images API en 3 étapes pour LinkedIn), pas
|
||||
par URL.
|
||||
- La diffusion est déclenchée **par la validation**, pas par un bouton :
|
||||
- L'image part **telle quelle** : aucun recadrage ni redimensionnement côté
|
||||
application. Un visuel non carré est donc recadré — ou entouré de bandes de
|
||||
couleur — par la plateforme. `src/lib/image-info.ts` (pur, verrouillé par
|
||||
`tests/image-info.test.ts`) décrit le fichier déposé (dimensions, format,
|
||||
poids) et `MemberSpaceForm` l'affiche sous l'aperçu : on informe, on ne
|
||||
bloque pas.
|
||||
- La diffusion est déclenchée **par la validation** (ou par l'échéance d'une
|
||||
publication programmée), pas par un bouton :
|
||||
`promotions.share_facebook` / `share_linkedin` sont choisis par l'adhérent,
|
||||
ajustables par le modérateur dans le formulaire « Valider », puis figés
|
||||
(`status !== 'pending'`).
|
||||
- `publishPromoShares()` dans `backend/actions.ts` est le seul point de
|
||||
- `publishPromoShares()` dans `src/lib/promo-publish.ts` est le seul point de
|
||||
publication. Elle ne lève jamais et ignore tout réseau ayant déjà une ligne
|
||||
`social_posts` en `posted` : c'est la garde anti-republication, qui couvre
|
||||
aussi le cycle suspension → remise en ligne.
|
||||
- `retryPromoShare` ne sert qu'au rattrapage d'un échec sur un réseau déjà
|
||||
choisi ; il ne peut pas élargir la diffusion.
|
||||
- L'URL publique du site : `siteUrl()` lit le réglage `site_public_url` puis
|
||||
`NEXT_PUBLIC_SITE_URL` / `AUTH_URL` ; `publicBaseUrl()` y ajoute un repli sur
|
||||
l'origine de la requête (`src/lib/site-url.ts`, en-têtes `X-Forwarded-*`).
|
||||
L'adresse de retour OAuth et les liens des publications passent par
|
||||
`publicBaseUrl()` : la connexion marche sans réglage tant que l'admin passe
|
||||
par l'adresse publique.
|
||||
- La CSP (`src/middleware.ts`) ne pose `upgrade-insecure-requests` qu'en HTTPS,
|
||||
sinon un test en HTTP par IP voit toutes ses navigations basculer vers un
|
||||
`https://` inexistant.
|
||||
- Les URLs publiques des pages FB/LinkedIn sont des `site_settings`
|
||||
(`association_facebook`, `association_linkedin`), éditables dans Paramètres.
|
||||
|
||||
## Sécurité
|
||||
|
||||
- **Erreurs des server actions** : un `throw` dans une action est masqué par
|
||||
Next en production (message remplacé par un digest) et fait tomber la page
|
||||
sur « Application error ». Les échecs **attendus** — e-mail déjà pris, champ
|
||||
manquant — sont donc **renvoyés** (`ActionError = { error: string }`) et
|
||||
affichés par le formulaire, qui conserve la saisie. Le `throw` reste réservé
|
||||
aux violations d'accès, qui n'ont pas à s'expliquer à l'utilisateur.
|
||||
- Le journal d'activité agrège des saisies de tiers, dont le formulaire de
|
||||
contact **public** : il est filtré à l'écriture (`sanitizeActivityMessage`) et
|
||||
rendu en éléments React (`activityNodes`), jamais en HTML brut.
|
||||
@@ -164,6 +248,18 @@ site sans traitement supplémentaire.
|
||||
(`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.
|
||||
|
||||
- **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
|
||||
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
|
||||
|
||||
@@ -87,12 +87,12 @@ vivent dans le volume `caddy_data` et se renouvellent seuls. Une fois le DNS
|
||||
posé, vérifier la délivrance avec
|
||||
`docker logs pleinr-caddy 2>&1 | grep -i "certificate obtained"`.
|
||||
|
||||
> **Vérification par l'IP** : en HTTP sur une adresse IP, la directive
|
||||
> `upgrade-insecure-requests` de la CSP (`src/middleware.ts`) peut empêcher le
|
||||
> navigateur de charger styles et scripts. Pour un test fidèle avant le DNS,
|
||||
> passer par un tunnel SSH — `ssh -L 8080:localhost:80 utilisateur@IP` — puis
|
||||
> ouvrir `http://localhost:8080` : `localhost` est une origine de confiance, la
|
||||
> directive ne s'y applique pas.
|
||||
> **Vérification par l'IP** : la directive `upgrade-insecure-requests` de la
|
||||
> CSP (`src/middleware.ts`) n'est posée que sur les pages servies en HTTPS. Un
|
||||
> test en HTTP par l'IP ou par le port direct fonctionne donc sans tunnel. En
|
||||
> revanche, Facebook et LinkedIn n'acceptent qu'une adresse de retour OAuth en
|
||||
> HTTPS sur un nom de domaine : la connexion des réseaux sociaux ne peut être
|
||||
> validée qu'une fois le DNS posé.
|
||||
|
||||
Mise à jour : `git pull && docker compose up -d --build`.
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "social_accounts" ADD COLUMN "last_check_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "social_accounts" ADD COLUMN "last_check_ok" boolean;--> statement-breakpoint
|
||||
ALTER TABLE "social_accounts" ADD COLUMN "last_check_error" text;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "promotions" ADD COLUMN "starts_on" date;--> statement-breakpoint
|
||||
ALTER TABLE "promotions" ADD COLUMN "ends_on" date;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TYPE "public"."promo_status" ADD VALUE 'scheduled';--> statement-breakpoint
|
||||
ALTER TABLE "promotions" ADD COLUMN "publish_at" timestamp with time zone;
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -99,6 +99,34 @@
|
||||
"when": 1788680191881,
|
||||
"tag": "0013_contact_email",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "7",
|
||||
"when": 1788691242685,
|
||||
"tag": "0014_lean_marvel_zombies",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "7",
|
||||
"when": 1788691741621,
|
||||
"tag": "0015_curious_winter_soldier",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "7",
|
||||
"when": 1788692576315,
|
||||
"tag": "0016_tiresome_korg",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "7",
|
||||
"when": 1789385157231,
|
||||
"tag": "0017_regular_devos",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -8,6 +8,20 @@ const nextConfig = {
|
||||
// Les images d'entête/logo sont envoyées en data-URL via server action.
|
||||
serverActions: { bodySizeLimit: "4mb" },
|
||||
},
|
||||
// `instrumentation.ts` est compilé pour les deux runtimes dès qu'un
|
||||
// middleware existe. Son chargement du libérateur de publications est déjà
|
||||
// conditionné à `NEXT_RUNTIME === "nodejs"`, mais webpack suit quand même
|
||||
// l'import et tente d'embarquer `pg` (donc `fs`, `path`, `stream`) dans le
|
||||
// bundle edge, où ces modules n'existent pas. On coupe la branche à la
|
||||
// source : côté edge, le module Node n'est tout simplement pas résolu.
|
||||
webpack: (config, { nextRuntime, webpack }) => {
|
||||
if (nextRuntime === "edge") {
|
||||
config.plugins.push(
|
||||
new webpack.IgnorePlugin({ resourceRegExp: /^\.\/instrumentation-node$/ })
|
||||
);
|
||||
}
|
||||
return config;
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -7,7 +7,9 @@ import { SiteFooter } from "@/components/SiteFooter";
|
||||
import { VitrineImage } from "@/components/VitrineImage";
|
||||
import { PromoImage } from "@/components/PromoImage";
|
||||
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 {
|
||||
NOINDEX,
|
||||
breadcrumbJsonLd,
|
||||
@@ -26,6 +28,7 @@ import {
|
||||
parseMemberHours,
|
||||
websiteLabel,
|
||||
} from "@/lib/member-profile";
|
||||
import { formatValidity } from "@/lib/promo-validity";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -143,7 +146,16 @@ export default async function FicheAdherentPage({
|
||||
const canonical = memberPath(member);
|
||||
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 fullAddress = [member.address, [member.postalCode, member.city].filter(Boolean).join(" ")]
|
||||
@@ -383,6 +395,27 @@ export default async function FicheAdherentPage({
|
||||
<div className="practical-empty">Coordonnées non communiquées.</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>
|
||||
</div>
|
||||
</section>
|
||||
@@ -437,8 +470,8 @@ export default async function FicheAdherentPage({
|
||||
<div style={{ padding: "14px 15px 15px" }}>
|
||||
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 16, margin: "0 0 5px", color: "#26201a" }}>{p.title}</h3>
|
||||
<p style={{ margin: "0 0 11px", fontSize: 13, color: "#8c8068", lineHeight: 1.5 }}>{p.text}</p>
|
||||
{p.validUntil && (
|
||||
<div style={{ borderTop: "1px solid #f0e8d6", paddingTop: 10, fontSize: 12, color: "#a99c82" }}>{p.validUntil}</div>
|
||||
{formatValidity(p) && (
|
||||
<div style={{ borderTop: "1px solid #f0e8d6", paddingTop: 10, fontSize: 12, color: "#a99c82" }}>{formatValidity(p)}</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { MemberCard, type MemberCardData } from "@/components/MemberCard";
|
||||
import type { MemberContact } from "@/lib/member-contact";
|
||||
|
||||
export type DirectoryMember = MemberCardData & {
|
||||
hasPromo: boolean;
|
||||
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({
|
||||
@@ -176,7 +179,7 @@ export function AnnuaireClient({
|
||||
{list.length > 0 ? (
|
||||
<div className="grid grid-3" style={{ gap: 20 }}>
|
||||
{list.map((m, i) => (
|
||||
<MemberCard key={m.id} m={m} index={i} />
|
||||
<MemberCard key={m.id} m={m} index={i} contact={m.contact} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -13,7 +13,9 @@ import {
|
||||
getCategoriesInUse,
|
||||
getCategoryBySlug,
|
||||
getLiveBadgesByMember,
|
||||
getMemberContacts,
|
||||
} from "@/lib/queries";
|
||||
import { getSession } from "@/lib/session";
|
||||
import {
|
||||
NOINDEX,
|
||||
breadcrumbJsonLd,
|
||||
@@ -77,12 +79,16 @@ export default async function CategoriePage({ params }: { params: Params }) {
|
||||
const category = await getCategoryBySlug(categorie);
|
||||
if (!category) notFound();
|
||||
|
||||
const [rawMembers, badges, categoriesInUse, baseUrl] = await Promise.all([
|
||||
const [rawMembers, badges, categoriesInUse, baseUrl, session] = await Promise.all([
|
||||
getActiveMembersByCategory(category.id),
|
||||
getLiveBadgesByMember(),
|
||||
getCategoriesInUse(),
|
||||
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>();
|
||||
for (const b of badges) {
|
||||
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 ? (
|
||||
<div className="grid grid-3" style={{ gap: 20 }}>
|
||||
{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>
|
||||
) : (
|
||||
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
getAllCategories,
|
||||
getCategoriesInUse,
|
||||
getLiveBadgesByMember,
|
||||
getMemberContacts,
|
||||
} from "@/lib/queries";
|
||||
import { getSession } from "@/lib/session";
|
||||
import { CategoryLinks } from "@/components/CategoryLinks";
|
||||
import { JsonLd } from "@/components/JsonLd";
|
||||
import { breadcrumbJsonLd, memberListJsonLd, pageMetadata } from "@/lib/seo";
|
||||
@@ -32,14 +34,21 @@ export default async function AnnuairePage({
|
||||
searchParams: Promise<{ q?: string }>;
|
||||
}) {
|
||||
const { q } = await searchParams;
|
||||
const [rawMembers, categories, badges, baseUrl, categoriesInUse] = await Promise.all([
|
||||
const [rawMembers, categories, badges, baseUrl, categoriesInUse, session] = await Promise.all([
|
||||
getActiveMembersWithCategory(),
|
||||
getAllCategories(),
|
||||
getLiveBadgesByMember(),
|
||||
publicBaseUrl(),
|
||||
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>();
|
||||
for (const b of badges) {
|
||||
if (b.memberId != null && !badgeByMember.has(b.memberId)) {
|
||||
@@ -51,6 +60,7 @@ export default async function AnnuairePage({
|
||||
...m,
|
||||
hasPromo: badgeByMember.has(m.id),
|
||||
promoBadge: badgeByMember.get(m.id) ?? null,
|
||||
contact: contacts?.get(m.id) ?? null,
|
||||
}));
|
||||
|
||||
return (
|
||||
@@ -97,6 +107,12 @@ export default async function AnnuairePage({
|
||||
<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.
|
||||
</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>
|
||||
|
||||
<AnnuaireClient members={members} categories={categories} initialQuery={q ?? ""} />
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
authorizeUrl,
|
||||
getDecryptedAppSecret,
|
||||
getSocialAccount,
|
||||
siteUrl,
|
||||
publicBaseUrl,
|
||||
SOCIAL_NETWORKS,
|
||||
type SocialNetwork,
|
||||
} from "@/lib/social-accounts";
|
||||
@@ -34,11 +34,13 @@ export async function GET(
|
||||
return back(request, "Réseau inconnu.");
|
||||
}
|
||||
|
||||
const base = await siteUrl();
|
||||
// Réglage enregistré, sinon l'adresse par laquelle l'administrateur est
|
||||
// arrivé : c'est celle où le réseau nous renverra.
|
||||
const base = await publicBaseUrl();
|
||||
if (!base) {
|
||||
return back(
|
||||
request,
|
||||
"Renseignez d'abord l'URL publique du site : elle sert à construire l'adresse de retour."
|
||||
"Impossible de déterminer l'adresse publique du site : renseignez-la dans la section « URL publique du site »."
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { auth } from "@/auth";
|
||||
import { db } from "@/db";
|
||||
import { meetingRegistrations, meetings, pastMeetingPhotos, pastMeetings } from "@/db/schema";
|
||||
import { JsonLd } from "@/components/JsonLd";
|
||||
import { MeetingCard } from "@/components/MeetingCard";
|
||||
import { PastMeetingGallery } from "@/components/PastMeetingGallery";
|
||||
import { SiteFooter } from "@/components/SiteFooter";
|
||||
import { SiteHeader } from "@/components/SiteHeader";
|
||||
@@ -232,40 +233,31 @@ export default async function AssociationPage() {
|
||||
const remaining = Math.max(0, meeting.capacity - registered);
|
||||
const isRegistered = myMeetingIds.has(meeting.id);
|
||||
return (
|
||||
<article id={`rencontre-${meeting.id}`} key={meeting.id} className="lift" style={{ background: "#fff", border: isRegistered ? "2px solid #1f8a5b" : "1px solid #e6dcc6", borderRadius: 12, overflow: "hidden", scrollMarginTop: 100 }}>
|
||||
<div style={{ position: "relative", aspectRatio: "16 / 10", background: meeting.imageUrl ? `center/cover no-repeat url(${meeting.imageUrl})` : "repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)" }}>
|
||||
{isRegistered && (
|
||||
<span style={{ position: "absolute", top: 12, right: 12, display: "inline-flex", alignItems: "center", gap: 6, background: "#1f8a5b", color: "#fff", borderRadius: 999, padding: "7px 12px", fontSize: 12, fontWeight: 800, boxShadow: "0 4px 14px rgba(22,72,49,0.28)" }}>
|
||||
✓ Inscrit
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ padding: 18 }}>
|
||||
<div style={{ color: "#9a6638", fontSize: 12.5, fontWeight: 800 }}>
|
||||
{formatDate(meeting.startsAt)} · {formatTime(meeting.startsAt)}
|
||||
</div>
|
||||
<h3 className="font-display" style={{ margin: "7px 0 7px", color: "#26201a", fontSize: 21 }}>
|
||||
{meeting.title}
|
||||
</h3>
|
||||
{meeting.location && <div style={{ fontSize: 13, color: "#6c6150", fontWeight: 700 }}>{meeting.location}</div>}
|
||||
<p style={{ color: "#8c8068", fontSize: 13.5, lineHeight: 1.6, minHeight: 64 }}>
|
||||
{meeting.description}
|
||||
</p>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", color: "#6c6150", fontSize: 12.5, fontWeight: 800, marginBottom: 12 }}>
|
||||
<span>{registered}/{meeting.capacity} inscrits</span>
|
||||
<span>{remaining} place(s)</span>
|
||||
</div>
|
||||
<div style={{ color: "#8c8068", fontSize: 12.5, marginBottom: 10 }}>
|
||||
Jusqu'à {meeting.participantsPerAccount} participant{meeting.participantsPerAccount > 1 ? "s" : ""} par compte
|
||||
</div>
|
||||
<Link
|
||||
href={`/inscription/${meeting.id}`}
|
||||
style={{ display: "block", textAlign: "center", textDecoration: "none", background: remaining <= 0 && !isRegistered ? "#a99c82" : "#13324F", color: "#fff", borderRadius: 8, padding: 11, fontWeight: 800 }}
|
||||
>
|
||||
{isRegistered ? "Modifier mon inscription" : remaining <= 0 ? "Complet" : memberId ? "S'inscrire" : "Connexion et inscription"}
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
<MeetingCard
|
||||
key={meeting.id}
|
||||
meeting={{
|
||||
id: meeting.id,
|
||||
title: meeting.title,
|
||||
description: meeting.description,
|
||||
location: meeting.location,
|
||||
imageUrl: meeting.imageUrl,
|
||||
// Formatées ici : côté client, le fuseau du visiteur
|
||||
// donnerait une autre heure et casserait l'hydratation.
|
||||
dateLabel: formatDate(meeting.startsAt),
|
||||
timeLabel: formatTime(meeting.startsAt),
|
||||
capacity: meeting.capacity,
|
||||
registered,
|
||||
participantsPerAccount: meeting.participantsPerAccount,
|
||||
isRegistered,
|
||||
ctaLabel: isRegistered
|
||||
? "Modifier mon inscription"
|
||||
: remaining <= 0
|
||||
? "Complet"
|
||||
: memberId
|
||||
? "S'inscrire"
|
||||
: "Connexion et inscription",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
+102
-116
@@ -4,7 +4,7 @@ import { randomInt } from "node:crypto";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { and, eq, ne, sql } from "drizzle-orm";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { signOut } from "@/auth";
|
||||
import { getSession } from "@/lib/session";
|
||||
@@ -13,7 +13,6 @@ import { CATEGORY_PALETTE } from "@/db/categories";
|
||||
import { slugify } from "@/lib/slug";
|
||||
import { autoTags } from "@/lib/tags";
|
||||
import {
|
||||
activityLog,
|
||||
categories,
|
||||
contactMessages,
|
||||
imageConsents,
|
||||
@@ -25,14 +24,10 @@ import {
|
||||
pastMeetings,
|
||||
promotions,
|
||||
siteSettings,
|
||||
socialPosts,
|
||||
users,
|
||||
} from "@/db/schema";
|
||||
import { sanitizeActivityMessage } from "@/lib/activity";
|
||||
import { can, LABEL_TO_ROLE } from "@/lib/rbac";
|
||||
import {
|
||||
isNetworkConfigured,
|
||||
publishPromoToNetwork,
|
||||
SOCIAL_LABELS,
|
||||
SOCIAL_NETWORKS,
|
||||
type SocialNetwork,
|
||||
@@ -43,16 +38,15 @@ import {
|
||||
selectTarget,
|
||||
} from "@/lib/social-accounts";
|
||||
import { normalizeWebsite } from "@/lib/member-profile";
|
||||
import { isRangeInvalid } from "@/lib/promo-validity";
|
||||
import { formatSchedule, isDue, parseScheduleInput } from "@/lib/promo-schedule";
|
||||
import { publishPromoShares, requestedNetworks } from "@/lib/promo-publish";
|
||||
import { logActivity } from "@/lib/activity-log";
|
||||
import { SITE_SETTING_DEFAULTS } from "@/lib/site-settings";
|
||||
import type { AppRole } from "@/types/next-auth";
|
||||
|
||||
|
||||
|
||||
async function logActivity(message: string, dot = "#2C6FB3") {
|
||||
// Le message agrège des saisies de tiers : on ne laisse passer que <strong>.
|
||||
await db.insert(activityLog).values({ message: sanitizeActivityMessage(message), dot });
|
||||
}
|
||||
|
||||
// Mot de passe temporaire lisible (sans caractères ambigus).
|
||||
// `randomInt` et non `Math.random()` : ce dernier n'est pas cryptographique et
|
||||
// permet de prédire les mots de passe suivants à partir de quelques tirages.
|
||||
@@ -114,6 +108,14 @@ function asImageDataUri(formData: FormData, key: string): string | null {
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Date `YYYY-MM-DD` facultative issue d'un `<input type="date">`. */
|
||||
function asOptionalDate(formData: FormData, key: string): string | null {
|
||||
const value = asString(formData, key);
|
||||
if (!value) return null;
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) throw new Error("Date invalide.");
|
||||
return value;
|
||||
}
|
||||
|
||||
function revalidatePromoPaths(memberId?: number | null) {
|
||||
revalidatePath("/backend/promotions");
|
||||
revalidatePath("/backend/espace");
|
||||
@@ -124,13 +126,6 @@ function revalidatePromoPaths(memberId?: number | null) {
|
||||
if (memberId) revalidatePath("/adherents/[id]", "page");
|
||||
}
|
||||
|
||||
/** Réseaux demandés pour une promo, dans l'ordre d'affichage. */
|
||||
function requestedNetworks(promo: { shareFacebook: boolean; shareLinkedin: boolean }): SocialNetwork[] {
|
||||
return SOCIAL_NETWORKS.filter((n) =>
|
||||
n === "facebook" ? promo.shareFacebook : promo.shareLinkedin
|
||||
);
|
||||
}
|
||||
|
||||
function readShareTargets(formData: FormData) {
|
||||
return {
|
||||
shareFacebook: formData.get("shareFacebook") === "on",
|
||||
@@ -138,84 +133,6 @@ function readShareTargets(formData: FormData) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Publie une promotion sur les réseaux demandés. Ne lève jamais : un réseau
|
||||
* indisponible ne doit pas faire échouer la mise en ligne, l'échec est
|
||||
* enregistré dans `social_posts` et rattrapable depuis le backoffice.
|
||||
*
|
||||
* Un réseau déjà publié avec succès est ignoré : c'est ce qui empêche toute
|
||||
* republication, y compris sur un cycle suspension → remise en ligne.
|
||||
*/
|
||||
async function publishPromoShares(
|
||||
promoId: number,
|
||||
networks: SocialNetwork[],
|
||||
userId: number | null
|
||||
) {
|
||||
if (networks.length === 0) return;
|
||||
|
||||
const [promo] = await db
|
||||
.select({
|
||||
title: promotions.title,
|
||||
text: promotions.text,
|
||||
badge: promotions.badge,
|
||||
validUntil: promotions.validUntil,
|
||||
imageUrl: promotions.imageUrl,
|
||||
memberId: promotions.memberId,
|
||||
memberName: members.name,
|
||||
memberCity: members.city,
|
||||
})
|
||||
.from(promotions)
|
||||
.leftJoin(members, eq(promotions.memberId, members.id))
|
||||
.where(eq(promotions.id, promoId));
|
||||
if (!promo) return;
|
||||
|
||||
const done = await db
|
||||
.select({ network: socialPosts.network })
|
||||
.from(socialPosts)
|
||||
.where(and(eq(socialPosts.promotionId, promoId), eq(socialPosts.status, "posted")));
|
||||
const alreadyPosted = new Set(done.map((d) => d.network));
|
||||
|
||||
for (const network of networks) {
|
||||
if (alreadyPosted.has(network)) continue;
|
||||
|
||||
if (!(await isNetworkConfigured(network))) {
|
||||
await db.insert(socialPosts).values({
|
||||
promotionId: promoId,
|
||||
network,
|
||||
status: "failed",
|
||||
error: `${SOCIAL_LABELS[network]} n'est pas configuré sur ce serveur.`,
|
||||
postedById: userId,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await publishPromoToNetwork(network, promo);
|
||||
await db.insert(socialPosts).values({
|
||||
promotionId: promoId,
|
||||
network,
|
||||
status: "posted",
|
||||
externalId: result.externalId,
|
||||
url: result.url,
|
||||
postedById: userId,
|
||||
});
|
||||
await logActivity(
|
||||
`Promotion « ${promo.title} » publiée sur ${SOCIAL_LABELS[network]}`,
|
||||
"#2C6FB3"
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Erreur inconnue";
|
||||
await db.insert(socialPosts).values({
|
||||
promotionId: promoId,
|
||||
network,
|
||||
status: "failed",
|
||||
error: message.slice(0, 2000),
|
||||
postedById: userId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Promotion moderation ----
|
||||
export async function moderatePromo(formData: FormData) {
|
||||
const { role, userId, name } = await requireRole();
|
||||
@@ -232,20 +149,36 @@ export async function moderatePromo(formData: FormData) {
|
||||
// Le modérateur peut ajuster les réseaux demandés par l'adhérent, mais
|
||||
// uniquement ici : après validation le choix est figé.
|
||||
const targets = readShareTargets(formData);
|
||||
// Le champ est pré-rempli avec la date demandée par l'adhérent : vidé, la
|
||||
// promotion part tout de suite ; une échéance déjà passée aussi.
|
||||
const publishAt = parseScheduleInput(String(formData.get("publishAt") ?? ""));
|
||||
const immediate = isDue(publishAt);
|
||||
|
||||
await db
|
||||
.update(promotions)
|
||||
.set({
|
||||
status: "live",
|
||||
status: immediate ? "live" : "scheduled",
|
||||
publishAt: immediate ? null : publishAt,
|
||||
...targets,
|
||||
suspendedBy: null,
|
||||
suspendedById: null,
|
||||
suspendedAt: null,
|
||||
})
|
||||
.where(eq(promotions.id, id));
|
||||
await logActivity(`Promotion « ${promo.title} » validée et mise en ligne`, "#1f8a5b");
|
||||
// Mise en ligne committée avant l'appel réseau : si un réseau répond mal,
|
||||
// la promo est quand même publiée sur le site.
|
||||
await publishPromoShares(id, requestedNetworks(targets), userId);
|
||||
|
||||
if (immediate) {
|
||||
await logActivity(`Promotion « ${promo.title} » validée et mise en ligne`, "#1f8a5b");
|
||||
// Mise en ligne committée avant l'appel réseau : si un réseau répond mal,
|
||||
// la promo est quand même publiée sur le site.
|
||||
await publishPromoShares(id, requestedNetworks(targets), userId);
|
||||
} else {
|
||||
// Rien n'est diffusé maintenant : `releaseDuePromotions()` s'en chargera
|
||||
// à l'échéance, site et réseaux d'un seul coup.
|
||||
await logActivity(
|
||||
`Promotion « ${promo.title} » validée, publication programmée ${formatSchedule(publishAt)}`,
|
||||
"#9a6638"
|
||||
);
|
||||
}
|
||||
} else if (action === "suspend") {
|
||||
// Suspension par l'association : la promotion sort du site public et
|
||||
// l'adhérent ne peut pas la remettre en ligne lui-même.
|
||||
@@ -257,6 +190,15 @@ export async function moderatePromo(formData: FormData) {
|
||||
`Promotion « ${promo.title} » suspendue par <strong>${name}</strong>`,
|
||||
"#d8472b"
|
||||
);
|
||||
} else if (action === "publish-now") {
|
||||
// Raccourci sur une promo programmée : on n'attend pas l'échéance.
|
||||
if (promo.status !== "scheduled") return;
|
||||
await db
|
||||
.update(promotions)
|
||||
.set({ status: "live", publishAt: null })
|
||||
.where(eq(promotions.id, id));
|
||||
await logActivity(`Promotion « ${promo.title} » publiée avant l'heure programmée`, "#1f8a5b");
|
||||
await publishPromoShares(id, requestedNetworks(promo), userId);
|
||||
} else if (action === "restore") {
|
||||
await db
|
||||
.update(promotions)
|
||||
@@ -371,7 +313,15 @@ export async function publishPromo(formData: FormData) {
|
||||
const text = String(formData.get("text") ?? "").slice(0, 240);
|
||||
const category = String(formData.get("category") ?? "");
|
||||
const badge = String(formData.get("badge") ?? "").trim() || null;
|
||||
const startsOn = asOptionalDate(formData, "startsOn");
|
||||
const endsOn = asOptionalDate(formData, "endsOn");
|
||||
if (isRangeInvalid({ startsOn, endsOn })) {
|
||||
throw new Error("La date de fin doit être postérieure à la date de début.");
|
||||
}
|
||||
const imageUrl = asImageDataUri(formData, "imageUrl");
|
||||
// Date souhaitée de mise en ligne. Elle n'engage rien : le modérateur la voit
|
||||
// et peut la garder, la déplacer ou la vider dans le formulaire « Valider ».
|
||||
const publishAt = parseScheduleInput(String(formData.get("publishAt") ?? ""));
|
||||
|
||||
// Réseaux souhaités : rien n'est publié ici, la diffusion attend la validation.
|
||||
await db.insert(promotions).values({
|
||||
@@ -382,6 +332,9 @@ export async function publishPromo(formData: FormData) {
|
||||
imageUrl,
|
||||
memberId: memberId ?? null,
|
||||
status: "pending",
|
||||
startsOn,
|
||||
endsOn,
|
||||
publishAt,
|
||||
...readShareTargets(formData),
|
||||
});
|
||||
|
||||
@@ -406,6 +359,16 @@ export type IssuedCredentials = { email: string; tempPassword: string };
|
||||
|
||||
export type CreatedMemberAccount = IssuedCredentials & { memberId: number };
|
||||
|
||||
/**
|
||||
* Échec **attendu** d'une action : e-mail déjà pris, champ manquant…
|
||||
*
|
||||
* Il est renvoyé et non `throw` : une exception levée dans une server action
|
||||
* est masquée par Next en production (message remplacé par un digest) et fait
|
||||
* tomber la page entière sur « Application error ». Le `throw` reste réservé
|
||||
* aux violations d'accès, qui ne doivent pas s'expliquer à l'utilisateur.
|
||||
*/
|
||||
export type ActionError = { error: string };
|
||||
|
||||
/**
|
||||
* Tags à enregistrer : la saisie si elle existe, sinon les suggestions déduites
|
||||
* du métier, de la commune et de la description (`autoTags`).
|
||||
@@ -428,14 +391,16 @@ async function resolveMemberTags(
|
||||
});
|
||||
}
|
||||
|
||||
export async function addMember(formData: FormData): Promise<CreatedMemberAccount | undefined> {
|
||||
export async function addMember(
|
||||
formData: FormData
|
||||
): Promise<CreatedMemberAccount | ActionError | undefined> {
|
||||
const { role } = await requireRole();
|
||||
if (!can(role, "manageMembers")) throw new Error("Accès refusé");
|
||||
|
||||
const name = String(formData.get("name") ?? "").trim();
|
||||
if (!name) return undefined;
|
||||
const email = String(formData.get("email") ?? "").trim().toLowerCase();
|
||||
if (!email) throw new Error("L'e-mail est requis pour créer le compte de l'adhérent.");
|
||||
if (!email) return { error: "L'e-mail est requis pour créer le compte de l'adhérent." };
|
||||
const categoryId = formData.get("categoryId") ? Number(formData.get("categoryId")) : null;
|
||||
const city = String(formData.get("city") ?? "").trim() || null;
|
||||
const status = (String(formData.get("status") ?? "pending") as "active" | "pending");
|
||||
@@ -443,7 +408,7 @@ export async function addMember(formData: FormData): Promise<CreatedMemberAccoun
|
||||
// Un seul compte par e-mail.
|
||||
const existing = await db.select({ id: users.id }).from(users).where(eq(users.email, email));
|
||||
if (existing.length > 0) {
|
||||
throw new Error("Un compte existe déjà avec cet e-mail.");
|
||||
return { error: `Un compte existe déjà avec l'e-mail ${email}.` };
|
||||
}
|
||||
|
||||
const tags = await resolveMemberTags(null, { categoryId, city, description: null });
|
||||
@@ -560,6 +525,9 @@ export async function updateMember(formData: FormData) {
|
||||
name: String(formData.get("name") ?? "").trim(),
|
||||
email: String(formData.get("email") ?? "").trim(),
|
||||
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,
|
||||
city,
|
||||
address: String(formData.get("address") ?? "").trim() || null,
|
||||
@@ -578,6 +546,7 @@ export async function updateMember(formData: FormData) {
|
||||
|
||||
revalidatePath("/backend/adherents");
|
||||
revalidatePath("/adherents/[id]", "page");
|
||||
revalidatePath("/annuaire");
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
@@ -598,7 +567,9 @@ export async function deleteMember(formData: FormData) {
|
||||
}
|
||||
|
||||
// ---- Admins ----
|
||||
export async function inviteAdmin(formData: FormData): Promise<IssuedCredentials | undefined> {
|
||||
export async function inviteAdmin(
|
||||
formData: FormData
|
||||
): Promise<IssuedCredentials | ActionError | undefined> {
|
||||
const { role } = await requireRole();
|
||||
if (!can(role, "manageAdmins")) throw new Error("Accès refusé");
|
||||
|
||||
@@ -610,7 +581,7 @@ export async function inviteAdmin(formData: FormData): Promise<IssuedCredentials
|
||||
const newRole: AppRole = LABEL_TO_ROLE[roleLabel] ?? "editor";
|
||||
|
||||
const existing = await db.select().from(users).where(eq(users.email, email));
|
||||
if (existing.length > 0) throw new Error("Un compte existe déjà avec cet e-mail.");
|
||||
if (existing.length > 0) return { error: `Un compte existe déjà avec l'e-mail ${email}.` };
|
||||
|
||||
// Mot de passe temporaire montré une seule fois à l'inviteur, à changer à
|
||||
// la première connexion. Auparavant il n'était ni conservé ni affiché :
|
||||
@@ -1128,6 +1099,9 @@ export async function updateOwnProfile(formData: FormData) {
|
||||
.set({
|
||||
name,
|
||||
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,
|
||||
address: String(formData.get("address") ?? "").trim() || null,
|
||||
postalCode: String(formData.get("postalCode") ?? "").trim() || null,
|
||||
@@ -1154,7 +1128,9 @@ export async function updateOwnProfile(formData: FormData) {
|
||||
// Approuve une demande d'adhésion ET crée directement l'adhérent + son compte
|
||||
// de connexion. Renvoie l'id du nouvel adhérent et ses identifiants, à
|
||||
// afficher une seule fois.
|
||||
export async function approveMembershipRequest(formData: FormData): Promise<CreatedMemberAccount | undefined> {
|
||||
export async function approveMembershipRequest(
|
||||
formData: FormData
|
||||
): Promise<CreatedMemberAccount | ActionError | undefined> {
|
||||
const { role } = await requireRole();
|
||||
if (!can(role, "manageMembers")) throw new Error("Accès refusé");
|
||||
|
||||
@@ -1163,18 +1139,29 @@ export async function approveMembershipRequest(formData: FormData): Promise<Crea
|
||||
|
||||
const [req] = await db.select().from(membershipRequests).where(eq(membershipRequests.id, id));
|
||||
if (!req) throw new Error("Demande introuvable.");
|
||||
// Garde serveur : un double clic ou un onglet resté ouvert ne doit jamais
|
||||
// créer un second compte pour la même demande.
|
||||
if (req.status === "approved") throw new Error("Cette demande a déjà été approuvée : l'adhérent existe.");
|
||||
|
||||
// Le bouton disparaît dès qu'une demande est traitée, mais un onglet resté
|
||||
// ouvert peut encore poster : on refuse ici plutôt que de créer un doublon.
|
||||
if (req.status !== "new") {
|
||||
return {
|
||||
error:
|
||||
req.status === "approved"
|
||||
? "Cette demande a déjà été approuvée."
|
||||
: "Cette demande a été rejetée : rouvrez-la avant de l'approuver.",
|
||||
};
|
||||
}
|
||||
|
||||
const email = (req.email ?? "").trim().toLowerCase();
|
||||
if (!email) {
|
||||
throw new Error("Cette demande n'a pas d'e-mail : impossible de créer le compte. Créez l'adhérent manuellement.");
|
||||
return {
|
||||
error:
|
||||
"Cette demande n'a pas d'e-mail : impossible de créer le compte. Créez l'adhérent manuellement.",
|
||||
};
|
||||
}
|
||||
|
||||
const existing = await db.select({ id: users.id }).from(users).where(eq(users.email, email));
|
||||
if (existing.length > 0) {
|
||||
throw new Error("Un compte existe déjà avec cet e-mail.");
|
||||
return { error: `Un compte existe déjà avec l'e-mail ${email}.` };
|
||||
}
|
||||
|
||||
const [newMember] = await db
|
||||
@@ -1209,13 +1196,12 @@ export async function setRequestStatus(formData: FormData) {
|
||||
const id = Number(formData.get("id"));
|
||||
const status = String(formData.get("status") ?? "");
|
||||
if (!id || !["new", "approved", "rejected"].includes(status)) return;
|
||||
// Une demande approuvée a produit un adhérent : son statut ne se réécrit plus.
|
||||
const [current] = await db.select({ status: membershipRequests.status }).from(membershipRequests).where(eq(membershipRequests.id, id));
|
||||
if (!current || current.status === "approved") return;
|
||||
// Une demande approuvée a créé un compte : elle ne se rejoue pas depuis un
|
||||
// onglet resté ouvert sur l'ancien état.
|
||||
await db
|
||||
.update(membershipRequests)
|
||||
.set({ status: status as "new" | "approved" | "rejected" })
|
||||
.where(eq(membershipRequests.id, id));
|
||||
.where(and(eq(membershipRequests.id, id), ne(membershipRequests.status, "approved")));
|
||||
revalidatePath("/backend/demandes");
|
||||
revalidatePath("/backend");
|
||||
}
|
||||
|
||||
@@ -14,6 +14,11 @@ export function AddMemberPanel({
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [created, setCreated] = useState<CreatedMemberAccount | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// React vide un formulaire non contrôlé après chaque action : sur un refus,
|
||||
// on réinjecte la saisie plutôt que de la faire retaper.
|
||||
const [draft, setDraft] = useState<Record<string, string>>({});
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
@@ -38,8 +43,18 @@ export function AddMemberPanel({
|
||||
|
||||
{open && (
|
||||
<form
|
||||
key={attempt}
|
||||
action={async (fd) => {
|
||||
setError(null);
|
||||
const result = await addMember(fd);
|
||||
// Échec de saisie : le formulaire reste ouvert avec la raison.
|
||||
if (result && "error" in result) {
|
||||
setDraft(Object.fromEntries([...fd.entries()].map(([k, v]) => [k, String(v)])));
|
||||
setAttempt((n) => n + 1);
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
setDraft({});
|
||||
setOpen(false);
|
||||
// On reste sur la page : le mot de passe temporaire n'est affiché
|
||||
// qu'ici, une seule fois, et n'est conservé nulle part.
|
||||
@@ -48,18 +63,23 @@ export function AddMemberPanel({
|
||||
}}
|
||||
style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 16, padding: 22, marginTop: 14 }}
|
||||
>
|
||||
{error && (
|
||||
<div role="alert" style={{ background: "#fdecea", border: "1px solid #f1c4bd", borderRadius: 10, padding: "10px 14px", fontSize: 13, color: "#a3372e", marginBottom: 16 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-2" style={{ gap: 16 }}>
|
||||
<div>
|
||||
<label className="field-label">Nom de l'adhérent</label>
|
||||
<input name="name" required placeholder="ex : Au Bon Pain" className="field" />
|
||||
<input name="name" required placeholder="ex : Au Bon Pain" className="field" defaultValue={draft.name ?? ""} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">E-mail (identifiant de connexion)</label>
|
||||
<input name="email" type="email" required placeholder="contact@exemple.fr" className="field" />
|
||||
<input name="email" type="email" required placeholder="contact@exemple.fr" className="field" defaultValue={draft.email ?? ""} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Catégorie</label>
|
||||
<select name="categoryId" className="field" defaultValue="">
|
||||
<select name="categoryId" className="field" defaultValue={draft.categoryId ?? ""}>
|
||||
<option value="">—</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
@@ -70,7 +90,7 @@ export function AddMemberPanel({
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Commune</label>
|
||||
<select name="city" className="field" defaultValue="">
|
||||
<select name="city" className="field" defaultValue={draft.city ?? ""}>
|
||||
<option value="">—</option>
|
||||
{BASSIN_POMPEY_COMMUNES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
@@ -81,7 +101,7 @@ export function AddMemberPanel({
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Statut</label>
|
||||
<select name="status" className="field" defaultValue="pending">
|
||||
<select name="status" className="field" defaultValue={draft.status ?? "pending"}>
|
||||
<option value="pending">En attente</option>
|
||||
<option value="active">Actif</option>
|
||||
</select>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { db } from "@/db";
|
||||
import { categories, members, users } from "@/db/schema";
|
||||
import { can } from "@/lib/rbac";
|
||||
import { ImageField } from "@/components/ImageField";
|
||||
import { MemberContactFields } from "@/components/MemberContactFields";
|
||||
import { HoursEditor } from "@/components/HoursEditor";
|
||||
import { TagsField } from "@/components/TagsField";
|
||||
import { communeOptions } from "@/lib/communes";
|
||||
@@ -132,6 +133,12 @@ export default async function EditMemberPage({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MemberContactFields
|
||||
firstName={member.contactFirstName}
|
||||
lastName={member.contactLastName}
|
||||
phone={member.contactPhone}
|
||||
/>
|
||||
|
||||
<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="logoUrl" label="Logo" defaultValue={member.logoUrl ?? ""} height={130} fit="contain" />
|
||||
|
||||
@@ -25,11 +25,16 @@ export function InviteAdminForm() {
|
||||
setError(null);
|
||||
try {
|
||||
const result = await inviteAdmin(fd);
|
||||
if (result && "error" in result) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
setIssued(result ?? null);
|
||||
router.refresh();
|
||||
} catch {
|
||||
// Next masque le détail des erreurs serveur en production.
|
||||
setError("Impossible de créer ce compte : vérifiez que l'e-mail n'est pas déjà utilisé.");
|
||||
// Filet pour l'imprévu : les échecs de saisie, eux, sont renvoyés
|
||||
// par l'action et affichés tels quels ci-dessus.
|
||||
setError("Impossible de créer ce compte. Réessayez dans un instant.");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
|
||||
@@ -10,10 +10,22 @@ import { approveMembershipRequest, type CreatedMemberAccount } from "../actions"
|
||||
* Approuve une demande et affiche une seule fois les identifiants du compte
|
||||
* créé. On reste sur la page : une redirection ferait perdre le mot de passe,
|
||||
* qui n'est conservé nulle part.
|
||||
*
|
||||
* Le bouton ne s'affiche que pour une demande encore à traiter, mais c'est ce
|
||||
* composant — et non la page — qui en décide : après l'approbation,
|
||||
* `router.refresh()` renvoie la demande en « approuvée », et un masquage côté
|
||||
* serveur démonterait le bloc d'identifiants avant qu'il ait été lu.
|
||||
*/
|
||||
export function ApproveRequestForm({ requestId }: { requestId: number }) {
|
||||
export function ApproveRequestForm({
|
||||
requestId,
|
||||
status,
|
||||
}: {
|
||||
requestId: number;
|
||||
status: string;
|
||||
}) {
|
||||
const [created, setCreated] = useState<CreatedMemberAccount | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
if (created) {
|
||||
@@ -27,13 +39,20 @@ export function ApproveRequestForm({ requestId }: { requestId: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
if (status !== "new") return null;
|
||||
|
||||
return (
|
||||
<form
|
||||
action={async (fd) => {
|
||||
setPending(true);
|
||||
try {
|
||||
const result = await approveMembershipRequest(fd);
|
||||
if (result) {
|
||||
// Échec de saisie (e-mail déjà pris, demande sans e-mail) : la
|
||||
// raison s'affiche à côté du bouton, la page reste en place.
|
||||
if (result && "error" in result) {
|
||||
setError(result.error);
|
||||
} else if (result) {
|
||||
setError(null);
|
||||
setCreated(result);
|
||||
router.refresh();
|
||||
}
|
||||
@@ -43,6 +62,11 @@ export function ApproveRequestForm({ requestId }: { requestId: number }) {
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="id" value={requestId} />
|
||||
{error && (
|
||||
<div role="alert" style={{ background: "#fdecea", border: "1px solid #f1c4bd", borderRadius: 9, padding: "8px 11px", fontSize: 12.5, color: "#a3372e", marginBottom: 8, maxWidth: 280, lineHeight: 1.5 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { desc, inArray } from "drizzle-orm";
|
||||
import { desc } from "drizzle-orm";
|
||||
import { getSession } from "@/lib/session";
|
||||
import { db } from "@/db";
|
||||
import { contactMessages, members, membershipRequests } from "@/db/schema";
|
||||
import { contactMessages, membershipRequests } from "@/db/schema";
|
||||
import { can } from "@/lib/rbac";
|
||||
import { setContactStatus, setRequestStatus } from "../actions";
|
||||
import { ApproveRequestForm } from "./ApproveRequestForm";
|
||||
@@ -53,21 +52,6 @@ export default async function DemandesPage() {
|
||||
db.select().from(contactMessages).orderBy(desc(contactMessages.createdAt)),
|
||||
]);
|
||||
|
||||
// Demandes approuvées : on retrouve la fiche créée par son e-mail, pour
|
||||
// proposer un lien vers l'adhérent plutôt que des boutons déjà consommés.
|
||||
const approvedEmails = requests
|
||||
.filter((r) => r.status === "approved" && r.email)
|
||||
.map((r) => (r.email ?? "").trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
const memberByEmail = new Map<string, number>();
|
||||
if (approvedEmails.length > 0) {
|
||||
const rows = await db
|
||||
.select({ id: members.id, email: members.email })
|
||||
.from(members)
|
||||
.where(inArray(members.email, approvedEmails));
|
||||
for (const row of rows) memberByEmail.set(row.email.trim().toLowerCase(), row.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 26 }}>
|
||||
{/* ---- Demandes d'adhésion ---- */}
|
||||
@@ -88,31 +72,24 @@ export default async function DemandesPage() {
|
||||
</div>
|
||||
</div>
|
||||
<StatusPill map={REQ_STATUS} status={r.status} />
|
||||
{/* Les actions dépendent du statut : une demande approuvée a déjà
|
||||
produit son adhérent, on ne propose plus que d'ouvrir sa fiche. */}
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||
{r.status === "approved" ? (
|
||||
(() => {
|
||||
const memberId = memberByEmail.get((r.email ?? "").trim().toLowerCase());
|
||||
return memberId ? (
|
||||
<Link href={`/backend/adherents/${memberId}`} style={{ color: "#2C6FB3", fontWeight: 700, fontSize: 12.5, textDecoration: "none", whiteSpace: "nowrap" }}>
|
||||
Ouvrir la fiche adhérent →
|
||||
</Link>
|
||||
) : (
|
||||
<span style={{ fontSize: 12.5, color: "#a99c82" }}>Adhérent créé</span>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<>
|
||||
<ApproveRequestForm requestId={r.id} />
|
||||
{r.status === "new" && (
|
||||
<form action={setRequestStatus}>
|
||||
<input type="hidden" name="id" value={r.id} />
|
||||
<input type="hidden" name="status" value="rejected" />
|
||||
<ActionBtn color="#d8472b">Rejeter</ActionBtn>
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
{/* Une demande traitée ne garde que la sortie qui a du sens :
|
||||
« Rejeter » disparaît une fois l'adhérent créé, et un rejet
|
||||
reste réversible tant qu'aucun compte n'existe. */}
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<ApproveRequestForm requestId={r.id} status={r.status} />
|
||||
{r.status === "new" && (
|
||||
<form action={setRequestStatus}>
|
||||
<input type="hidden" name="id" value={r.id} />
|
||||
<input type="hidden" name="status" value="rejected" />
|
||||
<ActionBtn color="#d8472b">Rejeter</ActionBtn>
|
||||
</form>
|
||||
)}
|
||||
{r.status === "rejected" && (
|
||||
<form action={setRequestStatus}>
|
||||
<input type="hidden" name="id" value={r.id} />
|
||||
<input type="hidden" name="status" value="new" />
|
||||
<ActionBtn color="#6c6150">Rouvrir</ActionBtn>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -144,17 +121,30 @@ export default async function DemandesPage() {
|
||||
</div>
|
||||
</div>
|
||||
<StatusPill map={MSG_STATUS} status={m.status} />
|
||||
{/* Chaque bouton fait avancer le message d'un cran : on ne
|
||||
propose plus « Marquer lu » sur un message déjà lu ni
|
||||
« Archiver » sur un message archivé. */}
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<form action={setContactStatus}>
|
||||
<input type="hidden" name="id" value={m.id} />
|
||||
<input type="hidden" name="status" value="read" />
|
||||
<ActionBtn color="#2C6FB3">Marquer lu</ActionBtn>
|
||||
</form>
|
||||
<form action={setContactStatus}>
|
||||
<input type="hidden" name="id" value={m.id} />
|
||||
<input type="hidden" name="status" value="archived" />
|
||||
<ActionBtn color="#a99c82">Archiver</ActionBtn>
|
||||
</form>
|
||||
{m.status === "new" && (
|
||||
<form action={setContactStatus}>
|
||||
<input type="hidden" name="id" value={m.id} />
|
||||
<input type="hidden" name="status" value="read" />
|
||||
<ActionBtn color="#2C6FB3">Marquer lu</ActionBtn>
|
||||
</form>
|
||||
)}
|
||||
{m.status !== "archived" ? (
|
||||
<form action={setContactStatus}>
|
||||
<input type="hidden" name="id" value={m.id} />
|
||||
<input type="hidden" name="status" value="archived" />
|
||||
<ActionBtn color="#a99c82">Archiver</ActionBtn>
|
||||
</form>
|
||||
) : (
|
||||
<form action={setContactStatus}>
|
||||
<input type="hidden" name="id" value={m.id} />
|
||||
<input type="hidden" name="status" value="read" />
|
||||
<ActionBtn color="#6c6150">Désarchiver</ActionBtn>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p style={{ margin: "10px 0 0", fontSize: 13.5, color: "#5a5040", whiteSpace: "pre-wrap" }}>{m.message}</p>
|
||||
|
||||
@@ -4,9 +4,13 @@ import { useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { publishPromo } from "../actions";
|
||||
import { PromoImage } from "@/components/PromoImage";
|
||||
import { MemberAvatar } from "@/components/MemberAvatar";
|
||||
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
|
||||
import type { SocialNetwork } from "@/lib/social";
|
||||
import type { PromoCategoryGroup } from "@/lib/promo-categories";
|
||||
import { formatValidity, isRangeInvalid } from "@/lib/promo-validity";
|
||||
import { formatSchedule, parseScheduleInput } from "@/lib/promo-schedule";
|
||||
import { imageNotes, imageSummary, type ImageInfo } from "@/lib/image-info";
|
||||
|
||||
const STRIPE_WARM =
|
||||
"repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)";
|
||||
@@ -16,13 +20,83 @@ const NETWORK_LABELS: Record<SocialNetwork, string> = {
|
||||
linkedin: "LinkedIn",
|
||||
};
|
||||
|
||||
/** Une ligne du récapitulatif de dates, à droite du formulaire. */
|
||||
function SummaryRow({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
last = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
tone: "set" | "muted" | "error";
|
||||
last?: boolean;
|
||||
}) {
|
||||
const color = tone === "error" ? "#d8472b" : tone === "set" ? "#3c3322" : "#a99c82";
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
justifyContent: "space-between",
|
||||
gap: 14,
|
||||
padding: "12px 0",
|
||||
borderBottom: last ? "none" : "1px solid #f0e8d6",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 12.5, color: "#9a8d72", fontWeight: 700, whiteSpace: "nowrap" }}>{label}</span>
|
||||
<span style={{ fontSize: 13, color, fontWeight: tone === "muted" ? 500 : 700, textAlign: "right" }}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Taille, format et poids de l'image déposée, avec ce que les réseaux en
|
||||
* feront. Purement informatif : aucune remarque n'empêche l'envoi.
|
||||
*/
|
||||
function ImageReport({ info }: { info: ImageInfo }) {
|
||||
const notes = imageNotes(info);
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginTop: -8,
|
||||
marginBottom: 18,
|
||||
background: "#faf7ef",
|
||||
border: "1px solid #f0e8d6",
|
||||
borderRadius: 12,
|
||||
padding: "10px 12px",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 12.5, fontWeight: 700, color: "#6c6150" }}>{imageSummary(info)}</div>
|
||||
{notes.map((note, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
fontSize: 11.5,
|
||||
lineHeight: 1.45,
|
||||
marginTop: 5,
|
||||
color: note.tone === "warn" ? "#a8503c" : "#1f8a5b",
|
||||
}}
|
||||
>
|
||||
{note.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MemberSpaceForm({
|
||||
memberName,
|
||||
memberLogoUrl,
|
||||
categoryGroups,
|
||||
defaultCategory,
|
||||
networks = [],
|
||||
}: {
|
||||
memberName: string;
|
||||
/** Logo de l'adhérent, affiché dans l'angle de l'aperçu comme sur le site. */
|
||||
memberLogoUrl?: string | null;
|
||||
/** Types de produits / services, groupés pour le `<select>`. */
|
||||
categoryGroups: PromoCategoryGroup[];
|
||||
/** Pré-sélection déduite du métier de l'adhérent. */
|
||||
@@ -36,7 +110,14 @@ export function MemberSpaceForm({
|
||||
const [text, setText] = useState("");
|
||||
const [cat, setCat] = useState(initialCategory);
|
||||
const [badge, setBadge] = useState("");
|
||||
const [startsOn, setStartsOn] = useState("");
|
||||
const [endsOn, setEndsOn] = useState("");
|
||||
const [publishAt, setPublishAt] = useState("");
|
||||
const [imgData, setImgData] = useState("");
|
||||
// Dimensions et poids du fichier déposé : rien n'est retouché à l'envoi, donc
|
||||
// c'est ici — dernier moment où l'adhérent peut changer de visuel — qu'on lui
|
||||
// dit ce que les réseaux feront d'un format inattendu.
|
||||
const [imgInfo, setImgInfo] = useState<ImageInfo | null>(null);
|
||||
const [shares, setShares] = useState<SocialNetwork[]>([]);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [pending, setPending] = useState(false);
|
||||
@@ -50,10 +131,20 @@ export function MemberSpaceForm({
|
||||
function onFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const f = e.target.files?.[0];
|
||||
if (!f) return;
|
||||
const bytes = f.size;
|
||||
setImgInfo(null);
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
setImgData(String(reader.result));
|
||||
const dataUrl = String(reader.result);
|
||||
setImgData(dataUrl);
|
||||
setSubmitted(false);
|
||||
// Les dimensions ne sont lisibles qu'une fois l'image décodée par le
|
||||
// navigateur. Un échec de décodage laisse simplement le résumé vide :
|
||||
// l'information est un confort, elle ne conditionne pas le dépôt.
|
||||
const probe = new Image();
|
||||
probe.onload = () => setImgInfo({ width: probe.naturalWidth, height: probe.naturalHeight, bytes });
|
||||
probe.onerror = () => setImgInfo(null);
|
||||
probe.src = dataUrl;
|
||||
};
|
||||
reader.readAsDataURL(f);
|
||||
}
|
||||
@@ -62,7 +153,11 @@ export function MemberSpaceForm({
|
||||
setTitle("");
|
||||
setText("");
|
||||
setBadge("");
|
||||
setStartsOn("");
|
||||
setEndsOn("");
|
||||
setPublishAt("");
|
||||
setImgData("");
|
||||
setImgInfo(null);
|
||||
setShares([]);
|
||||
setSubmitted(false);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
@@ -76,6 +171,9 @@ export function MemberSpaceForm({
|
||||
fd.set("text", text);
|
||||
fd.set("category", cat);
|
||||
fd.set("badge", badge);
|
||||
fd.set("startsOn", startsOn);
|
||||
fd.set("endsOn", endsOn);
|
||||
fd.set("publishAt", publishAt);
|
||||
fd.set("imageUrl", imgData);
|
||||
if (shares.includes("facebook")) fd.set("shareFacebook", "on");
|
||||
if (shares.includes("linkedin")) fd.set("shareLinkedin", "on");
|
||||
@@ -89,6 +187,17 @@ export function MemberSpaceForm({
|
||||
|
||||
const hasImg = !!imgData;
|
||||
const hasBadge = !!badge.trim();
|
||||
const validity = formatValidity({ startsOn, endsOn });
|
||||
// Bornes incohérentes : on le dit avant l'envoi plutôt qu'après un refus.
|
||||
const badRange = isRangeInvalid({ startsOn, endsOn });
|
||||
// Aperçu de la programmation. Le champ donne une heure locale : on la relit
|
||||
// avec le même formateur que le backoffice pour éviter toute surprise.
|
||||
let scheduleHint: string | null = null;
|
||||
try {
|
||||
scheduleHint = formatSchedule(parseScheduleInput(publishAt));
|
||||
} catch {
|
||||
scheduleHint = null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-2" style={{ alignItems: "start" }}>
|
||||
@@ -136,6 +245,8 @@ export function MemberSpaceForm({
|
||||
<input ref={fileRef} type="file" accept="image/*" onChange={onFile} style={{ display: "none" }} />
|
||||
</label>
|
||||
|
||||
{imgInfo && <ImageReport info={imgInfo} />}
|
||||
|
||||
<label className="field-label">Titre de la promotion</label>
|
||||
<input
|
||||
value={title}
|
||||
@@ -169,6 +280,56 @@ export function MemberSpaceForm({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label className="field-label">Durée de validité · facultatif</label>
|
||||
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
|
||||
<div style={{ flex: "1 1 150px" }}>
|
||||
<input
|
||||
type="date"
|
||||
value={startsOn}
|
||||
onChange={(e) => { setStartsOn(e.target.value); setSubmitted(false); }}
|
||||
className="field"
|
||||
aria-label="Début de la promotion"
|
||||
/>
|
||||
<div style={{ fontSize: 11.5, color: "#a99c82", marginTop: 4 }}>Début</div>
|
||||
</div>
|
||||
<div style={{ flex: "1 1 150px" }}>
|
||||
<input
|
||||
type="date"
|
||||
value={endsOn}
|
||||
min={startsOn || undefined}
|
||||
onChange={(e) => { setEndsOn(e.target.value); setSubmitted(false); }}
|
||||
className="field"
|
||||
aria-label="Fin de la promotion"
|
||||
/>
|
||||
<div style={{ fontSize: 11.5, color: "#a99c82", marginTop: 4 }}>Fin</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: badRange ? "#d8472b" : "#8c8068", marginTop: 8, lineHeight: 1.5 }}>
|
||||
{badRange
|
||||
? "La date de fin est antérieure à la date de début."
|
||||
: validity
|
||||
? `Affiché sur le site et dans la publication : « ${validity} ». L'offre n'apparaît que pendant cette période.`
|
||||
: "Laissez vide si votre offre n'a pas de date limite : elle restera affichée sans échéance."}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label className="field-label">Programmer la publication · facultatif</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={publishAt}
|
||||
onChange={(e) => { setPublishAt(e.target.value); setSubmitted(false); }}
|
||||
className="field"
|
||||
aria-label="Date et heure de publication souhaitées"
|
||||
/>
|
||||
<div style={{ fontSize: 12, color: "#8c8068", marginTop: 8, lineHeight: 1.5 }}>
|
||||
{scheduleHint
|
||||
? `Mise en ligne demandée ${scheduleHint} — site et réseaux en même temps. L'association reste libre de la déplacer.`
|
||||
: "Laissez vide pour une mise en ligne dès la validation par l'association."}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="field-label">Texte de la promotion</label>
|
||||
<textarea
|
||||
value={text}
|
||||
@@ -230,7 +391,7 @@ export function MemberSpaceForm({
|
||||
<div style={{ display: "flex", gap: 10 }}>
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={pending}
|
||||
disabled={pending || badRange}
|
||||
className="font-display"
|
||||
style={{ flex: 1, border: "none", background: "#9a6638", color: "#fff", fontWeight: 700, fontSize: 15, padding: 14, borderRadius: 11, cursor: pending ? "default" : "pointer", opacity: pending ? 0.7 : 1 }}
|
||||
>
|
||||
@@ -255,10 +416,31 @@ export function MemberSpaceForm({
|
||||
|
||||
{/* LIVE PREVIEW */}
|
||||
<div>
|
||||
{/* Récapitulatif des dates : quand l'offre paraît, combien de temps
|
||||
elle reste. Deux informations que la carte ne montre pas. */}
|
||||
<div style={{ fontSize: 12.5, letterSpacing: "0.08em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 700, marginBottom: 12 }}>
|
||||
Dates de votre offre
|
||||
</div>
|
||||
<div style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 14, padding: "4px 16px", marginBottom: 26 }}>
|
||||
<SummaryRow
|
||||
label="Durée de validité"
|
||||
value={badRange ? "Dates incohérentes" : validity ?? "Sans date limite"}
|
||||
tone={badRange ? "error" : validity ? "set" : "muted"}
|
||||
/>
|
||||
<SummaryRow
|
||||
label="Publication"
|
||||
value={scheduleHint ? `Programmée ${scheduleHint}` : "Dès la validation par l'association"}
|
||||
tone={scheduleHint ? "set" : "muted"}
|
||||
last
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 12.5, letterSpacing: "0.08em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 700, marginBottom: 12 }}>
|
||||
Aperçu en direct
|
||||
</div>
|
||||
<article style={{ background: "#fff", border: "1px solid #ece3d0", borderRadius: 18, overflow: "hidden", boxShadow: "0 18px 40px -26px rgba(40,30,15,0.5)" }}>
|
||||
{/* Largeur bornée : la carte doit ressembler à celle du site, pas
|
||||
occuper toute la colonne (le visuel carré la rendait démesurée). */}
|
||||
<article style={{ maxWidth: 320, background: "#fff", border: "1px solid #ece3d0", borderRadius: 18, overflow: "hidden", boxShadow: "0 18px 40px -26px rgba(40,30,15,0.5)" }}>
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
@@ -293,8 +475,14 @@ export function MemberSpaceForm({
|
||||
{text.trim() ||
|
||||
"Le texte de votre offre apparaîtra ici au fur et à mesure que vous le rédigez."}
|
||||
</p>
|
||||
{validity && (
|
||||
<div style={{ fontSize: 12, color: "#a99c82", marginBottom: 12 }}>{validity}</div>
|
||||
)}
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", borderTop: "1px solid #f0e8d6", paddingTop: 12 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: "#3c3322" }}>{memberName}</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 9, minWidth: 0 }}>
|
||||
<MemberAvatar name={memberName} logoUrl={memberLogoUrl} size={28} />
|
||||
<span style={{ fontSize: 13, fontWeight: 700, color: "#3c3322" }}>{memberName}</span>
|
||||
</div>
|
||||
<span style={{ color: "#2C6FB3", fontWeight: 700, fontSize: 13 }}>Voir l'offre →</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ImageField } from "@/components/ImageField";
|
||||
import { ImageConsentForm } from "@/components/ImageConsentForm";
|
||||
import { HoursEditor } from "@/components/HoursEditor";
|
||||
import { TagsField } from "@/components/TagsField";
|
||||
import { MemberContactFields } from "@/components/MemberContactFields";
|
||||
import { communeOptions } from "@/lib/communes";
|
||||
import { saveImageConsent, updateOwnProfile } from "../actions";
|
||||
import { EspaceHeader } from "./EspaceHeader";
|
||||
@@ -167,6 +168,12 @@ export default async function EspacePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MemberContactFields
|
||||
firstName={profile.contactFirstName}
|
||||
lastName={profile.contactLastName}
|
||||
phone={profile.contactPhone}
|
||||
/>
|
||||
|
||||
<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="logoUrl" label="Logo" defaultValue={profile.logoUrl ?? ""} height={130} fit="contain" />
|
||||
|
||||
@@ -7,6 +7,8 @@ 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 { formatValidityShort, visibilityNote } from "@/lib/promo-validity";
|
||||
import { scheduleLabel } from "@/lib/promo-schedule";
|
||||
import { setOwnPromoShareTargets, setOwnPromoSuspension } from "../../actions";
|
||||
import { EspaceHeader } from "../EspaceHeader";
|
||||
import { MemberSpaceForm } from "../MemberSpaceForm";
|
||||
@@ -19,6 +21,7 @@ const PROMO_STATUS: Record<string, { label: string; bg: string; color: string }>
|
||||
rejected: { label: "Refusée", bg: "#fbe9e6", color: "#d8472b" },
|
||||
suspended: { label: "Suspendue", bg: "#fbe9e6", color: "#d8472b" },
|
||||
expired: { label: "Expirée", bg: "#f1efe7", color: "#a99c82" },
|
||||
scheduled: { label: "Programmée", bg: "#eaf0f6", color: "#2C6FB3" },
|
||||
};
|
||||
|
||||
type MyPromo = {
|
||||
@@ -28,6 +31,9 @@ type MyPromo = {
|
||||
category: string | null;
|
||||
badge: string | null;
|
||||
validUntil: string | null;
|
||||
publishAt: Date | null;
|
||||
startsOn: string | null;
|
||||
endsOn: string | null;
|
||||
createdAt: Date;
|
||||
imageUrl: string | null;
|
||||
suspendedBy: "member" | "staff" | null;
|
||||
@@ -46,6 +52,7 @@ export default async function EspacePromotionsPage() {
|
||||
const session = await getSession();
|
||||
const memberId = session?.user.memberId ?? null;
|
||||
let memberName = session?.user.name ?? "Adhérent";
|
||||
let memberLogoUrl: string | null = null;
|
||||
let subtitle = "Espace adhérent · Promotions";
|
||||
let publicPath: string | null = null;
|
||||
let categorySlug: string | null = null;
|
||||
@@ -59,6 +66,7 @@ export default async function EspacePromotionsPage() {
|
||||
name: members.name,
|
||||
city: members.city,
|
||||
status: members.status,
|
||||
logoUrl: members.logoUrl,
|
||||
categorySlug: categories.slug,
|
||||
categoryLabel: categories.label,
|
||||
})
|
||||
@@ -67,6 +75,7 @@ export default async function EspacePromotionsPage() {
|
||||
.where(eq(members.id, memberId));
|
||||
if (m) {
|
||||
memberName = m.name;
|
||||
memberLogoUrl = m.logoUrl;
|
||||
categorySlug = m.categorySlug;
|
||||
subtitle = ["Espace adhérent", m.categoryLabel, m.city].filter(Boolean).join(" · ");
|
||||
publicPath = m.status === "active" ? memberPath(m) : null;
|
||||
@@ -79,6 +88,9 @@ export default async function EspacePromotionsPage() {
|
||||
category: promotions.category,
|
||||
badge: promotions.badge,
|
||||
validUntil: promotions.validUntil,
|
||||
publishAt: promotions.publishAt,
|
||||
startsOn: promotions.startsOn,
|
||||
endsOn: promotions.endsOn,
|
||||
createdAt: promotions.createdAt,
|
||||
imageUrl: promotions.imageUrl,
|
||||
suspendedBy: promotions.suspendedBy,
|
||||
@@ -111,7 +123,12 @@ export default async function EspacePromotionsPage() {
|
||||
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");
|
||||
// Validées mais pas encore à leur date : elles n'ont rien à faire dans
|
||||
// l'historique des offres terminées.
|
||||
const scheduled = myPromos.filter((p) => p.status === "scheduled");
|
||||
const others = myPromos.filter(
|
||||
(p) => p.status !== "live" && p.status !== "pending" && p.status !== "scheduled"
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -146,11 +163,22 @@ export default async function EspacePromotionsPage() {
|
||||
|
||||
<MemberSpaceForm
|
||||
memberName={memberName}
|
||||
memberLogoUrl={memberLogoUrl}
|
||||
categoryGroups={PROMO_CATEGORY_GROUPS}
|
||||
defaultCategory={defaultPromoCategory(categorySlug)}
|
||||
networks={networks}
|
||||
/>
|
||||
|
||||
{scheduled.length > 0 && (
|
||||
<PromoSection
|
||||
title="Publications programmées"
|
||||
intro="Validées par l'association : elles apparaîtront sur le site et sur les réseaux à la date prévue."
|
||||
promos={scheduled}
|
||||
networks={networks}
|
||||
lastShare={lastShare}
|
||||
/>
|
||||
)}
|
||||
|
||||
{pending.length > 0 && (
|
||||
<PromoSection
|
||||
title="En attente de validation"
|
||||
@@ -173,7 +201,7 @@ export default async function EspacePromotionsPage() {
|
||||
|
||||
{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>
|
||||
Les offres en ligne apparaissent sur <Link href="/promotions" style={{ color: "#9a6638", fontWeight: 700 }}>la page des promotions</Link>
|
||||
{publicPath ? (
|
||||
<>
|
||||
{" "}et sur <Link href={publicPath} style={{ color: "#9a6638", fontWeight: 700 }}>votre fiche</Link>
|
||||
@@ -237,7 +265,7 @@ function PromoSection({
|
||||
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(" · ");
|
||||
const meta = [mp.category, mp.badge, formatValidityShort(mp)].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" }}>
|
||||
@@ -281,6 +309,16 @@ function PromoRow({ promo: mp, networks, lastShare }: { promo: MyPromo; networks
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
{mp.status === "live" && visibilityNote(mp) && (
|
||||
<div style={{ marginTop: 10, background: "#faf7ef", border: "1px solid #f0e8d6", color: "#9a8d72", borderRadius: 9, padding: "9px 11px", fontSize: 12.5, lineHeight: 1.5 }}>
|
||||
{visibilityNote(mp)}
|
||||
</div>
|
||||
)}
|
||||
{mp.status === "scheduled" && (
|
||||
<div style={{ marginTop: 10, background: "#eaf0f6", border: "1px solid #d3e0ee", color: "#2C6FB3", borderRadius: 9, padding: "9px 11px", fontSize: 12.5, lineHeight: 1.5, fontWeight: 600 }}>
|
||||
Validée par l'association. {scheduleLabel(mp.publishAt)} — site et réseaux en même temps.
|
||||
</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.
|
||||
|
||||
+57
-15
@@ -3,10 +3,17 @@ import { redirect } from "next/navigation";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { getSession } from "@/lib/session";
|
||||
import { db } from "@/db";
|
||||
import { activityLog, contactMessages, members, membershipRequests, promotions } from "@/db/schema";
|
||||
import { activityLog, contactMessages, members, membershipRequests, promotions, socialPosts } from "@/db/schema";
|
||||
import { activityNodes } from "@/lib/activity";
|
||||
import { can, isStaff } from "@/lib/rbac";
|
||||
import { expiryStatus, getSocialAccounts, SOCIAL_LABELS } from "@/lib/social-accounts";
|
||||
import { countVisiblePromotions } from "@/lib/queries";
|
||||
import {
|
||||
expiryStatus,
|
||||
getSocialAccounts,
|
||||
SOCIAL_LABELS,
|
||||
tokenHealthCached,
|
||||
type SocialNetwork,
|
||||
} from "@/lib/social-accounts";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -42,7 +49,7 @@ export default async function DashboardPage() {
|
||||
|
||||
const [activeMembers, livePromos, pendingPromos, requests, newMessages, activity] = await Promise.all([
|
||||
db.select({ id: members.id }).from(members).where(eq(members.status, "active")),
|
||||
db.select({ id: promotions.id }).from(promotions).where(eq(promotions.status, "live")),
|
||||
countVisiblePromotions(),
|
||||
db.select({ id: promotions.id }).from(promotions).where(eq(promotions.status, "pending")),
|
||||
db.select({ id: membershipRequests.id }).from(membershipRequests).where(eq(membershipRequests.status, "new")),
|
||||
db.select({ id: contactMessages.id }).from(contactMessages).where(eq(contactMessages.status, "new")),
|
||||
@@ -51,14 +58,47 @@ export default async function DashboardPage() {
|
||||
|
||||
const pendingCount = pendingPromos.length;
|
||||
|
||||
// Jetons réseaux à renouveler : sans alerte, on ne découvre l'expiration
|
||||
// qu'au moment où une publication échoue.
|
||||
const expiringNetworks = can(session?.user.role, "manageSettings")
|
||||
? (await getSocialAccounts())
|
||||
.filter((a) => a.accessToken && a.targetId)
|
||||
.map((a) => ({ network: a.network, status: expiryStatus(a.expiresAt) }))
|
||||
.filter((a) => a.status === "soon" || a.status === "expired")
|
||||
: [];
|
||||
// Jetons réseaux à renouveler. Deux signaux, car une date d'expiration ne
|
||||
// suffit pas : un jeton de page Facebook n'expire jamais mais peut être
|
||||
// révoqué, et cela ne se voit qu'à la première publication en échec.
|
||||
const expiringNetworks: { network: SocialNetwork; status: string }[] = [];
|
||||
if (can(session?.user.role, "manageSettings")) {
|
||||
const connected = (await getSocialAccounts()).filter((a) => a.accessToken && a.targetId);
|
||||
|
||||
// Dernière tentative de publication par réseau : une erreur d'autorisation
|
||||
// signale un jeton mort, sans avoir à interroger la plateforme ici.
|
||||
const attempts = connected.length
|
||||
? await db
|
||||
.select({
|
||||
network: socialPosts.network,
|
||||
status: socialPosts.status,
|
||||
error: socialPosts.error,
|
||||
})
|
||||
.from(socialPosts)
|
||||
.orderBy(desc(socialPosts.createdAt))
|
||||
.limit(40)
|
||||
: [];
|
||||
const lastAttempt = new Map<string, (typeof attempts)[number]>();
|
||||
for (const a of attempts) if (!lastAttempt.has(a.network)) lastAttempt.set(a.network, a);
|
||||
|
||||
for (const account of connected) {
|
||||
const last = lastAttempt.get(account.network);
|
||||
const authFailure =
|
||||
last?.status === "failed" &&
|
||||
/oauth|token|401|expired|invalid|permission/i.test(last.error ?? "");
|
||||
// Contrôle direct, mis en cache 6 h : c'est ce qui permet de prévenir
|
||||
// avant la première publication en échec, sans appeler la plateforme à
|
||||
// chaque affichage du tableau de bord.
|
||||
const probe = authFailure ? null : await tokenHealthCached(account.network);
|
||||
const expiry = expiryStatus(account.expiresAt);
|
||||
|
||||
if (authFailure || probe?.ok === false) {
|
||||
expiringNetworks.push({ network: account.network, status: "broken" });
|
||||
} else if (expiry === "soon" || expiry === "expired") {
|
||||
expiringNetworks.push({ network: account.network, status: expiry });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function timeAgo(date: Date) {
|
||||
const diff = Date.now() - new Date(date).getTime();
|
||||
@@ -90,9 +130,11 @@ export default async function DashboardPage() {
|
||||
{expiringNetworks.map((n) => (
|
||||
<div key={n.network}>
|
||||
<strong>{SOCIAL_LABELS[n.network]}</strong>{" "}
|
||||
{n.status === "expired"
|
||||
? "— le jeton a expiré, les publications échoueront."
|
||||
: "— le jeton expire dans moins de 7 jours."}{" "}
|
||||
{n.status === "broken"
|
||||
? "— la dernière publication a été refusée : le jeton ne fonctionne plus."
|
||||
: n.status === "expired"
|
||||
? "— le jeton a expiré, les publications échoueront."
|
||||
: "— le jeton expire dans moins de 7 jours."}{" "}
|
||||
Reconnecter le compte →
|
||||
</div>
|
||||
))}
|
||||
@@ -101,7 +143,7 @@ export default async function DashboardPage() {
|
||||
|
||||
<div className="grid grid-4" style={{ marginBottom: 26 }}>
|
||||
<StatCard label="Adhérents actifs" value={activeMembers.length} hint="sur le réseau" valueColor="#13324F" hintColor="#1f8a5b" />
|
||||
<StatCard label="Promotions en ligne" value={livePromos.length} hint="visibles sur le site" />
|
||||
<StatCard label="Promotions en ligne" value={livePromos} hint="visibles sur le site" />
|
||||
<StatCard label="À modérer" value={pendingCount} hint="en attente de validation" valueColor="#9a6638" hintColor="#9a6638" />
|
||||
<Link href="/backend/demandes" style={{ textDecoration: "none" }}>
|
||||
<StatCard
|
||||
|
||||
@@ -7,7 +7,10 @@ import { can } from "@/lib/rbac";
|
||||
import { configuredNetworks, SOCIAL_LABELS } from "@/lib/social";
|
||||
import { moderatePromo, retryPromoShare } from "../actions";
|
||||
import { PromoImage } from "@/components/PromoImage";
|
||||
import { MemberAvatar } from "@/components/MemberAvatar";
|
||||
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
|
||||
import { formatValidity, visibilityNote } from "@/lib/promo-validity";
|
||||
import { formatSchedule, scheduleLabel, toScheduleInput } from "@/lib/promo-schedule";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -20,6 +23,7 @@ const STATUS_CHIP: Record<string, { label: string; bg: string; color: string }>
|
||||
pending: { label: "En attente", bg: "#fbeede", color: "#9a6638" },
|
||||
live: { label: "En ligne", bg: "#e6f4ec", color: "#1f8a5b" },
|
||||
suspended: { label: "Suspendue", bg: "#fbe9e6", color: "#d8472b" },
|
||||
scheduled: { label: "Programmée", bg: "#eaf0f6", color: "#2C6FB3" },
|
||||
};
|
||||
|
||||
function fmtDate(d: Date) {
|
||||
@@ -41,23 +45,30 @@ export default async function PromotionsPage() {
|
||||
text: promotions.text,
|
||||
category: promotions.category,
|
||||
status: promotions.status,
|
||||
publishAt: promotions.publishAt,
|
||||
validUntil: promotions.validUntil,
|
||||
startsOn: promotions.startsOn,
|
||||
endsOn: promotions.endsOn,
|
||||
imageUrl: promotions.imageUrl,
|
||||
suspendedBy: promotions.suspendedBy,
|
||||
suspendedAt: promotions.suspendedAt,
|
||||
shareFacebook: promotions.shareFacebook,
|
||||
shareLinkedin: promotions.shareLinkedin,
|
||||
memberName: members.name,
|
||||
memberLogoUrl: members.logoUrl,
|
||||
})
|
||||
.from(promotions)
|
||||
.leftJoin(members, eq(promotions.memberId, members.id))
|
||||
.where(inArray(promotions.status, ["pending", "live", "suspended"]))
|
||||
.where(inArray(promotions.status, ["pending", "live", "suspended", "scheduled"]))
|
||||
.orderBy(desc(promotions.createdAt));
|
||||
|
||||
const pending = rows.filter((r) => r.status === "pending");
|
||||
const live = rows.filter((r) => r.status === "live");
|
||||
const suspended = rows.filter((r) => r.status === "suspended");
|
||||
// En attente d'abord, puis en ligne, puis suspendues.
|
||||
const ordered = [...pending, ...live, ...suspended];
|
||||
const scheduled = rows.filter((r) => r.status === "scheduled");
|
||||
// En attente d'abord, puis les programmées (elles attendent une décision de
|
||||
// calendrier), puis les offres en ligne et enfin les suspendues.
|
||||
const ordered = [...pending, ...scheduled, ...live, ...suspended];
|
||||
|
||||
const shares = ordered.length
|
||||
? await db
|
||||
@@ -90,6 +101,9 @@ export default async function PromotionsPage() {
|
||||
<span style={{ background: "#13324F", color: "#fff", fontSize: 13, fontWeight: 600, padding: "8px 16px", borderRadius: 999 }}>
|
||||
En attente · {pending.length}
|
||||
</span>
|
||||
<span style={{ background: "#fff", border: "1px solid #e6dcc6", color: "#6c6150", fontSize: 13, fontWeight: 600, padding: "8px 16px", borderRadius: 999 }}>
|
||||
Programmées · {scheduled.length}
|
||||
</span>
|
||||
<span style={{ background: "#fff", border: "1px solid #e6dcc6", color: "#6c6150", fontSize: 13, fontWeight: 600, padding: "8px 16px", borderRadius: 999 }}>
|
||||
En ligne · {live.length}
|
||||
</span>
|
||||
@@ -115,6 +129,7 @@ export default async function PromotionsPage() {
|
||||
{ordered.map((p) => {
|
||||
const isPending = p.status === "pending";
|
||||
const isSuspended = p.status === "suspended";
|
||||
const isScheduled = p.status === "scheduled";
|
||||
const chip = STATUS_CHIP[p.status] ?? STATUS_CHIP.pending;
|
||||
const chosenNetworks = networks.filter((n) =>
|
||||
n === "facebook" ? p.shareFacebook : p.shareLinkedin
|
||||
@@ -169,13 +184,34 @@ export default async function PromotionsPage() {
|
||||
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 15.5, margin: "0 0 4px", color: "#26201a" }}>
|
||||
{p.title}
|
||||
</h3>
|
||||
<div style={{ fontSize: 12.5, color: "#9a6638", fontWeight: 600, marginBottom: 8 }}>
|
||||
{p.memberName}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 8 }}>
|
||||
<MemberAvatar name={p.memberName ?? ""} logoUrl={p.memberLogoUrl} size={24} />
|
||||
<span style={{ fontSize: 12.5, color: "#9a6638", fontWeight: 600 }}>{p.memberName}</span>
|
||||
</div>
|
||||
<p style={{ margin: "0 0 12px", fontSize: 12.5, color: "#8c8068", lineHeight: 1.45, flex: 1 }}>
|
||||
{p.text}
|
||||
</p>
|
||||
|
||||
{/* Le modérateur voit la période de validité avant de valider :
|
||||
c'est elle qui partira dans le message réseaux sociaux. */}
|
||||
{formatValidity(p) && (
|
||||
<div style={{ fontSize: 12, color: "#9a8d72", fontWeight: 600, margin: "0 0 12px" }}>
|
||||
{formatValidity(p)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{p.status === "live" && visibilityNote(p) && (
|
||||
<div style={{ background: "#faf7ef", border: "1px solid #f0e8d6", color: "#9a8d72", borderRadius: 10, padding: "9px 11px", fontSize: 12, lineHeight: 1.45, marginBottom: 11 }}>
|
||||
{visibilityNote(p)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isScheduled && (
|
||||
<div style={{ background: "#eaf0f6", border: "1px solid #d3e0ee", color: "#2C6FB3", borderRadius: 10, padding: "9px 11px", fontSize: 12, lineHeight: 1.45, marginBottom: 11, fontWeight: 600 }}>
|
||||
{scheduleLabel(p.publishAt)}. Rien n'est encore visible ni diffusé.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSuspended && (
|
||||
<div style={{ background: "#fbe9e6", border: "1px solid #f2d5cf", color: "#a8503c", borderRadius: 10, padding: "9px 11px", fontSize: 12, lineHeight: 1.45, marginBottom: 11 }}>
|
||||
Suspendue par {p.suspendedBy === "member" ? "l'adhérent" : "l'association"}
|
||||
@@ -210,10 +246,30 @@ export default async function PromotionsPage() {
|
||||
))}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: "#a99c82", marginTop: 7, lineHeight: 1.45 }}>
|
||||
Publié dès la validation. Ce choix n'est plus modifiable ensuite.
|
||||
Diffusé en même temps que la mise en ligne : tout de suite si le
|
||||
champ Publication est vide, à la date indiquée sinon. Ce choix
|
||||
n'est plus modifiable ensuite.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Date demandée par l'adhérent, modifiable ici. Vidée,
|
||||
la promotion part immédiatement. */}
|
||||
<div style={{ background: "#faf7ef", border: "1px solid #f0e8d6", borderRadius: 10, padding: "10px 11px", marginBottom: 8 }}>
|
||||
<label style={{ display: "block", fontSize: 10.5, letterSpacing: "0.08em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 800, marginBottom: 7 }}>
|
||||
Publication
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
name="publishAt"
|
||||
defaultValue={toScheduleInput(p.publishAt)}
|
||||
style={{ width: "100%", boxSizing: "border-box", border: "1px solid #e6dcc6", borderRadius: 8, padding: "7px 9px", fontSize: 12.5, fontFamily: "inherit", color: "#3c3322", background: "#fff" }}
|
||||
/>
|
||||
<div style={{ fontSize: 11, color: "#a99c82", marginTop: 7, lineHeight: 1.45 }}>
|
||||
{p.publishAt
|
||||
? `Demandé par l'adhérent ${formatSchedule(p.publishAt)}. Videz le champ pour publier tout de suite.`
|
||||
: "Vide : mise en ligne immédiate. Renseignez une date pour programmer."}
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" style={{ width: "100%", border: "none", background: "#1f8a5b", color: "#fff", fontWeight: 700, fontSize: 13, padding: 10, borderRadius: 9, cursor: "pointer" }}>
|
||||
Valider
|
||||
</button>
|
||||
@@ -230,6 +286,16 @@ export default async function PromotionsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isScheduled && (
|
||||
<form action={moderatePromo} style={{ marginBottom: 8 }}>
|
||||
<input type="hidden" name="id" value={p.id} />
|
||||
<input type="hidden" name="action" value="publish-now" />
|
||||
<button type="submit" style={{ width: "100%", border: "none", background: "#2C6FB3", color: "#fff", fontWeight: 700, fontSize: 13, padding: 10, borderRadius: 9, cursor: "pointer" }}>
|
||||
Publier maintenant
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{!isPending && (
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<form action={moderatePromo} style={{ flex: 1 }}>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { headers } from "next/headers";
|
||||
import { originFromHeaders } from "@/lib/site-url";
|
||||
import { redirect } from "next/navigation";
|
||||
import type { CSSProperties } from "react";
|
||||
import { getSession } from "@/lib/session";
|
||||
import { can } from "@/lib/rbac";
|
||||
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
|
||||
import {
|
||||
checkTokenHealth,
|
||||
expiryStatus,
|
||||
getSocialAccounts,
|
||||
listStoredTargets,
|
||||
@@ -13,6 +15,7 @@ import {
|
||||
SOCIAL_LABELS,
|
||||
SOCIAL_NETWORKS,
|
||||
type SocialTarget,
|
||||
type TokenHealth,
|
||||
} from "@/lib/social-accounts";
|
||||
import { disconnectSocial, saveSitePublicUrl, saveSocialApp, selectSocialTarget } from "../actions";
|
||||
|
||||
@@ -20,7 +23,14 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
const HELP: Record<
|
||||
(typeof SOCIAL_NETWORKS)[number],
|
||||
{ portal: string; steps: string[]; caution?: string }
|
||||
{
|
||||
portal: string;
|
||||
steps: string[];
|
||||
caution?: string;
|
||||
tools?: { label: string; href: string }[];
|
||||
/** Les deux liens utiles au moment de renouveler un jeton. */
|
||||
renew: { note: string; links: { label: string; hint: string; href: string }[] };
|
||||
}
|
||||
> = {
|
||||
facebook: {
|
||||
portal: "https://developers.facebook.com/apps",
|
||||
@@ -29,6 +39,29 @@ const HELP: Record<
|
||||
"Ajoutez le produit « Connexion Facebook » puis collez l'URL de redirection ci-dessous dans « URI de redirection OAuth valides ».",
|
||||
"Laissez l'application en mode développement et ajoutez le compte de l'association comme administrateur : publier sur votre propre page ne demande alors aucune revue Meta.",
|
||||
],
|
||||
renew: {
|
||||
note:
|
||||
"Le bouton refait tout l'échange : jeton court, puis jeton de longue durée, puis jeton de page. " +
|
||||
"Vous n'avez aucun jeton à copier à la main — les deux liens ci-dessous ne servent qu'à vérifier.",
|
||||
links: [
|
||||
{
|
||||
label: "Débogueur de jeton",
|
||||
hint: "coller un jeton pour voir sa validité et sa date d'expiration",
|
||||
href: "https://developers.facebook.com/tools/debug/accesstoken/",
|
||||
},
|
||||
{
|
||||
label: "Outil de jetons d'accès",
|
||||
hint: "lister les jetons de vos pages et en générer un de longue durée",
|
||||
href: "https://developers.facebook.com/tools/accesstoken/",
|
||||
},
|
||||
],
|
||||
},
|
||||
tools: [
|
||||
{ label: "Débogueur de jeton (vérifier un jeton et sa date d'expiration)", href: "https://developers.facebook.com/tools/debug/accesstoken/" },
|
||||
{ label: "Outil de jetons d'accès (voir les jetons de vos pages)", href: "https://developers.facebook.com/tools/accesstoken/" },
|
||||
{ label: "Explorateur d'API Graph (tester un appel à la main)", href: "https://developers.facebook.com/tools/explorer/" },
|
||||
{ label: "Documentation : jetons de longue durée", href: "https://developers.facebook.com/docs/facebook-login/guides/access-tokens/get-long-lived" },
|
||||
],
|
||||
},
|
||||
linkedin: {
|
||||
portal: "https://www.linkedin.com/developers/apps",
|
||||
@@ -38,7 +71,28 @@ const HELP: Record<
|
||||
"Onglet Produits : demandez « Community Management API », indispensable pour publier au nom de la page.",
|
||||
],
|
||||
caution:
|
||||
"LinkedIn soumet cette demande à une revue (page vérifiée, nom légal, adresse, politique de confidentialité) et ses jetons expirent au bout de 60 jours : il faudra recliquer sur Reconnecter environ tous les deux mois.",
|
||||
"LinkedIn soumet cette demande à une revue (page vérifiée, nom légal, adresse, politique de confidentialité) et ses jetons expirent au bout de 60 jours : il faudra recliquer sur « Renouveler le jeton » environ tous les deux mois.",
|
||||
renew: {
|
||||
note:
|
||||
"Les jetons LinkedIn expirent au bout de 60 jours : le bouton relance l'autorisation et " +
|
||||
"repart pour deux mois. Les deux liens ci-dessous permettent de contrôler l'échéance.",
|
||||
links: [
|
||||
{
|
||||
label: "Jetons de votre application",
|
||||
hint: "voir la durée de validité restante des jetons émis",
|
||||
href: "https://www.linkedin.com/developers/tools/oauth",
|
||||
},
|
||||
{
|
||||
label: "Documentation : durée de vie des jetons",
|
||||
hint: "règles de rafraîchissement et conditions d'accès",
|
||||
href: "https://learn.microsoft.com/linkedin/shared/authentication/programmatic-refresh-tokens",
|
||||
},
|
||||
],
|
||||
},
|
||||
tools: [
|
||||
{ label: "Jetons de votre application (durée de validité)", href: "https://www.linkedin.com/developers/tools/oauth" },
|
||||
{ label: "Documentation : durée de vie et rafraîchissement des jetons", href: "https://learn.microsoft.com/linkedin/shared/authentication/programmatic-refresh-tokens" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -61,10 +115,7 @@ export default async function ReseauxPage({
|
||||
|
||||
// Adresse par laquelle l'administrateur consulte cette page : proposée par
|
||||
// défaut pour lui éviter de la recopier à la main.
|
||||
const requestHeaders = await headers();
|
||||
const host = requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host") ?? "";
|
||||
const protocol = requestHeaders.get("x-forwarded-proto") ?? (host.startsWith("localhost") ? "http" : "https");
|
||||
const detected = host ? `${protocol}://${host}` : "";
|
||||
const detected = originFromHeaders(await headers());
|
||||
|
||||
// Calculées ici : le rendu des cartes n'est pas asynchrone.
|
||||
const redirectUris = new Map<string, string>();
|
||||
@@ -72,6 +123,16 @@ export default async function ReseauxPage({
|
||||
redirectUris.set(network, await redirectUri(network));
|
||||
}
|
||||
|
||||
// État réel du jeton, demandé à la plateforme à chaque affichage : c'est le
|
||||
// seul moyen de savoir qu'un jeton « sans expiration » a été révoqué.
|
||||
const health = new Map<string, TokenHealth | null>();
|
||||
for (const network of SOCIAL_NETWORKS) {
|
||||
const account = byNetwork.get(network);
|
||||
if (account?.accessToken && account.targetId) {
|
||||
health.set(network, await checkTokenHealth(network));
|
||||
}
|
||||
}
|
||||
|
||||
// Cibles à proposer quand le compte administre plusieurs pages.
|
||||
const targets = new Map<string, SocialTarget[]>();
|
||||
let targetError: string | null = null;
|
||||
@@ -124,8 +185,9 @@ export default async function ReseauxPage({
|
||||
</form>
|
||||
{!base && detected && (
|
||||
<div style={{ marginTop: 10, fontSize: 12.5, color: "#9a6638" }}>
|
||||
Adresse détectée depuis votre navigation : <strong>{detected}</strong>. Vérifiez-la puis
|
||||
enregistrez.
|
||||
Aucune adresse enregistrée : celle de votre navigation, <strong>{detected}</strong>, est
|
||||
utilisée par défaut. Facebook et LinkedIn n'acceptent qu'une adresse de retour
|
||||
en <code>https</code> sur un nom de domaine : vérifiez-la puis enregistrez.
|
||||
</div>
|
||||
)}
|
||||
{!base && !detected && (
|
||||
@@ -141,6 +203,11 @@ export default async function ReseauxPage({
|
||||
const status = expiryStatus(account?.expiresAt);
|
||||
const isConnected = !!account?.accessToken && !!account.targetId;
|
||||
const candidates = targets.get(network) ?? [];
|
||||
const tokenHealth = health.get(network) ?? null;
|
||||
const broken = tokenHealth?.ok === false;
|
||||
// Le bloc de renouvellement passe en ambre quand il y a urgence :
|
||||
// jeton mort, expiré, ou sur le point de l'être.
|
||||
const needsRenewal = broken || status === "expired" || status === "soon";
|
||||
|
||||
return (
|
||||
<section key={network} style={panel}>
|
||||
@@ -163,7 +230,7 @@ export default async function ReseauxPage({
|
||||
<h2 className="font-display" style={{ ...title, margin: 0, flex: 1 }}>
|
||||
{SOCIAL_LABELS[network]}
|
||||
</h2>
|
||||
<StatusChip connected={isConnected} status={status} />
|
||||
<StatusChip connected={isConnected} status={broken ? "expired" : status} />
|
||||
</div>
|
||||
|
||||
{isConnected && (
|
||||
@@ -173,15 +240,30 @@ export default async function ReseauxPage({
|
||||
{account?.expiresAt && status !== "never" && ` Jeton valable jusqu'au ${fmtDate(account.expiresAt)}.`}
|
||||
{account?.connectedAt && ` Connecté le ${fmtDate(account.connectedAt)}.`}
|
||||
<br />
|
||||
Pour changer de page, relancez une connexion.
|
||||
{tokenHealth?.ok && (
|
||||
<span style={{ color: "#1f8a5b", fontWeight: 600 }}>
|
||||
✓ Jeton vérifié à l'instant auprès de {SOCIAL_LABELS[network]} : il fonctionne.{" "}
|
||||
</span>
|
||||
)}
|
||||
Pour changer de page ou renouveler l'accès, utilisez le bouton ci-dessous.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{broken && (
|
||||
<Banner tone="error">
|
||||
Le jeton {SOCIAL_LABELS[network]} ne fonctionne plus : les publications échoueront
|
||||
tant que vous n'aurez pas cliqué sur « Renouveler le jeton ».
|
||||
<div style={{ marginTop: 6, fontSize: 12, opacity: 0.85 }}>
|
||||
Réponse de la plateforme : {tokenHealth.reason.slice(0, 220)}
|
||||
</div>
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
{(status === "soon" || status === "expired") && isConnected && (
|
||||
<Banner tone={status === "expired" ? "error" : "warn"}>
|
||||
{status === "expired"
|
||||
? `Le jeton ${SOCIAL_LABELS[network]} a expiré : les publications échoueront tant que vous n'aurez pas reconnecté le compte.`
|
||||
: `Le jeton ${SOCIAL_LABELS[network]} expire bientôt. Un clic sur Reconnecter suffit à le renouveler.`}
|
||||
? `Le jeton ${SOCIAL_LABELS[network]} a expiré : les publications échoueront tant que vous ne l'aurez pas renouvelé.`
|
||||
: `Le jeton ${SOCIAL_LABELS[network]} expire bientôt. Un clic sur « Renouveler le jeton » suffit.`}
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
@@ -234,24 +316,91 @@ export default async function ReseauxPage({
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div style={{ display: "flex", gap: 10, flexWrap: "wrap", borderTop: "1px solid #f0e8d6", paddingTop: 14 }}>
|
||||
<a
|
||||
href={`/api/social/${network}/connect`}
|
||||
className="font-display"
|
||||
{/* Renouvellement : l'action et ses deux liens de contrôle, visibles
|
||||
sans avoir à déplier l'aide de configuration. */}
|
||||
{isConnected && (
|
||||
<div
|
||||
style={{
|
||||
textDecoration: "none",
|
||||
border: "none",
|
||||
background: account?.appId ? SOCIAL_BRAND[network] : "#d8cdb4",
|
||||
color: "#fff",
|
||||
fontWeight: 700,
|
||||
fontSize: 14,
|
||||
padding: "12px 20px",
|
||||
borderRadius: 10,
|
||||
pointerEvents: account?.appId ? undefined : "none",
|
||||
background: needsRenewal ? "#fbeede" : "#faf7ef",
|
||||
border: `1px solid ${needsRenewal ? "#ecd8b8" : "#f0e8d6"}`,
|
||||
borderRadius: 12,
|
||||
padding: 15,
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
{isConnected ? "Reconnecter" : "Connecter"}
|
||||
</a>
|
||||
<div className="field-label" style={{ marginBottom: 4 }}>
|
||||
Renouveler le jeton
|
||||
</div>
|
||||
<div style={{ fontSize: 12.5, color: "#8c8068", lineHeight: 1.6, marginBottom: 12 }}>
|
||||
{help.renew.note}
|
||||
</div>
|
||||
<a
|
||||
href={`/api/social/${network}/connect`}
|
||||
className="font-display"
|
||||
style={{
|
||||
display: "inline-block",
|
||||
textDecoration: "none",
|
||||
background: SOCIAL_BRAND[network],
|
||||
color: "#fff",
|
||||
fontWeight: 700,
|
||||
fontSize: 13.5,
|
||||
padding: "11px 18px",
|
||||
borderRadius: 10,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
Renouveler le jeton {SOCIAL_LABELS[network]}
|
||||
</a>
|
||||
<div style={{ display: "grid", gap: 8 }}>
|
||||
{help.renew.links.map((link) => (
|
||||
<a
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
gap: 8,
|
||||
textDecoration: "none",
|
||||
fontSize: 13,
|
||||
lineHeight: 1.5,
|
||||
color: "#2C6FB3",
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{link.label} ↗
|
||||
<span style={{ color: "#8c8068", fontWeight: 500, fontSize: 12.5 }}>
|
||||
{link.hint}
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", gap: 10, flexWrap: "wrap", borderTop: "1px solid #f0e8d6", paddingTop: 14 }}>
|
||||
{/* Une fois connecté, l'action vit dans le bloc de renouvellement
|
||||
ci-dessus : deux boutons identiques côte à côte n'aidaient personne. */}
|
||||
{!isConnected && (
|
||||
<a
|
||||
href={`/api/social/${network}/connect`}
|
||||
className="font-display"
|
||||
style={{
|
||||
textDecoration: "none",
|
||||
border: "none",
|
||||
background: account?.appId ? SOCIAL_BRAND[network] : "#d8cdb4",
|
||||
color: "#fff",
|
||||
fontWeight: 700,
|
||||
fontSize: 14,
|
||||
padding: "12px 20px",
|
||||
borderRadius: 10,
|
||||
pointerEvents: account?.appId ? undefined : "none",
|
||||
}}
|
||||
>
|
||||
Connecter
|
||||
</a>
|
||||
)}
|
||||
{isConnected && (
|
||||
<form action={disconnectSocial}>
|
||||
<input type="hidden" name="network" value={network} />
|
||||
@@ -277,7 +426,28 @@ export default async function ReseauxPage({
|
||||
{help.portal}
|
||||
</a>
|
||||
</div>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
{help.tools && (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<div className="field-label">Outils pour inspecter un jeton</div>
|
||||
<ul style={{ margin: "6px 0 0", paddingLeft: 20, fontSize: 13, lineHeight: 1.8 }}>
|
||||
{help.tools.map((tool) => (
|
||||
<li key={tool.href}>
|
||||
<a href={tool.href} target="_blank" rel="noopener noreferrer" style={{ color: "#2C6FB3", fontWeight: 600 }}>
|
||||
{tool.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div style={{ marginTop: 8, fontSize: 12.5, color: "#8c8068", lineHeight: 1.6 }}>
|
||||
Ces outils servent à contrôler ou dépanner : le bouton
|
||||
<strong> Connecter</strong> fait déjà l'échange complet — jeton court,
|
||||
puis jeton de longue durée, puis jeton de page — vous n'avez aucun jeton à
|
||||
copier à la main.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<div className="field-label">URL de redirection à déclarer</div>
|
||||
<code style={{ display: "block", background: "#faf7ef", border: "1px solid #f0e8d6", borderRadius: 9, padding: "10px 12px", fontSize: 12.5, color: "#3c3322", overflowWrap: "anywhere" }}>
|
||||
{base ? redirectUris.get(network) : "— renseignez d'abord l'URL publique du site ci-dessus —"}
|
||||
|
||||
@@ -1403,6 +1403,48 @@ img {
|
||||
color: var(--muted-4);
|
||||
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 ---- */
|
||||
.hours-editor {
|
||||
|
||||
+10
-103
@@ -7,11 +7,11 @@ import { SiteFooter } from "@/components/SiteFooter";
|
||||
import { MembershipModalButton } from "@/components/MembershipModalButton";
|
||||
import { MembersCarousel } from "@/components/MembersCarousel";
|
||||
import { StarField } from "@/components/StarField";
|
||||
import { PromoImage } from "@/components/PromoImage";
|
||||
import { PromoCard } from "@/components/PromoCard";
|
||||
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
|
||||
import { getActiveMemberCount, getLivePromotions, getRotatingActiveMembers } from "@/lib/queries";
|
||||
import { getSiteSettings, socialLinks } from "@/lib/site-settings";
|
||||
import { SITE_DESCRIPTION, SITE_TITLE, memberPath, pageMetadata } from "@/lib/seo";
|
||||
import { SITE_DESCRIPTION, SITE_TITLE, pageMetadata } from "@/lib/seo";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -26,14 +26,6 @@ export const metadata: Metadata = {
|
||||
title: { absolute: SITE_TITLE },
|
||||
};
|
||||
|
||||
const STRIPE_WARM =
|
||||
"repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)";
|
||||
|
||||
function badgeColor(badge: string | null) {
|
||||
if (!badge) return "#1f8a5b";
|
||||
return badge.includes("%") ? "#d8472b" : "#1f8a5b";
|
||||
}
|
||||
|
||||
export default async function AccueilPage() {
|
||||
const [promos, rotatingMembers, settings, memberCount] = await Promise.all([
|
||||
getLivePromotions(6),
|
||||
@@ -66,7 +58,7 @@ export default async function AccueilPage() {
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src="/assets/logo.png"
|
||||
alt="Plein R — Association des commerçants et entreprises du Bassin de Pompey"
|
||||
alt="Plein R — Association des commerçants et entreprises de service du Bassin de Pompey"
|
||||
className="floatlogo"
|
||||
style={{
|
||||
width: "100%",
|
||||
@@ -115,10 +107,10 @@ export default async function AccueilPage() {
|
||||
className="font-display"
|
||||
style={{ fontWeight: 600, fontSize: 17, color: "#9a6638", margin: "0 0 16px" }}
|
||||
>
|
||||
Association des commerçants & entreprises du Bassin de Pompey
|
||||
Association des commerçants & entreprises de service du Bassin de Pompey
|
||||
</div>
|
||||
<p style={{ fontSize: 17, lineHeight: 1.6, color: "#6c6150", margin: "0 0 24px", maxWidth: 520 }}>
|
||||
Boulangers, artisans, restaurateurs, entreprises… retrouvez tous nos adhérents, profitez de
|
||||
Boulangers, artisans, restaurateurs, entreprises de service… retrouvez tous nos adhérents, profitez de
|
||||
leurs bons plans et faites vivre le commerce de proximité.
|
||||
</p>
|
||||
<form
|
||||
@@ -226,99 +218,14 @@ export default async function AccueilPage() {
|
||||
Offres, réductions et nouveautés publiées par les commerçants du réseau.
|
||||
</p>
|
||||
</div>
|
||||
<a href="#promotions" className="link-arrow" style={{ textDecoration: "none", color: "#9a6638", fontWeight: 700, fontSize: 14.5, whiteSpace: "nowrap" }}>
|
||||
<Link href="/promotions" className="link-arrow" style={{ textDecoration: "none", color: "#9a6638", fontWeight: 700, fontSize: 14.5, whiteSpace: "nowrap" }}>
|
||||
Toutes les promos →
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Reveal className="grid grid-3 reveal-stagger" style={{ marginTop: 26 }}>
|
||||
{promos.map((p) => (
|
||||
<Link
|
||||
key={p.id}
|
||||
href={p.memberId ? memberPath({ id: p.memberId, name: p.memberName ?? "", city: p.memberCity }) : "#"}
|
||||
className="lift-card"
|
||||
style={{
|
||||
textDecoration: "none",
|
||||
color: "inherit",
|
||||
background: "#fff",
|
||||
border: "1px solid #ece3d0",
|
||||
borderRadius: 18,
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
aspectRatio: "1 / 1",
|
||||
background: STRIPE_WARM,
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{p.imageUrl && <PromoImage src={p.imageUrl} alt={p.title ?? ""} />}
|
||||
{!p.imageUrl && (
|
||||
<span style={{ fontSize: 10.5, letterSpacing: "0.12em", color: "#a99c82", textTransform: "uppercase" }}>
|
||||
photo de l'offre
|
||||
</span>
|
||||
)}
|
||||
{p.badge && (
|
||||
<span
|
||||
className="font-display"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
background: badgeColor(p.badge),
|
||||
color: "#fff",
|
||||
fontWeight: 800,
|
||||
fontSize: 18,
|
||||
padding: "8px 16px",
|
||||
borderRadius: "0 0 0 16px",
|
||||
}}
|
||||
>
|
||||
{p.badge}
|
||||
</span>
|
||||
)}
|
||||
{p.category && (
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 12,
|
||||
left: 12,
|
||||
background: "#9a6638",
|
||||
color: "#fff",
|
||||
borderRadius: 999,
|
||||
padding: "5px 12px",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{p.category}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ padding: "17px 18px 18px", display: "flex", flexDirection: "column", flex: 1 }}>
|
||||
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 18, margin: "0 0 5px", color: "#26201a" }}>
|
||||
{p.title}
|
||||
</h3>
|
||||
<p style={{ margin: "0 0 14px", fontSize: 13.5, color: "#8c8068", lineHeight: 1.5, flex: 1 }}>
|
||||
{p.text}
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
borderTop: "1px solid #f0e8d6",
|
||||
paddingTop: 12,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: "#3c3322" }}>{p.memberName}</div>
|
||||
<div style={{ fontSize: 11.5, color: "#a99c82" }}>{p.validUntil}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<PromoCard key={p.id} promo={p} />
|
||||
))}
|
||||
</Reveal>
|
||||
</section>
|
||||
@@ -452,8 +359,8 @@ export default async function AccueilPage() {
|
||||
</h2>
|
||||
<p style={{ margin: "0 auto 22px", fontSize: 15.5, color: "#f4e6d3", maxWidth: 480 }}>
|
||||
{memberCount > 0
|
||||
? `Rejoignez les ${memberCount} commerçants et entreprises adhérents qui font réseau, se rencontrent et réussissent ensemble.`
|
||||
: "Rejoignez les commerçants et entreprises qui font réseau, se rencontrent et réussissent ensemble."}
|
||||
? `Rejoignez les ${memberCount} commerçants et entreprises de service adhérents qui font réseau, se rencontrent et réussissent ensemble.`
|
||||
: "Rejoignez les commerçants et entreprises de service qui font réseau, se rencontrent et réussissent ensemble."}
|
||||
</p>
|
||||
<MembershipModalButton
|
||||
label="Devenir adhérent"
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Sparkle } from "@/components/Sparkle";
|
||||
import { SiteHeader } from "@/components/SiteHeader";
|
||||
import { SiteFooter } from "@/components/SiteFooter";
|
||||
import { MembershipModalButton } from "@/components/MembershipModalButton";
|
||||
import { PromoCard } from "@/components/PromoCard";
|
||||
import { JsonLd } from "@/components/JsonLd";
|
||||
import { getLivePromotions } from "@/lib/queries";
|
||||
import { breadcrumbJsonLd, memberListJsonLd, pageMetadata } from "@/lib/seo";
|
||||
import { publicBaseUrl } from "@/lib/seo-server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const metadata: Metadata = pageMetadata({
|
||||
title: "Bons plans et promotions du Bassin de Pompey",
|
||||
description:
|
||||
"Toutes les offres en cours chez les commerçants, artisans et entreprises adhérents Plein R : " +
|
||||
"réductions, nouveautés et bons plans à Pompey, Frouard, Liverdun, Champigneulles…",
|
||||
path: "/promotions",
|
||||
});
|
||||
|
||||
export default async function PromotionsPage() {
|
||||
// `null` : toutes les offres en cours, là où l'accueil n'en montre que six.
|
||||
const [promos, baseUrl] = await Promise.all([getLivePromotions(null), publicBaseUrl()]);
|
||||
|
||||
// Une offre pointe vers la fiche de son commerçant : la liste structurée
|
||||
// renvoie donc vers les adhérents, pas vers des URLs de promotion.
|
||||
const listed = promos
|
||||
.filter((p) => p.memberId != null)
|
||||
.map((p) => ({ id: p.memberId as number, name: p.memberName ?? "", city: p.memberCity }));
|
||||
|
||||
return (
|
||||
<div style={{ background: "#F6F2E8", minHeight: "100vh", fontFamily: "'Public Sans',sans-serif", color: "#33291D" }}>
|
||||
<SiteHeader active="promotions" logo />
|
||||
<JsonLd
|
||||
data={[
|
||||
breadcrumbJsonLd(baseUrl, [
|
||||
{ name: "Accueil", path: "/" },
|
||||
{ name: "Promotions", path: "/promotions" },
|
||||
]),
|
||||
memberListJsonLd(baseUrl, "Promotions en cours chez les adhérents Plein R", listed),
|
||||
]}
|
||||
/>
|
||||
|
||||
<main className="container" style={{ paddingBottom: 56 }}>
|
||||
{/* title block */}
|
||||
<section style={{ position: "relative", padding: "14px 0 22px", overflow: "hidden" }}>
|
||||
<Sparkle color="#E0A63C" size={18} style={{ top: 22, right: 30 }} duration={3.2} />
|
||||
<Sparkle color="#6FB0C6" size={12} style={{ top: 60, right: 90 }} duration={2.7} delay={0.5} />
|
||||
<div
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 9,
|
||||
background: "#fff",
|
||||
border: "1px solid #e6dcc6",
|
||||
color: "#9a6638",
|
||||
fontSize: 12,
|
||||
letterSpacing: "0.12em",
|
||||
textTransform: "uppercase",
|
||||
fontWeight: 800,
|
||||
padding: "6px 13px",
|
||||
borderRadius: 999,
|
||||
marginBottom: 14,
|
||||
}}
|
||||
>
|
||||
<span style={{ width: 7, height: 7, borderRadius: "50%", background: "#E0A63C" }} />
|
||||
{promos.length} offre{promos.length > 1 ? "s" : ""} en cours
|
||||
</div>
|
||||
<h1 className="font-display" style={{ fontWeight: 800, fontSize: "clamp(26px, 6vw, 40px)", letterSpacing: "-0.02em", margin: "0 0 8px", color: "#26201a" }}>
|
||||
Les bons plans de nos adhérents
|
||||
</h1>
|
||||
<p style={{ margin: 0, fontSize: 16.5, color: "#6c6150", maxWidth: 560 }}>
|
||||
Toutes les offres en cours chez les commerçants, artisans et entreprises du réseau.
|
||||
Chaque promotion renvoie vers la fiche de son commerce.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{promos.length === 0 ? (
|
||||
<section
|
||||
style={{
|
||||
background: "#fff",
|
||||
border: "1px solid #e6dcc6",
|
||||
borderRadius: 18,
|
||||
padding: "38px 24px",
|
||||
textAlign: "center",
|
||||
color: "#8c8068",
|
||||
fontSize: 15,
|
||||
lineHeight: 1.7,
|
||||
}}
|
||||
>
|
||||
Aucune promotion en cours pour le moment.
|
||||
<br />
|
||||
<Link href="/annuaire" style={{ color: "#9a6638", fontWeight: 700 }}>
|
||||
Parcourir l'annuaire des adhérents →
|
||||
</Link>
|
||||
</section>
|
||||
) : (
|
||||
<div className="grid grid-3" style={{ gap: 20 }}>
|
||||
{promos.map((p) => (
|
||||
<PromoCard key={p.id} promo={p} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* join CTA */}
|
||||
<section
|
||||
style={{
|
||||
marginTop: 34,
|
||||
position: "relative",
|
||||
background: "linear-gradient(120deg,#13324F,#1d4a72)",
|
||||
borderRadius: 20,
|
||||
padding: "34px 40px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 28,
|
||||
overflow: "hidden",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Sparkle color="#E0A63C" size={18} style={{ top: 20, right: 40 }} duration={3.2} />
|
||||
<div>
|
||||
<div style={{ fontSize: 12.5, letterSpacing: "0.12em", textTransform: "uppercase", color: "#9fc6e6", fontWeight: 700, marginBottom: 8 }}>
|
||||
Vous êtes commerçant du Bassin de Pompey ?
|
||||
</div>
|
||||
<h2 className="font-display" style={{ fontWeight: 800, fontSize: 26, margin: 0, color: "#fff" }}>
|
||||
Publiez vos offres sur Plein R
|
||||
</h2>
|
||||
</div>
|
||||
<MembershipModalButton
|
||||
label="Adhérer à l'association"
|
||||
className="font-display"
|
||||
style={{ border: "none", background: "#E0A63C", color: "#33291D", fontWeight: 700, fontSize: 15.5, padding: "14px 26px", borderRadius: 12, whiteSpace: "nowrap" }}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { Modal } from "@/components/Modal";
|
||||
|
||||
/**
|
||||
* Carte d'une rencontre à venir, de hauteur identique à ses voisines.
|
||||
*
|
||||
* Le texte d'une rencontre est libre : une description longue étirait sa carte,
|
||||
* donc toute la rangée de la grille, et laissait les autres avec un grand vide
|
||||
* sous leur bouton. Titre et description occupent désormais une hauteur figée
|
||||
* et sont tronqués en fin de ligne ; le texte complet reste lisible dans une
|
||||
* fenêtre ouverte par « Voir le détail ».
|
||||
*
|
||||
* Les dates arrivent **déjà formatées** depuis la page serveur : les formater
|
||||
* ici les rendrait dans le fuseau du visiteur, différent de celui du serveur,
|
||||
* et provoquerait une erreur d'hydratation.
|
||||
*/
|
||||
|
||||
export type UpcomingMeetingCardData = {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string | null;
|
||||
location: string | null;
|
||||
imageUrl: string | null;
|
||||
/** « vendredi 3 octobre 2026 ». */
|
||||
dateLabel: string;
|
||||
/** « 18:30 ». */
|
||||
timeLabel: string;
|
||||
capacity: number;
|
||||
registered: number;
|
||||
participantsPerAccount: number;
|
||||
isRegistered: boolean;
|
||||
/** Libellé du bouton d'inscription, décidé côté serveur (session, places). */
|
||||
ctaLabel: string;
|
||||
};
|
||||
|
||||
const STRIPE =
|
||||
"repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)";
|
||||
|
||||
/**
|
||||
* Hauteurs figées des deux blocs de texte, même principe que `MemberCard` :
|
||||
* elles alignent les cartes quel que soit le texte saisi par le staff.
|
||||
*/
|
||||
const TITLE_FONT = 21;
|
||||
const TITLE_LINE = 1.25;
|
||||
const TITLE_LINES = 2;
|
||||
const DESC_FONT = 13.5;
|
||||
const DESC_LINE = 1.6;
|
||||
const DESC_LINES = 3;
|
||||
|
||||
const clampLines = (lines: number) =>
|
||||
({
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: lines,
|
||||
WebkitBoxOrient: "vertical" as const,
|
||||
overflow: "hidden",
|
||||
}) as const;
|
||||
|
||||
export function MeetingCard({ meeting }: { meeting: UpcomingMeetingCardData }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const remaining = Math.max(0, meeting.capacity - meeting.registered);
|
||||
const full = remaining <= 0 && !meeting.isRegistered;
|
||||
const perAccount = `Jusqu'à ${meeting.participantsPerAccount} participant${meeting.participantsPerAccount > 1 ? "s" : ""} par compte`;
|
||||
const cover = meeting.imageUrl ? `center/cover no-repeat url(${meeting.imageUrl})` : STRIPE;
|
||||
|
||||
return (
|
||||
<article
|
||||
id={`rencontre-${meeting.id}`}
|
||||
className="lift"
|
||||
style={{
|
||||
background: "#fff",
|
||||
border: meeting.isRegistered ? "2px solid #1f8a5b" : "1px solid #e6dcc6",
|
||||
borderRadius: 12,
|
||||
overflow: "hidden",
|
||||
scrollMarginTop: 100,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
<div style={{ position: "relative", aspectRatio: "16 / 10", background: cover, flexShrink: 0 }}>
|
||||
{meeting.isRegistered && (
|
||||
<span style={{ position: "absolute", top: 12, right: 12, display: "inline-flex", alignItems: "center", gap: 6, background: "#1f8a5b", color: "#fff", borderRadius: 999, padding: "7px 12px", fontSize: 12, fontWeight: 800, boxShadow: "0 4px 14px rgba(22,72,49,0.28)" }}>
|
||||
✓ Inscrit
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ padding: 18, display: "flex", flexDirection: "column", flex: 1 }}>
|
||||
<div style={{ color: "#9a6638", fontSize: 12.5, fontWeight: 800 }}>
|
||||
{meeting.dateLabel} · {meeting.timeLabel}
|
||||
</div>
|
||||
|
||||
<h3
|
||||
className="font-display"
|
||||
style={{
|
||||
margin: "7px 0 7px",
|
||||
color: "#26201a",
|
||||
fontSize: TITLE_FONT,
|
||||
lineHeight: TITLE_LINE,
|
||||
height: TITLE_FONT * TITLE_LINE * TITLE_LINES,
|
||||
...clampLines(TITLE_LINES),
|
||||
}}
|
||||
>
|
||||
{meeting.title}
|
||||
</h3>
|
||||
|
||||
{/* Le lieu est facultatif : sa ligne est réservée dans tous les cas,
|
||||
sinon une carte sans lieu remonterait tout son bas. */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "#6c6150",
|
||||
fontWeight: 700,
|
||||
height: 13 * 1.4,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{meeting.location}
|
||||
</div>
|
||||
|
||||
<p
|
||||
style={{
|
||||
color: "#8c8068",
|
||||
fontSize: DESC_FONT,
|
||||
lineHeight: DESC_LINE,
|
||||
height: DESC_FONT * DESC_LINE * DESC_LINES,
|
||||
margin: "8px 0 0",
|
||||
...clampLines(DESC_LINES),
|
||||
}}
|
||||
>
|
||||
{meeting.description}
|
||||
</p>
|
||||
|
||||
{/* Espaceur : colle le bas de carte au bas du cadre, quelle que soit la
|
||||
hauteur imposée par la plus haute carte de la rangée. */}
|
||||
<div style={{ flex: 1, minHeight: 12 }} />
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "space-between", color: "#6c6150", fontSize: 12.5, fontWeight: 800, marginBottom: 8 }}>
|
||||
<span>{meeting.registered}/{meeting.capacity} inscrits</span>
|
||||
<span>{remaining} place(s)</span>
|
||||
</div>
|
||||
<div style={{ color: "#8c8068", fontSize: 12.5, marginBottom: 10 }}>{perAccount}</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
style={{ flex: "0 0 auto", border: "1px solid #d6c9ad", background: "#fff", color: "#6f6450", borderRadius: 8, padding: "11px 14px", fontWeight: 800, fontSize: 13.5, cursor: "pointer", fontFamily: "inherit" }}
|
||||
>
|
||||
Voir le détail
|
||||
</button>
|
||||
<Link
|
||||
href={`/inscription/${meeting.id}`}
|
||||
style={{ flex: 1, textAlign: "center", textDecoration: "none", background: full ? "#a99c82" : "#13324F", color: "#fff", borderRadius: 8, padding: 11, fontWeight: 800 }}
|
||||
>
|
||||
{meeting.ctaLabel}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal open={open} onClose={() => setOpen(false)} title={meeting.title} maxWidth={560}>
|
||||
{meeting.imageUrl && (
|
||||
<div style={{ aspectRatio: "16 / 10", background: cover, borderRadius: 12, marginBottom: 16 }} />
|
||||
)}
|
||||
|
||||
<div style={{ color: "#9a6638", fontSize: 13, fontWeight: 800 }}>
|
||||
{meeting.dateLabel} · {meeting.timeLabel}
|
||||
</div>
|
||||
{meeting.location && (
|
||||
<div style={{ fontSize: 13.5, color: "#6c6150", fontWeight: 700, marginTop: 4 }}>{meeting.location}</div>
|
||||
)}
|
||||
|
||||
{meeting.description && (
|
||||
<p style={{ color: "#6c6150", fontSize: 14, lineHeight: 1.7, whiteSpace: "pre-line", margin: "14px 0 0" }}>
|
||||
{meeting.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "space-between", gap: 12, background: "#faf7ef", border: "1px solid #f0e8d6", borderRadius: 12, padding: "12px 14px", margin: "18px 0 6px", fontSize: 13, fontWeight: 800, color: "#6c6150" }}>
|
||||
<span>{meeting.registered}/{meeting.capacity} inscrits</span>
|
||||
<span>{remaining} place(s) restante(s)</span>
|
||||
</div>
|
||||
<div style={{ color: "#8c8068", fontSize: 12.5, marginBottom: 16 }}>{perAccount}</div>
|
||||
|
||||
<Link
|
||||
href={`/inscription/${meeting.id}`}
|
||||
style={{ display: "block", textAlign: "center", textDecoration: "none", background: full ? "#a99c82" : "#13324F", color: "#fff", borderRadius: 8, padding: 12, fontWeight: 800 }}
|
||||
>
|
||||
{meeting.ctaLabel}
|
||||
</Link>
|
||||
</Modal>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Pastille ronde d'un adhérent : son logo, sinon ses initiales.
|
||||
*
|
||||
* Sert à identifier le commerce d'un coup d'œil devant son nom, notamment sur
|
||||
* les cartes de promotion. Le logo est rendu en `contain` sur fond blanc : la
|
||||
* plupart des logos d'adhérents sont des marques sur fond clair, un `cover` les
|
||||
* rognerait.
|
||||
*/
|
||||
|
||||
export function memberInitials(name: string): string {
|
||||
return name
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map((w) => w[0])
|
||||
.join("")
|
||||
.slice(0, 2)
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
export function MemberAvatar({
|
||||
name,
|
||||
logoUrl,
|
||||
size = 26,
|
||||
accent = "#E0A63C",
|
||||
}: {
|
||||
name: string;
|
||||
logoUrl?: string | null;
|
||||
size?: number;
|
||||
accent?: string;
|
||||
}) {
|
||||
const common: React.CSSProperties = {
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: "50%",
|
||||
flexShrink: 0,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
if (logoUrl) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{ ...common, background: "#fff", border: "1px solid #eee3cf" }}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt=""
|
||||
style={{ width: "100%", height: "100%", objectFit: "contain", padding: 2, boxSizing: "border-box" }}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
...common,
|
||||
background: accent,
|
||||
color: "#fff",
|
||||
fontWeight: 800,
|
||||
fontSize: Math.round(size * 0.4),
|
||||
letterSpacing: "0.02em",
|
||||
}}
|
||||
>
|
||||
{memberInitials(name)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import { VitrineImage } from "@/components/VitrineImage";
|
||||
import { memberPath } from "@/lib/seo";
|
||||
import type { MemberContact } from "@/lib/member-contact";
|
||||
|
||||
export type MemberCardData = {
|
||||
id: number;
|
||||
@@ -21,11 +22,45 @@ const STRIPE_WARM =
|
||||
const STRIPE_COOL =
|
||||
"repeating-linear-gradient(45deg,#eef0ec,#eef0ec 12px,#e2e8e6 12px,#e2e8e6 24px)";
|
||||
|
||||
/**
|
||||
* Hauteurs figées des deux blocs de texte : une description longue ne doit pas
|
||||
* étirer la carte (donc toute la rangée de la grille). Titre et description
|
||||
* sont tronqués en fin de ligne et occupent toujours la même place, ce qui
|
||||
* aligne les cartes entre elles quel que soit le texte saisi par l'adhérent.
|
||||
*/
|
||||
const TITLE_LINE = 1.25;
|
||||
const TITLE_LINES = 2;
|
||||
const DESC_FONT = 13;
|
||||
const DESC_LINE = 1.5;
|
||||
const DESC_LINES = 4;
|
||||
|
||||
const clampLines = (lines: number) =>
|
||||
({
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: lines,
|
||||
WebkitBoxOrient: "vertical" as const,
|
||||
overflow: "hidden",
|
||||
}) as const;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* `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(" · ");
|
||||
return (
|
||||
<Link
|
||||
href={memberPath(m)}
|
||||
@@ -50,16 +85,37 @@ export function MemberCard({ m, index = 0 }: { m: MemberCardData; index?: number
|
||||
)}
|
||||
</VitrineImage>
|
||||
<div style={{ padding: "16px 18px 18px", display: "flex", flexDirection: "column", flex: 1 }}>
|
||||
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 18, margin: 0, color: "#26201a" }}>
|
||||
<h3
|
||||
className="font-display"
|
||||
style={{ fontWeight: 700, fontSize: 18, lineHeight: TITLE_LINE, margin: 0, color: "#26201a", minHeight: 18 * TITLE_LINE * TITLE_LINES, ...clampLines(TITLE_LINES) }}
|
||||
>
|
||||
{m.name}
|
||||
</h3>
|
||||
<p style={{ margin: "6px 0 12px", fontSize: 13, color: "#8c8068", lineHeight: 1.5, flex: 1 }}>
|
||||
<p
|
||||
style={{ margin: "6px 0 12px", fontSize: DESC_FONT, color: "#8c8068", lineHeight: DESC_LINE, height: DESC_FONT * DESC_LINE * DESC_LINES, ...clampLines(DESC_LINES) }}
|
||||
>
|
||||
{m.description}
|
||||
</p>
|
||||
<div style={{ flex: 1 }} />
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 7, fontSize: 12.5, color: "#6c6150", borderTop: "1px solid #f0e8d6", paddingTop: 11 }}>
|
||||
<span style={{ width: 7, height: 7, borderRadius: "50%", background: m.accent ?? "#E0A63C" }} />
|
||||
{[m.address, m.city].filter(Boolean).join(" · ")}
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Carte d'une promotion sur le site public.
|
||||
*
|
||||
* Un seul rendu pour l'accueil (les 6 dernières) et la page `/promotions`
|
||||
* (toutes les offres en cours) : les deux listes doivent rester identiques.
|
||||
*/
|
||||
|
||||
import Link from "next/link";
|
||||
import { PromoImage } from "@/components/PromoImage";
|
||||
import { MemberAvatar } from "@/components/MemberAvatar";
|
||||
import { formatValidity } from "@/lib/promo-validity";
|
||||
import { memberPath } from "@/lib/seo";
|
||||
|
||||
const STRIPE_WARM =
|
||||
"repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)";
|
||||
|
||||
/** Rouge pour une remise chiffrée, vert pour une offre en nature. */
|
||||
function badgeColor(badge: string | null) {
|
||||
if (!badge) return "#1f8a5b";
|
||||
return badge.includes("%") ? "#d8472b" : "#1f8a5b";
|
||||
}
|
||||
|
||||
export type PublicPromo = {
|
||||
id: number;
|
||||
title: string;
|
||||
text: string | null;
|
||||
category: string | null;
|
||||
badge: string | null;
|
||||
imageUrl: string | null;
|
||||
validUntil: string | null;
|
||||
startsOn: string | null;
|
||||
endsOn: string | null;
|
||||
memberId: number | null;
|
||||
memberName: string | null;
|
||||
memberCity: string | null;
|
||||
memberLogoUrl: string | null;
|
||||
};
|
||||
|
||||
export function PromoCard({ promo: p }: { promo: PublicPromo }) {
|
||||
const validity = formatValidity(p);
|
||||
return (
|
||||
<Link
|
||||
href={p.memberId ? memberPath({ id: p.memberId, name: p.memberName ?? "", city: p.memberCity }) : "#"}
|
||||
className="lift-card"
|
||||
style={{
|
||||
textDecoration: "none",
|
||||
color: "inherit",
|
||||
background: "#fff",
|
||||
border: "1px solid #ece3d0",
|
||||
borderRadius: 18,
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
aspectRatio: "1 / 1",
|
||||
background: STRIPE_WARM,
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{p.imageUrl && <PromoImage src={p.imageUrl} alt={p.title ?? ""} />}
|
||||
{!p.imageUrl && (
|
||||
<span style={{ fontSize: 10.5, letterSpacing: "0.12em", color: "#a99c82", textTransform: "uppercase" }}>
|
||||
photo de l'offre
|
||||
</span>
|
||||
)}
|
||||
{p.badge && (
|
||||
<span
|
||||
className="font-display"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
background: badgeColor(p.badge),
|
||||
color: "#fff",
|
||||
fontWeight: 800,
|
||||
fontSize: 18,
|
||||
padding: "8px 16px",
|
||||
borderRadius: "0 0 0 16px",
|
||||
}}
|
||||
>
|
||||
{p.badge}
|
||||
</span>
|
||||
)}
|
||||
{p.category && (
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 12,
|
||||
left: 12,
|
||||
background: "#9a6638",
|
||||
color: "#fff",
|
||||
borderRadius: 999,
|
||||
padding: "5px 12px",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{p.category}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ padding: "17px 18px 18px", display: "flex", flexDirection: "column", flex: 1 }}>
|
||||
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 18, margin: "0 0 5px", color: "#26201a" }}>
|
||||
{p.title}
|
||||
</h3>
|
||||
<p style={{ margin: "0 0 14px", fontSize: 13.5, color: "#8c8068", lineHeight: 1.5, flex: 1 }}>
|
||||
{p.text}
|
||||
</p>
|
||||
<div style={{ borderTop: "1px solid #f0e8d6", paddingTop: 12 }}>
|
||||
{/* Le logo du commerce juste à gauche de son nom. */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 9 }}>
|
||||
<MemberAvatar name={p.memberName ?? ""} logoUrl={p.memberLogoUrl} size={28} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: "#3c3322" }}>{p.memberName}</div>
|
||||
{validity && <div style={{ fontSize: 11.5, color: "#a99c82" }}>{validity}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -32,7 +32,7 @@ export async function SiteFooter() {
|
||||
<div className="footer-title">Accès rapide</div>
|
||||
<div className="footer-links">
|
||||
<Link href="/annuaire">Annuaire</Link>
|
||||
<Link href="/#promotions">Promotions</Link>
|
||||
<Link href="/promotions">Promotions</Link>
|
||||
<Link href="/association">L'association</Link>
|
||||
<Link href="/rencontres-passees">Rencontres passées</Link>
|
||||
<Link href="/login">Espace adhérent</Link>
|
||||
|
||||
@@ -3,7 +3,7 @@ import Link from "next/link";
|
||||
const NAV = [
|
||||
{ key: "accueil", label: "Accueil", href: "/" },
|
||||
{ key: "annuaire", label: "Annuaire", href: "/annuaire" },
|
||||
{ key: "promotions", label: "Promotions", href: "/#promotions" },
|
||||
{ key: "promotions", label: "Promotions", href: "/promotions" },
|
||||
{ key: "association", label: "L'association", href: "/association" },
|
||||
];
|
||||
|
||||
|
||||
@@ -19,6 +19,9 @@ export function demoMembers(catId: (slug: string) => number | null) {
|
||||
address: "12 rue de la République",
|
||||
postalCode: "54390",
|
||||
phone: "03 83 49 00 00",
|
||||
contactFirstName: "Julien",
|
||||
contactLastName: "Marchal",
|
||||
contactPhone: "06 12 34 56 78",
|
||||
website: "https://www.aubonpain-frouard.fr",
|
||||
memberSince: 2021,
|
||||
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",
|
||||
postalCode: "54340",
|
||||
phone: "03 83 24 00 00",
|
||||
contactFirstName: "Sophie",
|
||||
contactLastName: "Renard",
|
||||
contactPhone: "06 98 76 54 32",
|
||||
memberSince: 2019,
|
||||
tags: "Cuisine de saison, Brunch, Terrasse, Produits frais",
|
||||
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"),
|
||||
city: "Champigneulles",
|
||||
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.",
|
||||
status: "active" as const,
|
||||
highlighted: true,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
text,
|
||||
varchar,
|
||||
boolean,
|
||||
date,
|
||||
timestamp,
|
||||
index,
|
||||
uniqueIndex,
|
||||
@@ -21,6 +22,10 @@ export const promoStatusEnum = pgEnum("promo_status", [
|
||||
"expired",
|
||||
"rejected",
|
||||
"suspended",
|
||||
// Validée par l'association mais en attente de sa date de publication
|
||||
// (`publish_at`). Invisible du site public tant que l'échéance n'est pas
|
||||
// atteinte, et non encore diffusée sur les réseaux.
|
||||
"scheduled",
|
||||
]);
|
||||
// Qui a suspendu la promotion : son créateur (l'adhérent) ou l'association.
|
||||
// Une promotion suspendue par l'association ne peut être réactivée que par elle.
|
||||
@@ -57,6 +62,13 @@ export const members = pgTable("members", {
|
||||
email: varchar("email", { length: 200 }).notNull(),
|
||||
// E-mail public de la fiche ; à vide, la fiche retombe sur `email`.
|
||||
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),
|
||||
city: varchar("city", { length: 120 }),
|
||||
address: varchar("address", { length: 240 }),
|
||||
@@ -107,11 +119,18 @@ export const promotions = pgTable("promotions", {
|
||||
imageUrl: text("image_url"),
|
||||
memberId: integer("member_id").references(() => members.id),
|
||||
status: promoStatusEnum("status").notNull().default("pending"),
|
||||
// Période de validité. Les deux bornes sont facultatives et indépendantes :
|
||||
// une offre peut n'avoir qu'une date de fin, qu'une date de début, ou aucune.
|
||||
startsOn: date("starts_on"),
|
||||
endsOn: date("ends_on"),
|
||||
/** @deprecated Texte libre des anciennes promos ; sert de repli à l'affichage. */
|
||||
validUntil: varchar("valid_until", { length: 120 }),
|
||||
// Réseaux demandés à la soumission. Le choix reste modifiable tant que la
|
||||
// promo est « pending » ; la validation le fige ET déclenche la publication.
|
||||
shareFacebook: boolean("share_facebook").notNull().default(false),
|
||||
shareLinkedin: boolean("share_linkedin").notNull().default(false),
|
||||
// Programmation de la publication. Vide = mise en ligne dès la validation.
|
||||
publishAt: timestamp("publish_at", { withTimezone: true }),
|
||||
suspendedBy: promoSuspendedByEnum("suspended_by"),
|
||||
suspendedById: integer("suspended_by_id").references(() => users.id, { onDelete: "set null" }),
|
||||
suspendedAt: timestamp("suspended_at", { withTimezone: true }),
|
||||
@@ -155,6 +174,11 @@ export const socialAccounts = pgTable("social_accounts", {
|
||||
targetName: varchar("target_name", { length: 200 }),
|
||||
connectedById: integer("connected_by_id").references(() => users.id, { onDelete: "set null" }),
|
||||
connectedAt: timestamp("connected_at", { withTimezone: true }),
|
||||
// Dernier contrôle de validité auprès de la plateforme : permet au tableau de
|
||||
// bord d'alerter sans refaire un appel réseau à chaque affichage.
|
||||
lastCheckAt: timestamp("last_check_at", { withTimezone: true }),
|
||||
lastCheckOk: boolean("last_check_ok"),
|
||||
lastCheckError: text("last_check_error"),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Boucle de libération des publications programmées. Fichier séparé de
|
||||
* `instrumentation.ts` : il touche la base et ne doit donc jamais entrer dans
|
||||
* le bundle edge du middleware.
|
||||
*/
|
||||
import { releaseDuePromotions } from "@/lib/promo-publish";
|
||||
|
||||
const INTERVAL_MS = 60_000;
|
||||
|
||||
async function tick() {
|
||||
try {
|
||||
const n = await releaseDuePromotions();
|
||||
if (n > 0) console.log(`[promotions] ${n} publication(s) programmée(s) mise(s) en ligne`);
|
||||
} catch (error) {
|
||||
// `releaseDuePromotions` avale déjà ses erreurs ; ce filet couvre le cas
|
||||
// d'une base injoignable au démarrage, qui ne doit pas tuer le serveur.
|
||||
console.error("[promotions] libérateur :", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Un premier passage au démarrage rattrape les échéances tombées pendant un
|
||||
// redéploiement ou une coupure.
|
||||
void tick();
|
||||
setInterval(tick, INTERVAL_MS).unref();
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Démarrage du serveur : libérateur des publications programmées.
|
||||
*
|
||||
* Le déploiement est un conteneur unique et permanent (`docker-compose`), il
|
||||
* n'y a donc ni cron ni file d'attente. Une boucle minute suffit : elle relit
|
||||
* les promotions `scheduled` dont l'échéance est passée, les met en ligne et
|
||||
* lance la diffusion réseaux. La minute est aussi la précision annoncée dans
|
||||
* le formulaire.
|
||||
*
|
||||
* Sûr à plusieurs instances : la bascule passe par un `UPDATE … RETURNING`
|
||||
* filtré sur le statut (voir `releaseDuePromotions`), une seule instance
|
||||
* récupère chaque promotion.
|
||||
*/
|
||||
export async function register() {
|
||||
// `register()` est aussi appelé pour le runtime edge, où il n'y a ni base
|
||||
// ni `setInterval` durable : le module Node est chargé à la demande pour ne
|
||||
// pas entrer dans ce bundle.
|
||||
if (process.env.NEXT_RUNTIME !== "nodejs") return;
|
||||
if (process.env.PROMO_SCHEDULER === "off") return;
|
||||
await import("./instrumentation-node");
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { db } from "@/db";
|
||||
import { activityLog } from "@/db/schema";
|
||||
import { sanitizeActivityMessage } from "@/lib/activity";
|
||||
|
||||
/**
|
||||
* Écrit une ligne du journal d'activité.
|
||||
*
|
||||
* Le message agrège des saisies de tiers (jusqu'au formulaire de contact
|
||||
* public) : il ne laisse passer que `<strong>`.
|
||||
*/
|
||||
export async function logActivity(message: string, dot = "#2C6FB3") {
|
||||
await db.insert(activityLog).values({ message: sanitizeActivityMessage(message), dot });
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Description d'une image déposée dans un formulaire : dimensions, format et
|
||||
* poids, plus les remarques à montrer à l'adhérent avant l'envoi.
|
||||
*
|
||||
* Module **pur** (aucun accès DOM, base ou réseau) : il est appelé depuis un
|
||||
* composant client, et verrouillé par `tests/image-info.test.ts`.
|
||||
*
|
||||
* Le besoin vient des réseaux sociaux. L'application publie le fichier tel
|
||||
* quel — `loadPromoImage()` décode la data-URI et envoie les octets sans y
|
||||
* toucher — donc un visuel qui n'est pas au format attendu par Facebook ou
|
||||
* LinkedIn est recadré ou entouré de bandes de couleur *par la plateforme*,
|
||||
* une couleur prise dans l'image. Rien ne se voit côté site, qui recadre en
|
||||
* `cover` : d'où l'information au moment du dépôt, seul endroit où l'adhérent
|
||||
* peut encore changer de fichier.
|
||||
*/
|
||||
|
||||
export type ImageInfo = {
|
||||
/** Largeur en pixels. */
|
||||
width: number;
|
||||
/** Hauteur en pixels. */
|
||||
height: number;
|
||||
/** Poids du fichier d'origine, en octets. */
|
||||
bytes: number;
|
||||
};
|
||||
|
||||
/** Écart toléré autour du carré parfait (2 %) : 1080×1078 reste « carré ». */
|
||||
export const SQUARE_TOLERANCE = 0.02;
|
||||
|
||||
/** En dessous, l'image sera floue une fois agrandie dans un fil d'actualité. */
|
||||
export const MIN_SIDE = 800;
|
||||
|
||||
/** Côté conseillé, celui qu'attendent les deux réseaux. */
|
||||
export const IDEAL_SIDE = 1080;
|
||||
|
||||
/**
|
||||
* Au delà, la data-URI (base64, ~+33 %) approche la limite de 4 Mo des server
|
||||
* actions : l'envoi peut être refusé.
|
||||
*/
|
||||
export const MAX_BYTES = 2_500_000;
|
||||
|
||||
/** Formats courants reconnus, du plus au moins fréquent. */
|
||||
const KNOWN_RATIOS: { ratio: number; label: string }[] = [
|
||||
{ ratio: 1, label: "1:1" },
|
||||
{ ratio: 4 / 5, label: "4:5" },
|
||||
{ ratio: 5 / 4, label: "5:4" },
|
||||
{ ratio: 3 / 4, label: "3:4" },
|
||||
{ ratio: 4 / 3, label: "4:3" },
|
||||
{ ratio: 2 / 3, label: "2:3" },
|
||||
{ ratio: 3 / 2, label: "3:2" },
|
||||
{ ratio: 9 / 16, label: "9:16" },
|
||||
{ ratio: 16 / 9, label: "16:9" },
|
||||
{ ratio: 1.91, label: "1,91:1" },
|
||||
];
|
||||
|
||||
function isValid(info: ImageInfo): boolean {
|
||||
return (
|
||||
Number.isFinite(info.width) &&
|
||||
Number.isFinite(info.height) &&
|
||||
info.width > 0 &&
|
||||
info.height > 0
|
||||
);
|
||||
}
|
||||
|
||||
/** Poids lisible : « 840 Ko », « 1,2 Mo ». */
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes < 0) return "";
|
||||
if (bytes < 1000) return `${Math.round(bytes)} o`;
|
||||
if (bytes < 1_000_000) return `${Math.round(bytes / 1000)} Ko`;
|
||||
const mo = bytes / 1_000_000;
|
||||
// Une décimale jusqu'à 10 Mo, virgule française.
|
||||
const value = mo < 10 ? mo.toFixed(1).replace(".", ",") : String(Math.round(mo));
|
||||
return `${value} Mo`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Libellé du rapport largeur/hauteur : un format courant quand il en approche
|
||||
* un à 2 % près, sinon une approximation décimale plutôt qu'un « 1083:721 »
|
||||
* illisible.
|
||||
*/
|
||||
export function aspectLabel(width: number, height: number): string {
|
||||
if (!isValid({ width, height, bytes: 0 })) return "";
|
||||
const ratio = width / height;
|
||||
for (const known of KNOWN_RATIOS) {
|
||||
if (Math.abs(ratio - known.ratio) / known.ratio <= SQUARE_TOLERANCE) return known.label;
|
||||
}
|
||||
return ratio >= 1
|
||||
? `${ratio.toFixed(2).replace(".", ",")}:1`
|
||||
: `1:${(1 / ratio).toFixed(2).replace(".", ",")}`;
|
||||
}
|
||||
|
||||
/** `true` si l'image est carrée à la tolérance près. */
|
||||
export function isSquare(info: ImageInfo): boolean {
|
||||
if (!isValid(info)) return false;
|
||||
return Math.abs(info.width / info.height - 1) <= SQUARE_TOLERANCE;
|
||||
}
|
||||
|
||||
/** Orientation en toutes lettres, pour accompagner le ratio. */
|
||||
export function orientationLabel(info: ImageInfo): string {
|
||||
if (!isValid(info)) return "";
|
||||
if (isSquare(info)) return "carré";
|
||||
return info.width > info.height ? "paysage" : "portrait";
|
||||
}
|
||||
|
||||
/** « 1080 × 1350 px · portrait 4:5 · 420 Ko ». */
|
||||
export function imageSummary(info: ImageInfo): string {
|
||||
if (!isValid(info)) return "";
|
||||
const parts = [
|
||||
`${Math.round(info.width)} × ${Math.round(info.height)} px`,
|
||||
`${orientationLabel(info)} ${aspectLabel(info.width, info.height)}`.trim(),
|
||||
];
|
||||
const weight = formatBytes(info.bytes);
|
||||
if (weight) parts.push(weight);
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
export type ImageNote = { tone: "warn" | "info"; text: string };
|
||||
|
||||
/**
|
||||
* Remarques à afficher sous l'aperçu. Elles **informent**, elles ne bloquent
|
||||
* pas : un visuel non carré reste publiable, l'adhérent doit seulement savoir
|
||||
* ce que les réseaux en feront.
|
||||
*/
|
||||
export function imageNotes(info: ImageInfo): ImageNote[] {
|
||||
if (!isValid(info)) return [];
|
||||
const notes: ImageNote[] = [];
|
||||
|
||||
if (!isSquare(info)) {
|
||||
notes.push({
|
||||
tone: "warn",
|
||||
text:
|
||||
`Format ${aspectLabel(info.width, info.height)} : sur Facebook et LinkedIn, l'image sera ` +
|
||||
`recadrée ou entourée de bandes de couleur. Une image carrée (${IDEAL_SIDE} × ${IDEAL_SIDE} px) ` +
|
||||
`s'affiche entière.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (Math.min(info.width, info.height) < MIN_SIDE) {
|
||||
notes.push({
|
||||
tone: "warn",
|
||||
text: `Image de petite taille : elle paraîtra floue une fois agrandie. Visez ${IDEAL_SIDE} px de côté.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (info.bytes > MAX_BYTES) {
|
||||
notes.push({
|
||||
tone: "warn",
|
||||
text: `Fichier lourd (${formatBytes(info.bytes)}) : l'envoi peut être refusé. Compressez l'image avant de la déposer.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (notes.length === 0) {
|
||||
notes.push({ tone: "info", text: "Format idéal : l'image s'affichera entière sur le site et sur les réseaux." });
|
||||
}
|
||||
|
||||
return notes;
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Mise en ligne d'une promotion : diffusion sur les réseaux et libération des
|
||||
* publications programmées.
|
||||
*
|
||||
* Ce module vit hors de `backend/actions.ts` parce qu'il a deux appelants : la
|
||||
* validation par un modérateur (une requête HTTP) et le libérateur périodique
|
||||
* démarré par `instrumentation.ts` (aucune requête). Il ne fait donc jamais
|
||||
* appel à `revalidatePath()`, qui n'a pas de sens hors requête ; les pages
|
||||
* publiques sont en `force-dynamic` et relisent la base à chaque affichage.
|
||||
*/
|
||||
|
||||
import { and, eq, isNotNull, lte, sql } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { members, promotions, socialPosts } from "@/db/schema";
|
||||
import { logActivity } from "@/lib/activity-log";
|
||||
import {
|
||||
isNetworkConfigured,
|
||||
publishPromoToNetwork,
|
||||
SOCIAL_LABELS,
|
||||
SOCIAL_NETWORKS,
|
||||
type SocialNetwork,
|
||||
} from "@/lib/social";
|
||||
|
||||
/** Réseaux demandés pour une promo, dans l'ordre d'affichage. */
|
||||
export function requestedNetworks(promo: {
|
||||
shareFacebook: boolean;
|
||||
shareLinkedin: boolean;
|
||||
}): SocialNetwork[] {
|
||||
return SOCIAL_NETWORKS.filter((n) =>
|
||||
n === "facebook" ? promo.shareFacebook : promo.shareLinkedin
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Publie une promotion sur les réseaux demandés. Ne lève jamais : un réseau
|
||||
* indisponible ne doit pas faire échouer la mise en ligne, l'échec est
|
||||
* enregistré dans `social_posts` et rattrapable depuis le backoffice.
|
||||
*
|
||||
* Un réseau déjà publié avec succès est ignoré : c'est ce qui empêche toute
|
||||
* republication, y compris sur un cycle suspension → remise en ligne, et ce
|
||||
* qui rend le libérateur périodique sans danger s'il repasse sur une promo.
|
||||
*/
|
||||
export async function publishPromoShares(
|
||||
promoId: number,
|
||||
networks: SocialNetwork[],
|
||||
userId: number | null
|
||||
) {
|
||||
if (networks.length === 0) return;
|
||||
|
||||
const [promo] = await db
|
||||
.select({
|
||||
title: promotions.title,
|
||||
text: promotions.text,
|
||||
badge: promotions.badge,
|
||||
validUntil: promotions.validUntil,
|
||||
startsOn: promotions.startsOn,
|
||||
endsOn: promotions.endsOn,
|
||||
imageUrl: promotions.imageUrl,
|
||||
memberId: promotions.memberId,
|
||||
memberName: members.name,
|
||||
memberCity: members.city,
|
||||
})
|
||||
.from(promotions)
|
||||
.leftJoin(members, eq(promotions.memberId, members.id))
|
||||
.where(eq(promotions.id, promoId));
|
||||
if (!promo) return;
|
||||
|
||||
const done = await db
|
||||
.select({ network: socialPosts.network })
|
||||
.from(socialPosts)
|
||||
.where(and(eq(socialPosts.promotionId, promoId), eq(socialPosts.status, "posted")));
|
||||
const alreadyPosted = new Set(done.map((d) => d.network));
|
||||
|
||||
for (const network of networks) {
|
||||
if (alreadyPosted.has(network)) continue;
|
||||
|
||||
if (!(await isNetworkConfigured(network))) {
|
||||
await db.insert(socialPosts).values({
|
||||
promotionId: promoId,
|
||||
network,
|
||||
status: "failed",
|
||||
error: `${SOCIAL_LABELS[network]} n'est pas configuré sur ce serveur.`,
|
||||
postedById: userId,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await publishPromoToNetwork(network, promo);
|
||||
await db.insert(socialPosts).values({
|
||||
promotionId: promoId,
|
||||
network,
|
||||
status: "posted",
|
||||
externalId: result.externalId,
|
||||
url: result.url,
|
||||
postedById: userId,
|
||||
});
|
||||
await logActivity(
|
||||
`Promotion « ${promo.title} » publiée sur ${SOCIAL_LABELS[network]}`,
|
||||
"#2C6FB3"
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Erreur inconnue";
|
||||
await db.insert(socialPosts).values({
|
||||
promotionId: promoId,
|
||||
network,
|
||||
status: "failed",
|
||||
error: message.slice(0, 2000),
|
||||
postedById: userId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Met en ligne les promotions programmées dont l'échéance est passée.
|
||||
*
|
||||
* La bascule `scheduled → live` est faite par un `UPDATE … RETURNING` filtré
|
||||
* sur le statut : deux instances de l'application qui tourneraient en même
|
||||
* temps ne peuvent pas récupérer la même ligne, chacune n'obtient que celles
|
||||
* qu'elle a effectivement changées. La diffusion réseaux suit, protégée en
|
||||
* plus par la garde anti-republication de `publishPromoShares`.
|
||||
*
|
||||
* Ne lève jamais : le libérateur tourne en tâche de fond, une erreur ne doit
|
||||
* pas arrêter la boucle.
|
||||
*/
|
||||
export async function releaseDuePromotions(now = new Date()): Promise<number> {
|
||||
let released: { id: number; title: string; memberId: number | null; shareFacebook: boolean; shareLinkedin: boolean }[];
|
||||
try {
|
||||
released = await db
|
||||
.update(promotions)
|
||||
.set({ status: "live", publishAt: null })
|
||||
.where(
|
||||
and(
|
||||
eq(promotions.status, "scheduled"),
|
||||
isNotNull(promotions.publishAt),
|
||||
lte(promotions.publishAt, now)
|
||||
)
|
||||
)
|
||||
.returning({
|
||||
id: promotions.id,
|
||||
title: promotions.title,
|
||||
memberId: promotions.memberId,
|
||||
shareFacebook: promotions.shareFacebook,
|
||||
shareLinkedin: promotions.shareLinkedin,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[promotions] libération programmée impossible :", error);
|
||||
return 0;
|
||||
}
|
||||
|
||||
for (const promo of released) {
|
||||
try {
|
||||
await logActivity(
|
||||
`Promotion « ${promo.title} » publiée à l'heure programmée`,
|
||||
"#1f8a5b"
|
||||
);
|
||||
// `null` : la publication n'est déclenchée par personne, c'est l'échéance.
|
||||
await publishPromoShares(promo.id, requestedNetworks(promo), null);
|
||||
} catch (error) {
|
||||
console.error(`[promotions] publication programmée #${promo.id} :`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return released.length;
|
||||
}
|
||||
|
||||
/** Nombre de promotions encore en attente de leur date de publication. */
|
||||
export async function countScheduledPromotions(): Promise<number> {
|
||||
const [row] = await db
|
||||
.select({ n: sql<number>`count(*)::int` })
|
||||
.from(promotions)
|
||||
.where(eq(promotions.status, "scheduled"));
|
||||
return row?.n ?? 0;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Programmation de la publication d'une promotion.
|
||||
*
|
||||
* Une promotion validée peut soit partir tout de suite, soit attendre une
|
||||
* date choisie : c'est `promotions.publish_at`. Tant que cette date n'est pas
|
||||
* atteinte la promotion reste au statut `scheduled` — invisible sur le site
|
||||
* public (les lectures filtrent `status = 'live'`) et non diffusée sur les
|
||||
* réseaux. Le libérateur (`releaseDuePromotions`) fait la bascule.
|
||||
*
|
||||
* Ce module est **pur** : aucune base, aucun réseau. Il ne s'occupe que de la
|
||||
* conversion et du rendu des dates, et il est verrouillé par
|
||||
* `tests/promo-schedule.test.ts`.
|
||||
*
|
||||
* Le fuseau est celui de l'association, pas celui du serveur : un conteneur
|
||||
* qui tourne en UTC ne doit pas décaler d'une heure la publication demandée
|
||||
* par un adhérent lorrain.
|
||||
*/
|
||||
|
||||
export const ASSOCIATION_TIME_ZONE = "Europe/Paris";
|
||||
|
||||
const LOCAL_DATETIME = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::\d{2})?$/;
|
||||
|
||||
/**
|
||||
* Décalage du fuseau de l'association à un instant donné, en millisecondes.
|
||||
* Passe par `Intl` plutôt que par une constante : l'heure d'été change deux
|
||||
* fois par an et une valeur figée publierait à la mauvaise heure la moitié de
|
||||
* l'année.
|
||||
*/
|
||||
function zoneOffsetMs(instant: Date): number {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: ASSOCIATION_TIME_ZONE,
|
||||
hour12: false,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
}).formatToParts(instant);
|
||||
const get = (type: string) => Number(parts.find((p) => p.type === type)?.value ?? "0");
|
||||
// `hour` vaut 24 à minuit avec hour12:false sur certaines plateformes.
|
||||
const hour = get("hour") % 24;
|
||||
const asIfUtc = Date.UTC(get("year"), get("month") - 1, get("day"), hour, get("minute"), get("second"));
|
||||
return asIfUtc - instant.getTime();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit la saisie d'un `<input type="datetime-local">` (heure locale de
|
||||
* l'association) en instant absolu.
|
||||
*
|
||||
* Renvoie `null` si le champ est vide, et lève si la saisie n'a pas la forme
|
||||
* attendue : une date illisible ne doit pas se transformer silencieusement en
|
||||
* publication immédiate.
|
||||
*/
|
||||
export function parseScheduleInput(value: string | null | undefined): Date | null {
|
||||
const raw = (value ?? "").trim();
|
||||
if (!raw) return null;
|
||||
|
||||
const m = LOCAL_DATETIME.exec(raw);
|
||||
if (!m) throw new Error("Date de programmation invalide.");
|
||||
const [, y, mo, d, h, mi] = m.map(Number) as unknown as number[];
|
||||
|
||||
// `Date.UTC` accepte 13 comme mois ou 99 comme heure en débordant sur la
|
||||
// date suivante : on refuse plutôt que de publier à un moment inattendu.
|
||||
if (mo < 1 || mo > 12 || d < 1 || d > 31 || h > 23 || mi > 59) {
|
||||
throw new Error("Date de programmation invalide.");
|
||||
}
|
||||
|
||||
const naive = Date.UTC(y, mo - 1, d, h, mi);
|
||||
if (new Date(naive).getUTCDate() !== d) throw new Error("Date de programmation invalide.");
|
||||
// Deux passes : la première donne un décalage approché, la seconde le corrige
|
||||
// quand la date tombe juste après un changement d'heure.
|
||||
const first = new Date(naive - zoneOffsetMs(new Date(naive)));
|
||||
const exact = new Date(naive - zoneOffsetMs(first));
|
||||
if (Number.isNaN(exact.getTime())) throw new Error("Date de programmation invalide.");
|
||||
return exact;
|
||||
}
|
||||
|
||||
/** Valeur à remettre dans un `<input type="datetime-local">`. */
|
||||
export function toScheduleInput(date: Date | null | undefined): string {
|
||||
if (!date || Number.isNaN(date.getTime())) return "";
|
||||
const local = new Date(date.getTime() + zoneOffsetMs(date));
|
||||
return local.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
/** Une programmation passée n'a plus lieu d'être : on publie tout de suite. */
|
||||
export function isDue(publishAt: Date | null | undefined, now = new Date()): boolean {
|
||||
return !publishAt || publishAt.getTime() <= now.getTime();
|
||||
}
|
||||
|
||||
/** `true` si la promotion doit attendre. */
|
||||
export function isPending(publishAt: Date | null | undefined, now = new Date()): boolean {
|
||||
return !isDue(publishAt, now);
|
||||
}
|
||||
|
||||
/** « le 12 mars 2027 à 09:00 » — sans majuscule, à insérer dans une phrase. */
|
||||
export function formatSchedule(publishAt: Date | null | undefined): string | null {
|
||||
if (!publishAt || Number.isNaN(publishAt.getTime())) return null;
|
||||
const day = new Intl.DateTimeFormat("fr-FR", {
|
||||
timeZone: ASSOCIATION_TIME_ZONE,
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
}).format(publishAt);
|
||||
const time = new Intl.DateTimeFormat("fr-FR", {
|
||||
timeZone: ASSOCIATION_TIME_ZONE,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(publishAt);
|
||||
return `le ${day} à ${time}`;
|
||||
}
|
||||
|
||||
/** Phrase complète pour le backoffice. */
|
||||
export function scheduleLabel(publishAt: Date | null | undefined): string | null {
|
||||
const when = formatSchedule(publishAt);
|
||||
return when ? `Publication programmée ${when}` : null;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Période de validité d'une promotion.
|
||||
*
|
||||
* Module **pur** : aucune dépendance à la base ni à React, pour être verrouillé
|
||||
* par les tests. Les dates arrivent en `YYYY-MM-DD` (colonnes `date` de
|
||||
* Postgres, lues telles quelles par Drizzle) et sont donc traitées comme des
|
||||
* jours calendaires, sans fuseau horaire.
|
||||
*/
|
||||
|
||||
export type PromoValidity = {
|
||||
startsOn?: string | null;
|
||||
endsOn?: string | null;
|
||||
/** Texte libre des anciennes promotions, utilisé à défaut de dates. */
|
||||
validUntil?: string | null;
|
||||
};
|
||||
|
||||
const MONTHS = [
|
||||
"janvier", "février", "mars", "avril", "mai", "juin",
|
||||
"juillet", "août", "septembre", "octobre", "novembre", "décembre",
|
||||
];
|
||||
|
||||
type Parts = { day: number; month: number; year: number };
|
||||
|
||||
function parse(value: string | null | undefined): Parts | null {
|
||||
if (!value) return null;
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(value.trim());
|
||||
if (!match) return null;
|
||||
const [, year, month, day] = match;
|
||||
const parts = { day: Number(day), month: Number(month) - 1, year: Number(year) };
|
||||
if (parts.month < 0 || parts.month > 11 || parts.day < 1 || parts.day > 31) return null;
|
||||
return parts;
|
||||
}
|
||||
|
||||
/** « 1er » et non « 1 », comme on l'écrit en français. */
|
||||
function dayLabel(day: number): string {
|
||||
return day === 1 ? "1er" : String(day);
|
||||
}
|
||||
|
||||
function full(p: Parts): string {
|
||||
return `${dayLabel(p.day)} ${MONTHS[p.month]} ${p.year}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Phrase de validité, ou `null` si la promotion n'en a pas.
|
||||
* L'année et le mois ne sont répétés que lorsqu'ils changent : « du 1er au
|
||||
* 15 mars 2027 » plutôt que « du 1er mars 2027 au 15 mars 2027 ».
|
||||
*/
|
||||
export function formatValidity(promo: PromoValidity): string | null {
|
||||
const start = parse(promo.startsOn);
|
||||
const end = parse(promo.endsOn);
|
||||
|
||||
if (start && end) {
|
||||
const sameYear = start.year === end.year;
|
||||
const sameMonth = sameYear && start.month === end.month;
|
||||
const from = sameMonth
|
||||
? dayLabel(start.day)
|
||||
: sameYear
|
||||
? `${dayLabel(start.day)} ${MONTHS[start.month]}`
|
||||
: full(start);
|
||||
return `Valable du ${from} au ${full(end)}`;
|
||||
}
|
||||
if (end) return `Valable jusqu'au ${full(end)}`;
|
||||
if (start) return `Valable à partir du ${full(start)}`;
|
||||
|
||||
const legacy = promo.validUntil?.trim();
|
||||
return legacy || null;
|
||||
}
|
||||
|
||||
/** Variante sans le mot « Valable », pour les libellés déjà introduits. */
|
||||
export function formatValidityShort(promo: PromoValidity): string | null {
|
||||
const formatted = formatValidity(promo);
|
||||
if (!formatted) return null;
|
||||
return formatted.replace(/^Valable /, "");
|
||||
}
|
||||
|
||||
/** `true` si la date de fin est dépassée. Un jour de fin reste valable en entier. */
|
||||
export function isExpired(promo: PromoValidity, today = new Date()): boolean {
|
||||
const end = parse(promo.endsOn);
|
||||
if (!end) return false;
|
||||
const endOfDay = Date.UTC(end.year, end.month, end.day, 23, 59, 59);
|
||||
return today.getTime() > endOfDay;
|
||||
}
|
||||
|
||||
/** Bornes invalides : fin antérieure au début. */
|
||||
export function isRangeInvalid(promo: PromoValidity): boolean {
|
||||
const start = parse(promo.startsOn);
|
||||
const end = parse(promo.endsOn);
|
||||
if (!start || !end) return false;
|
||||
return Date.UTC(end.year, end.month, end.day) < Date.UTC(start.year, start.month, start.day);
|
||||
}
|
||||
|
||||
/** `true` si la période n'a pas encore commencé. */
|
||||
export function isNotStarted(promo: PromoValidity, today = new Date()): boolean {
|
||||
const start = parse(promo.startsOn);
|
||||
if (!start) return false;
|
||||
return today.getTime() < Date.UTC(start.year, start.month, start.day);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pourquoi une promotion validée n'apparaît pas sur le site aujourd'hui.
|
||||
*
|
||||
* Les lectures publiques (`src/lib/queries.ts`) écartent les offres hors
|
||||
* période ; ce message l'explique côté backoffice, pour qu'un adhérent ne
|
||||
* cherche pas une offre « en ligne » qui n'est pas encore, ou plus, affichée.
|
||||
* Renvoie `null` quand la promotion est bien visible.
|
||||
*/
|
||||
export function visibilityNote(promo: PromoValidity, today = new Date()): string | null {
|
||||
const start = parse(promo.startsOn);
|
||||
const end = parse(promo.endsOn);
|
||||
if (start && isNotStarted(promo, today)) {
|
||||
return `Pas encore affichée : elle apparaîtra sur le site le ${full(start)}.`;
|
||||
}
|
||||
if (end && isExpired(promo, today)) {
|
||||
return `Période terminée le ${full(end)} : elle n'apparaît plus sur le site.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
+83
-8
@@ -1,6 +1,22 @@
|
||||
import { and, asc, count, desc, eq } from "drizzle-orm";
|
||||
import { and, asc, count, desc, eq, sql } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
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
|
||||
* de validité.
|
||||
*
|
||||
* Les deux bornes sont facultatives — une offre sans date reste affichée sans
|
||||
* limite. La journée de référence est celle de l'association, pas celle du
|
||||
* serveur : un conteneur en UTC retirerait sinon une offre « valable jusqu'au
|
||||
* 30 juin » deux heures trop tôt.
|
||||
*/
|
||||
const VISIBLE_PROMO = and(
|
||||
eq(promotions.status, "live"),
|
||||
sql`(${promotions.startsOn} is null or ${promotions.startsOn} <= (current_timestamp at time zone 'Europe/Paris')::date)`,
|
||||
sql`(${promotions.endsOn} is null or ${promotions.endsOn} >= (current_timestamp at time zone 'Europe/Paris')::date)`
|
||||
);
|
||||
|
||||
export async function getActiveMembersWithCategory() {
|
||||
return db
|
||||
@@ -32,7 +48,7 @@ export async function getLiveBadgesByMember() {
|
||||
return db
|
||||
.select({ memberId: promotions.memberId, badge: promotions.badge })
|
||||
.from(promotions)
|
||||
.where(eq(promotions.status, "live"));
|
||||
.where(VISIBLE_PROMO);
|
||||
}
|
||||
|
||||
export async function getCategoriesWithCounts(limit = 6) {
|
||||
@@ -57,8 +73,14 @@ export async function getCategoriesWithCounts(limit = 6) {
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function getLivePromotions(limit = 6) {
|
||||
const rows = await db
|
||||
/**
|
||||
* Promotions visibles, de la plus récente à la plus ancienne.
|
||||
*
|
||||
* `limit` à `null` : toutes les offres en cours (page `/promotions`) ; sinon
|
||||
* les N dernières (accueil).
|
||||
*/
|
||||
export async function getLivePromotions(limit: number | null = 6) {
|
||||
const query = db
|
||||
.select({
|
||||
id: promotions.id,
|
||||
title: promotions.title,
|
||||
@@ -67,16 +89,21 @@ export async function getLivePromotions(limit = 6) {
|
||||
badge: promotions.badge,
|
||||
imageUrl: promotions.imageUrl,
|
||||
validUntil: promotions.validUntil,
|
||||
startsOn: promotions.startsOn,
|
||||
endsOn: promotions.endsOn,
|
||||
memberId: promotions.memberId,
|
||||
memberName: members.name,
|
||||
memberCity: members.city,
|
||||
memberLogoUrl: members.logoUrl,
|
||||
})
|
||||
.from(promotions)
|
||||
.leftJoin(members, eq(promotions.memberId, members.id))
|
||||
.where(eq(promotions.status, "live"))
|
||||
.where(VISIBLE_PROMO)
|
||||
.orderBy(desc(promotions.createdAt))
|
||||
.limit(limit);
|
||||
return rows;
|
||||
.$dynamic();
|
||||
// La limite reste dans le SQL : la page « toutes les promos » est la seule à
|
||||
// tout demander, l'accueil ne rapatrie que ses six cartes.
|
||||
return limit === null ? query : query.limit(limit);
|
||||
}
|
||||
|
||||
export async function getPublicMember(id: number) {
|
||||
@@ -109,6 +136,46 @@ export async function getPublicMember(id: number) {
|
||||
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) {
|
||||
return db
|
||||
.select({
|
||||
@@ -119,9 +186,11 @@ export async function getMemberLivePromotions(memberId: number) {
|
||||
badge: promotions.badge,
|
||||
imageUrl: promotions.imageUrl,
|
||||
validUntil: promotions.validUntil,
|
||||
startsOn: promotions.startsOn,
|
||||
endsOn: promotions.endsOn,
|
||||
})
|
||||
.from(promotions)
|
||||
.where(and(eq(promotions.memberId, memberId), eq(promotions.status, "live")))
|
||||
.where(and(eq(promotions.memberId, memberId), VISIBLE_PROMO))
|
||||
.orderBy(desc(promotions.createdAt));
|
||||
}
|
||||
|
||||
@@ -218,3 +287,9 @@ export async function getCategoriesInUse() {
|
||||
const rows = await getCategoriesWithCounts(1000);
|
||||
return rows.filter((c) => Number(c.memberCount) > 0);
|
||||
}
|
||||
|
||||
/** Promotions réellement affichées aujourd'hui — le chiffre du tableau de bord. */
|
||||
export async function countVisiblePromotions(): Promise<number> {
|
||||
const [row] = await db.select({ n: count() }).from(promotions).where(VISIBLE_PROMO);
|
||||
return row?.n ?? 0;
|
||||
}
|
||||
+4
-19
@@ -1,28 +1,13 @@
|
||||
import { headers } from "next/headers";
|
||||
import { siteUrl } from "@/lib/social-accounts";
|
||||
import { publicBaseUrl } from "@/lib/social-accounts";
|
||||
|
||||
/**
|
||||
* URL publique du site pour le référencement (canoniques, sitemap, JSON-LD).
|
||||
*
|
||||
* Priorité au réglage `site_public_url` (Backend › Réseaux sociaux) puis aux
|
||||
* variables d'environnement, via `siteUrl()`. En dernier recours, l'URL est
|
||||
* déduite des en-têtes de la requête (reverse proxy compris) : le site reste
|
||||
* ainsi correctement référencé tant que le réglage n'est pas renseigné.
|
||||
* variables d'environnement ; en dernier recours, l'URL est déduite des
|
||||
* en-têtes de la requête (reverse proxy compris). Voir `publicBaseUrl()`.
|
||||
*/
|
||||
export async function publicBaseUrl(): Promise<string> {
|
||||
const configured = await siteUrl().catch(() => "");
|
||||
if (configured) return configured;
|
||||
try {
|
||||
const h = await headers();
|
||||
const host = h.get("x-forwarded-host") ?? h.get("host") ?? "";
|
||||
if (!host) return "";
|
||||
const protocol = h.get("x-forwarded-proto") ?? (host.startsWith("localhost") ? "http" : "https");
|
||||
return `${protocol}://${host}`.replace(/\/+$/, "");
|
||||
} catch {
|
||||
// Hors requête (génération de robots/sitemap au build par exemple).
|
||||
return "";
|
||||
}
|
||||
}
|
||||
export { publicBaseUrl };
|
||||
|
||||
/** `metadataBase` pour Next : indéfini si aucune URL n'est connue. */
|
||||
export async function metadataBase(): Promise<URL | undefined> {
|
||||
|
||||
@@ -71,6 +71,7 @@ export const PRIVATE_PATHS = ["/backend", "/api", "/login", "/inscription"];
|
||||
export const STATIC_ROUTES: { path: string; priority: number; changeFrequency: "daily" | "weekly" | "monthly" | "yearly" }[] = [
|
||||
{ path: "/", priority: 1, changeFrequency: "daily" },
|
||||
{ path: "/annuaire", priority: 0.9, changeFrequency: "daily" },
|
||||
{ path: "/promotions", priority: 0.9, changeFrequency: "daily" },
|
||||
{ path: "/association", priority: 0.8, changeFrequency: "weekly" },
|
||||
{ path: "/rencontres-passees", priority: 0.6, changeFrequency: "monthly" },
|
||||
{ path: "/mentions-legales", priority: 0.2, changeFrequency: "yearly" },
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Déduction de l'origine publique du site à partir des en-têtes d'une requête.
|
||||
*
|
||||
* Sans dépendance à la base ni à Next : utilisable partout et testable. Sert de
|
||||
* repli quand aucune URL publique n'est enregistrée dans Backend › Réseaux
|
||||
* sociaux : derrière Caddy ou Nginx Proxy Manager, `X-Forwarded-Host` et
|
||||
* `X-Forwarded-Proto` décrivent l'adresse vue par le visiteur.
|
||||
*/
|
||||
|
||||
type HeaderReader = { get(name: string): string | null };
|
||||
|
||||
const LOCAL_HOSTS = /^(localhost|.*\.localhost|127(?:\.\d{1,3}){3}|0\.0\.0\.0|\[::1\]|\[::\])$/i;
|
||||
const IPV4 = /^\d{1,3}(?:\.\d{1,3}){3}$/;
|
||||
|
||||
function first(value: string | null): string {
|
||||
return (value ?? "").split(",")[0]?.trim() ?? "";
|
||||
}
|
||||
|
||||
/** Nom d'hôte sans port, en minuscules. */
|
||||
function hostname(host: string): string {
|
||||
const match = /^(\[[^\]]*\]|[^:]+)/.exec(host);
|
||||
return (match?.[1] ?? host).toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sans `X-Forwarded-Proto`, on présume HTTPS pour un nom de domaine — c'est le
|
||||
* cas de tout déploiement réel — et HTTP pour une adresse locale ou une IP nue,
|
||||
* qu'aucun certificat ne couvre.
|
||||
*/
|
||||
export function defaultProtocol(host: string): "http" | "https" {
|
||||
const name = hostname(host);
|
||||
if (!name || LOCAL_HOSTS.test(name) || IPV4.test(name)) return "http";
|
||||
return "https";
|
||||
}
|
||||
|
||||
/**
|
||||
* Origine (`https://pleinr.example.fr`) vue par le visiteur, ou chaîne vide si
|
||||
* la requête ne porte aucun hôte.
|
||||
*/
|
||||
export function originFromHeaders(headers: HeaderReader): string {
|
||||
const host = first(headers.get("x-forwarded-host")) || first(headers.get("host"));
|
||||
if (!host || /[\s/\\]/.test(host)) return "";
|
||||
const forwarded = first(headers.get("x-forwarded-proto")).toLowerCase();
|
||||
const protocol = forwarded === "https" || forwarded === "http" ? forwarded : defaultProtocol(host);
|
||||
try {
|
||||
return new URL(`${protocol}://${host}`).origin;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
+110
-1
@@ -1,7 +1,9 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { headers } from "next/headers";
|
||||
import { db } from "@/db";
|
||||
import { siteSettings, socialAccounts, type SocialAccount, type SocialNetwork } from "@/db/schema";
|
||||
import { decryptSecret, encryptSecret } from "./crypto";
|
||||
import { originFromHeaders } from "./site-url";
|
||||
|
||||
/**
|
||||
* Configuration des comptes Facebook / LinkedIn.
|
||||
@@ -43,8 +45,29 @@ export async function siteUrl(): Promise<string> {
|
||||
return (stored || env("NEXT_PUBLIC_SITE_URL") || env("AUTH_URL")).replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* URL publique effective : le réglage s'il existe, sinon l'adresse par laquelle
|
||||
* la requête en cours est arrivée (en-têtes du reverse proxy compris). Chaîne
|
||||
* vide hors requête si rien n'est configuré.
|
||||
*/
|
||||
export async function publicBaseUrl(): Promise<string> {
|
||||
const configured = await siteUrl().catch(() => "");
|
||||
if (configured) return configured;
|
||||
try {
|
||||
return originFromHeaders(await headers());
|
||||
} catch {
|
||||
// Hors requête (génération de robots/sitemap au build par exemple).
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adresse de retour OAuth. Elle doit être identique au clic sur « Connecter »
|
||||
* et au retour du réseau : les deux passent par le même hôte, la déduction
|
||||
* depuis la requête est donc stable.
|
||||
*/
|
||||
export async function redirectUri(network: SocialNetwork): Promise<string> {
|
||||
return `${await siteUrl()}/api/social/${network}/callback`;
|
||||
return `${await publicBaseUrl()}/api/social/${network}/callback`;
|
||||
}
|
||||
|
||||
// ---- Lecture ----
|
||||
@@ -118,6 +141,92 @@ export function expiryStatus(expiresAt: Date | null | undefined): ExpiryStatus {
|
||||
return remainingMs <= EXPIRY_WARNING_DAYS * 86_400_000 ? "soon" : "ok";
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie que le jeton fonctionne encore, en interrogeant la plateforme.
|
||||
*
|
||||
* « N'expire pas » ne veut pas dire « ne meurt jamais » : un jeton de page
|
||||
* Facebook est invalidé si le mot de passe du compte connecté change, si ce
|
||||
* compte perd son rôle d'administrateur de la page, si l'autorisation est
|
||||
* retirée, ou si Meta le révoque. Sans cette vérification, on ne l'apprend
|
||||
* qu'au moment où une publication échoue.
|
||||
*/
|
||||
export type TokenHealth =
|
||||
| { ok: true; detail: string }
|
||||
| { ok: false; reason: string };
|
||||
|
||||
export async function checkTokenHealth(network: SocialNetwork): Promise<TokenHealth | null> {
|
||||
const health = await probeToken(network);
|
||||
if (health) await rememberCheck(network, health);
|
||||
return health;
|
||||
}
|
||||
|
||||
/** Contrôle mis en cache : évite un appel réseau à chaque affichage. */
|
||||
export async function tokenHealthCached(
|
||||
network: SocialNetwork,
|
||||
maxAgeMs = 6 * 3_600_000
|
||||
): Promise<TokenHealth | null> {
|
||||
const account = await getSocialAccount(network);
|
||||
if (!account?.accessToken || !account.targetId) return null;
|
||||
|
||||
const fresh =
|
||||
account.lastCheckAt && Date.now() - new Date(account.lastCheckAt).getTime() < maxAgeMs;
|
||||
if (fresh && account.lastCheckOk !== null) {
|
||||
return account.lastCheckOk
|
||||
? { ok: true, detail: "Vérifié récemment" }
|
||||
: { ok: false, reason: account.lastCheckError ?? "Jeton refusé" };
|
||||
}
|
||||
return checkTokenHealth(network);
|
||||
}
|
||||
|
||||
async function rememberCheck(network: SocialNetwork, health: TokenHealth) {
|
||||
await db
|
||||
.update(socialAccounts)
|
||||
.set({
|
||||
lastCheckAt: new Date(),
|
||||
lastCheckOk: health.ok,
|
||||
lastCheckError: health.ok ? null : health.reason.slice(0, 2000),
|
||||
})
|
||||
.where(eq(socialAccounts.network, network));
|
||||
}
|
||||
|
||||
async function probeToken(network: SocialNetwork): Promise<TokenHealth | null> {
|
||||
const credentials = await resolveCredentials(network);
|
||||
if (!credentials) return null;
|
||||
|
||||
try {
|
||||
if (network === "facebook") {
|
||||
// Un jeton de page répond `/me` avec la page elle-même.
|
||||
const res = await fetch(
|
||||
`https://graph.facebook.com/${FACEBOOK_GRAPH_VERSION}/me?${new URLSearchParams({
|
||||
fields: "id,name",
|
||||
access_token: credentials.accessToken,
|
||||
})}`
|
||||
);
|
||||
if (!res.ok) return { ok: false, reason: await readError(res) };
|
||||
const page = (await res.json()) as { id?: string; name?: string };
|
||||
if (page.id && credentials.targetId && page.id !== credentials.targetId) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `Le jeton ne pointe plus vers la bonne page (${page.name ?? page.id}).`,
|
||||
};
|
||||
}
|
||||
return { ok: true, detail: page.name ?? "Page accessible" };
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
"https://api.linkedin.com/rest/organizationAcls?q=roleAssignee&role=ADMINISTRATOR&state=APPROVED&count=1",
|
||||
{ headers: linkedinHeaders(credentials.accessToken) }
|
||||
);
|
||||
if (!res.ok) return { ok: false, reason: await readError(res) };
|
||||
return { ok: true, detail: "Accès à la page confirmé" };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: error instanceof Error ? error.message : "Plateforme injoignable",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ---- OAuth ----
|
||||
|
||||
export class SocialAuthError extends Error {}
|
||||
|
||||
+9
-4
@@ -12,9 +12,10 @@
|
||||
import {
|
||||
refreshLinkedInIfNeeded,
|
||||
resolveCredentials,
|
||||
siteUrl,
|
||||
publicBaseUrl,
|
||||
type SocialNetwork,
|
||||
} from "./social-accounts";
|
||||
import { formatValidity } from "./promo-validity";
|
||||
import { memberPath } from "./seo";
|
||||
|
||||
export {
|
||||
@@ -35,6 +36,8 @@ export type PromoForSharing = {
|
||||
text: string | null;
|
||||
badge: string | null;
|
||||
validUntil: string | null;
|
||||
startsOn?: string | null;
|
||||
endsOn?: string | null;
|
||||
imageUrl: string | null;
|
||||
memberId: number | null;
|
||||
memberName: string | null;
|
||||
@@ -42,11 +45,11 @@ export type PromoForSharing = {
|
||||
};
|
||||
|
||||
export async function promoLink(promo: PromoForSharing): Promise<string | null> {
|
||||
const base = await siteUrl();
|
||||
const base = await publicBaseUrl();
|
||||
if (!base) return null;
|
||||
return promo.memberId
|
||||
? `${base}${memberPath({ id: promo.memberId, name: promo.memberName ?? "", city: promo.memberCity })}`
|
||||
: `${base}/#promotions`;
|
||||
: `${base}/promotions`;
|
||||
}
|
||||
|
||||
/** Texte du post, commun aux deux réseaux. */
|
||||
@@ -55,7 +58,9 @@ export async function buildPromoMessage(promo: PromoForSharing): Promise<string>
|
||||
lines.push(promo.badge ? `${promo.badge} — ${promo.title}` : promo.title);
|
||||
if (promo.memberName) lines.push(`Chez ${promo.memberName}`);
|
||||
if (promo.text?.trim()) lines.push("", promo.text.trim());
|
||||
if (promo.validUntil?.trim()) lines.push("", promo.validUntil.trim());
|
||||
// « Valable du … au … » : la même phrase que sur la carte du site.
|
||||
const validity = formatValidity(promo);
|
||||
if (validity) lines.push("", validity);
|
||||
const link = await promoLink(promo);
|
||||
if (link) lines.push("", link);
|
||||
lines.push("", "#PleinR #BassinDePompey #CommerceLocal");
|
||||
|
||||
+11
-4
@@ -18,7 +18,7 @@ const { auth } = NextAuth(authConfig);
|
||||
* `style` et sur la feuille Google Fonts. C'est un compromis assumé — une
|
||||
* injection de style est sans commune mesure avec une injection de script.
|
||||
*/
|
||||
function contentSecurityPolicy(nonce: string, isDev: boolean): string {
|
||||
function contentSecurityPolicy(nonce: string, isDev: boolean, isHttps: boolean): string {
|
||||
return [
|
||||
"default-src 'self'",
|
||||
"base-uri 'self'",
|
||||
@@ -35,14 +35,21 @@ function contentSecurityPolicy(nonce: string, isDev: boolean): string {
|
||||
"connect-src 'self'",
|
||||
// Carte « Nous situer » de la fiche adhérent.
|
||||
"frame-src https://www.google.com https://maps.google.com",
|
||||
"upgrade-insecure-requests",
|
||||
].join("; ");
|
||||
// Uniquement quand la page est servie en HTTPS : sur une page HTTP (test
|
||||
// par IP avant la pose du DNS, accès direct au port), le navigateur
|
||||
// basculerait toutes les navigations vers un https:// inexistant.
|
||||
isHttps ? "upgrade-insecure-requests" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
export default auth((request: NextRequest) => {
|
||||
// `btoa` et non `Buffer` : le middleware tourne sur le runtime Edge.
|
||||
const nonce = btoa(crypto.randomUUID());
|
||||
const csp = contentSecurityPolicy(nonce, process.env.NODE_ENV !== "production");
|
||||
const forwardedProto = request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim();
|
||||
const isHttps = forwardedProto ? forwardedProto === "https" : request.nextUrl.protocol === "https:";
|
||||
const csp = contentSecurityPolicy(nonce, process.env.NODE_ENV !== "production", isHttps);
|
||||
|
||||
// L'en-tête est posé sur la REQUÊTE : Next y lit le nonce pour l'appliquer à
|
||||
// ses balises <script>. Il est ensuite renvoyé sur la réponse au navigateur.
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import {
|
||||
aspectLabel,
|
||||
formatBytes,
|
||||
imageNotes,
|
||||
imageSummary,
|
||||
isSquare,
|
||||
orientationLabel,
|
||||
IDEAL_SIDE,
|
||||
} from "../src/lib/image-info";
|
||||
|
||||
describe("Poids d'un fichier", () => {
|
||||
it("passe des octets aux kilo-octets puis aux méga-octets", () => {
|
||||
assert.equal(formatBytes(640), "640 o");
|
||||
assert.equal(formatBytes(420_000), "420 Ko");
|
||||
assert.equal(formatBytes(1_200_000), "1,2 Mo");
|
||||
});
|
||||
|
||||
it("ne rend rien pour une valeur absurde", () => {
|
||||
assert.equal(formatBytes(-1), "");
|
||||
assert.equal(formatBytes(Number.NaN), "");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Format d'une image", () => {
|
||||
it("reconnaît les formats courants", () => {
|
||||
assert.equal(aspectLabel(1080, 1080), "1:1");
|
||||
assert.equal(aspectLabel(1080, 1350), "4:5");
|
||||
assert.equal(aspectLabel(1920, 1080), "16:9");
|
||||
});
|
||||
|
||||
it("tolère un écart de 2 % autour d'un format connu", () => {
|
||||
assert.equal(aspectLabel(1080, 1078), "1:1");
|
||||
assert.ok(isSquare({ width: 1080, height: 1078, bytes: 0 }));
|
||||
});
|
||||
|
||||
it("approche les formats inhabituels plutôt que de rendre une fraction illisible", () => {
|
||||
assert.equal(aspectLabel(1000, 730), "1,37:1");
|
||||
assert.equal(aspectLabel(730, 1000), "1:1,37");
|
||||
});
|
||||
|
||||
it("nomme l'orientation", () => {
|
||||
assert.equal(orientationLabel({ width: 1080, height: 1080, bytes: 0 }), "carré");
|
||||
assert.equal(orientationLabel({ width: 1080, height: 1350, bytes: 0 }), "portrait");
|
||||
assert.equal(orientationLabel({ width: 1350, height: 1080, bytes: 0 }), "paysage");
|
||||
});
|
||||
|
||||
it("résume dimensions, format et poids", () => {
|
||||
assert.equal(
|
||||
imageSummary({ width: 1080, height: 1350, bytes: 420_000 }),
|
||||
"1080 × 1350 px · portrait 4:5 · 420 Ko"
|
||||
);
|
||||
});
|
||||
|
||||
it("ne résume rien sans dimensions exploitables", () => {
|
||||
assert.equal(imageSummary({ width: 0, height: 0, bytes: 1000 }), "");
|
||||
assert.deepEqual(imageNotes({ width: 0, height: 0, bytes: 1000 }), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Remarques sur l'image déposée", () => {
|
||||
it("félicite une image carrée assez grande et assez légère", () => {
|
||||
const notes = imageNotes({ width: IDEAL_SIDE, height: IDEAL_SIDE, bytes: 300_000 });
|
||||
assert.equal(notes.length, 1);
|
||||
assert.equal(notes[0].tone, "info");
|
||||
});
|
||||
|
||||
it("prévient des bandes de couleur ajoutées par les réseaux hors du carré", () => {
|
||||
const notes = imageNotes({ width: 1080, height: 1350, bytes: 300_000 });
|
||||
assert.ok(notes.some((n) => n.tone === "warn" && /bandes de couleur/.test(n.text)));
|
||||
assert.ok(notes.some((n) => /4:5/.test(n.text)));
|
||||
});
|
||||
|
||||
it("signale une image trop petite", () => {
|
||||
const notes = imageNotes({ width: 400, height: 400, bytes: 60_000 });
|
||||
assert.ok(notes.some((n) => /floue/.test(n.text)));
|
||||
});
|
||||
|
||||
it("signale un fichier trop lourd", () => {
|
||||
const notes = imageNotes({ width: 1080, height: 1080, bytes: 3_000_000 });
|
||||
assert.ok(notes.some((n) => /Fichier lourd/.test(n.text)));
|
||||
});
|
||||
|
||||
it("cumule les remarques quand tout se ligue", () => {
|
||||
const notes = imageNotes({ width: 600, height: 900, bytes: 3_000_000 });
|
||||
assert.equal(notes.length, 3);
|
||||
assert.ok(notes.every((n) => n.tone === "warn"));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
formatSchedule,
|
||||
isDue,
|
||||
isPending,
|
||||
parseScheduleInput,
|
||||
scheduleLabel,
|
||||
toScheduleInput,
|
||||
} from "../src/lib/promo-schedule";
|
||||
|
||||
test("programmation — champ vide = publication immédiate", () => {
|
||||
assert.equal(parseScheduleInput(""), null);
|
||||
assert.equal(parseScheduleInput(null), null);
|
||||
assert.equal(parseScheduleInput(" "), null);
|
||||
assert.equal(isDue(null), true);
|
||||
});
|
||||
|
||||
test("programmation — heure d'hiver interprétée en heure de Paris (UTC+1)", () => {
|
||||
const d = parseScheduleInput("2027-01-15T09:00");
|
||||
assert.ok(d);
|
||||
assert.equal(d.toISOString(), "2027-01-15T08:00:00.000Z");
|
||||
});
|
||||
|
||||
test("programmation — heure d'été interprétée en heure de Paris (UTC+2)", () => {
|
||||
const d = parseScheduleInput("2027-07-15T09:00");
|
||||
assert.ok(d);
|
||||
assert.equal(d.toISOString(), "2027-07-15T07:00:00.000Z");
|
||||
});
|
||||
|
||||
test("programmation — minuit ne bascule pas d'un jour", () => {
|
||||
const d = parseScheduleInput("2027-03-10T00:00");
|
||||
assert.ok(d);
|
||||
assert.equal(d.toISOString(), "2027-03-09T23:00:00.000Z");
|
||||
});
|
||||
|
||||
test("programmation — aller-retour avec le champ du formulaire", () => {
|
||||
for (const value of ["2027-01-15T09:00", "2027-07-15T18:30", "2027-12-31T23:45"]) {
|
||||
assert.equal(toScheduleInput(parseScheduleInput(value)), value);
|
||||
}
|
||||
assert.equal(toScheduleInput(null), "");
|
||||
});
|
||||
|
||||
test("programmation — une saisie illisible est refusée, jamais ignorée", () => {
|
||||
assert.throws(() => parseScheduleInput("demain matin"));
|
||||
assert.throws(() => parseScheduleInput("2027-13-45T99:99"));
|
||||
assert.throws(() => parseScheduleInput("2027-01-15"));
|
||||
});
|
||||
|
||||
test("programmation — échéance atteinte", () => {
|
||||
const at = new Date("2027-01-15T08:00:00.000Z");
|
||||
assert.equal(isDue(at, new Date("2027-01-15T07:59:59.000Z")), false);
|
||||
assert.equal(isDue(at, at), true);
|
||||
assert.equal(isDue(at, new Date("2027-01-15T08:00:01.000Z")), true);
|
||||
assert.equal(isPending(at, new Date("2027-01-15T07:00:00.000Z")), true);
|
||||
});
|
||||
|
||||
test("programmation — libellé en heure de Paris", () => {
|
||||
assert.equal(formatSchedule(new Date("2027-01-15T08:00:00.000Z")), "le 15 janvier 2027 à 09:00");
|
||||
assert.equal(formatSchedule(new Date("2027-07-15T07:00:00.000Z")), "le 15 juillet 2027 à 09:00");
|
||||
assert.equal(formatSchedule(null), null);
|
||||
assert.equal(
|
||||
scheduleLabel(new Date("2027-01-15T08:00:00.000Z")),
|
||||
"Publication programmée le 15 janvier 2027 à 09:00"
|
||||
);
|
||||
assert.equal(scheduleLabel(null), null);
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import {
|
||||
formatValidity,
|
||||
formatValidityShort,
|
||||
isExpired,
|
||||
isNotStarted,
|
||||
isRangeInvalid,
|
||||
visibilityNote,
|
||||
} from "../src/lib/promo-validity";
|
||||
|
||||
describe("Période de validité d'une promotion", () => {
|
||||
it("affiche les deux bornes", () => {
|
||||
assert.equal(
|
||||
formatValidity({ startsOn: "2027-03-01", endsOn: "2027-03-15" }),
|
||||
"Valable du 1er au 15 mars 2027"
|
||||
);
|
||||
});
|
||||
|
||||
it("ne répète pas l'année quand elle est identique", () => {
|
||||
assert.equal(
|
||||
formatValidity({ startsOn: "2027-03-01", endsOn: "2027-04-10" }),
|
||||
"Valable du 1er mars au 10 avril 2027"
|
||||
);
|
||||
});
|
||||
|
||||
it("écrit les deux années quand elles diffèrent", () => {
|
||||
assert.equal(
|
||||
formatValidity({ startsOn: "2027-12-20", endsOn: "2028-01-05" }),
|
||||
"Valable du 20 décembre 2027 au 5 janvier 2028"
|
||||
);
|
||||
});
|
||||
|
||||
it("gère une seule borne", () => {
|
||||
assert.equal(formatValidity({ endsOn: "2027-06-30" }), "Valable jusqu'au 30 juin 2027");
|
||||
assert.equal(
|
||||
formatValidity({ startsOn: "2027-06-01" }),
|
||||
"Valable à partir du 1er juin 2027"
|
||||
);
|
||||
});
|
||||
|
||||
it("ne renvoie rien sans date ni texte : la carte n'affiche alors aucune mention", () => {
|
||||
assert.equal(formatValidity({}), null);
|
||||
assert.equal(formatValidity({ startsOn: null, endsOn: null, validUntil: " " }), null);
|
||||
});
|
||||
|
||||
it("retombe sur l'ancien texte libre des promotions existantes", () => {
|
||||
assert.equal(formatValidity({ validUntil: "Tout l'été" }), "Tout l'été");
|
||||
// Les dates priment sur le texte libre.
|
||||
assert.equal(
|
||||
formatValidity({ endsOn: "2027-07-31", validUntil: "Tout l'été" }),
|
||||
"Valable jusqu'au 31 juillet 2027"
|
||||
);
|
||||
});
|
||||
|
||||
it("ignore une date illisible", () => {
|
||||
assert.equal(formatValidity({ endsOn: "pas-une-date" }), null);
|
||||
assert.equal(formatValidity({ endsOn: "2027-13-45" }), null);
|
||||
});
|
||||
|
||||
it("propose une variante sans le mot « Valable »", () => {
|
||||
assert.equal(formatValidityShort({ endsOn: "2027-06-30" }), "jusqu'au 30 juin 2027");
|
||||
});
|
||||
|
||||
it("considère le dernier jour comme encore valable", () => {
|
||||
const end = { endsOn: "2027-06-30" };
|
||||
assert.equal(isExpired(end, new Date("2027-06-30T23:00:00Z")), false);
|
||||
assert.equal(isExpired(end, new Date("2027-07-01T12:00:00Z")), true);
|
||||
assert.equal(isExpired({}, new Date("2099-01-01T00:00:00Z")), false);
|
||||
});
|
||||
|
||||
it("détecte une fin antérieure au début", () => {
|
||||
assert.equal(isRangeInvalid({ startsOn: "2027-06-10", endsOn: "2027-06-01" }), true);
|
||||
assert.equal(isRangeInvalid({ startsOn: "2027-06-01", endsOn: "2027-06-01" }), false);
|
||||
assert.equal(isRangeInvalid({ startsOn: "2027-06-01" }), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Visibilité publique d'une promotion", () => {
|
||||
it("distingue avant, pendant et après la période", () => {
|
||||
const promo = { startsOn: "2027-03-01", endsOn: "2027-03-15" };
|
||||
assert.equal(
|
||||
visibilityNote(promo, new Date("2027-02-20T12:00:00Z")),
|
||||
"Pas encore affichée : elle apparaîtra sur le site le 1er mars 2027."
|
||||
);
|
||||
assert.equal(visibilityNote(promo, new Date("2027-03-01T00:00:00Z")), null);
|
||||
assert.equal(visibilityNote(promo, new Date("2027-03-15T23:00:00Z")), null);
|
||||
assert.equal(
|
||||
visibilityNote(promo, new Date("2027-03-16T08:00:00Z")),
|
||||
"Période terminée le 15 mars 2027 : elle n'apparaît plus sur le site."
|
||||
);
|
||||
});
|
||||
|
||||
it("laisse visible une offre sans date", () => {
|
||||
assert.equal(visibilityNote({ startsOn: null, endsOn: null }), null);
|
||||
assert.equal(visibilityNote({ validUntil: "Ce week-end" }), null);
|
||||
assert.equal(isNotStarted({ startsOn: null }), false);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { defaultProtocol, originFromHeaders } from "../src/lib/site-url";
|
||||
|
||||
function headersOf(entries: Record<string, string>) {
|
||||
const map = new Map(Object.entries(entries).map(([k, v]) => [k.toLowerCase(), v]));
|
||||
return { get: (name: string) => map.get(name.toLowerCase()) ?? null };
|
||||
}
|
||||
|
||||
test("l'origine suit les en-têtes du reverse proxy", () => {
|
||||
assert.equal(
|
||||
originFromHeaders(headersOf({ host: "pleinr-app:3000", "x-forwarded-host": "pleinr.example.fr", "x-forwarded-proto": "https" })),
|
||||
"https://pleinr.example.fr"
|
||||
);
|
||||
});
|
||||
|
||||
test("une liste d'hôtes transmis retient le premier", () => {
|
||||
assert.equal(
|
||||
originFromHeaders(headersOf({ "x-forwarded-host": "pleinr.example.fr, proxy.interne", "x-forwarded-proto": "https, http" })),
|
||||
"https://pleinr.example.fr"
|
||||
);
|
||||
});
|
||||
|
||||
test("un accès direct en HTTP sur une IP reste en HTTP", () => {
|
||||
assert.equal(originFromHeaders(headersOf({ host: "0.0.0.0:3000" })), "http://0.0.0.0:3000");
|
||||
assert.equal(originFromHeaders(headersOf({ host: "192.168.1.20:8413" })), "http://192.168.1.20:8413");
|
||||
assert.equal(originFromHeaders(headersOf({ host: "localhost:3000", "x-forwarded-proto": "http" })), "http://localhost:3000");
|
||||
});
|
||||
|
||||
test("un nom de domaine sans indication de protocole est présumé en HTTPS", () => {
|
||||
assert.equal(originFromHeaders(headersOf({ host: "pleinr.example.fr" })), "https://pleinr.example.fr");
|
||||
assert.equal(defaultProtocol("pleinr.example.fr:8443"), "https");
|
||||
assert.equal(defaultProtocol("[::1]:3000"), "http");
|
||||
});
|
||||
|
||||
test("un en-tête absent ou malformé ne produit rien", () => {
|
||||
assert.equal(originFromHeaders(headersOf({})), "");
|
||||
assert.equal(originFromHeaders(headersOf({ host: "bad host/with path" })), "");
|
||||
assert.equal(originFromHeaders(headersOf({ host: "pleinr.example.fr", "x-forwarded-proto": "ftp" })), "https://pleinr.example.fr");
|
||||
});
|
||||
Reference in new issue
Block a user