Author SHA1 Message Date
Claude 0e340dde58 Boîte mail : accorder le port SMTP et le chiffrement
Le port et la case « connexion chiffrée » étaient deux champs indépendants
qu'il fallait accorder de tête. Passer en 587 sans décocher fait tenter une
poignée de main TLS sur un port qui répond en clair : la connexion meurt sur
un « wrong version number » que rien ne traduisait.

- `src/lib/smtp-config.ts` (pur, testé) : `impliedSecure()`, `tlsMismatch()`
  et `describeSmtpError()`, qui traduit les échecs courants — mauvaise case,
  port filtré, identifiants refusés, certificat, expéditeur rejeté — en
  consigne actionnable, l'explication avant la trace puisque l'écran tronque.
- `SmtpAccountForm` (client) réaligne la case sur le port saisi, propose les
  deux ports courants en pastilles et affiche le désaccord s'il subsiste. Le
  mot de passe enregistré ne descend pas en prop, seul un booléen le fait.
- Transport : `requireTLS` hors TLS implicite — sans lui STARTTLS reste
  opportuniste et l'identifiant partirait en clair chez un serveur qui ne
  l'annonce pas — SNI explicite, TLS 1.2 minimum, et délais ramenés à
  quelques secondes pour qu'un port bloqué en sortie rende son verdict au
  lieu de figer l'action serveur.
- `verifySmtp` referme la socket même en échec et dit sur quel port et dans
  quel mode la connexion a abouti.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RCUSintYfXnBtwFKQezFSt
2026-09-15 13:51:51 +00:00
Claude 2da125eca2 Espace adhérent : plus de barre latérale hors staff
Un adhérent voyait deux fois le même menu : la barre latérale ne portait chez
lui que la section « Côté adhérent » — Informations, Mon profil, Mes
promotions — et EspaceHeader rend déjà ces trois entrées en onglets sous le
bandeau. Un quart de l'écran pour une redite.

BackendShell branche donc sur isStaff : hors staff, il ne reste qu'un bandeau
de marque, aligné sur la colonne de contenu, et la page prend toute la
largeur. Le staff garde la barre, qui est chez lui la navigation principale et
le seul chemin vers son propre espace.

Le piège était sur téléphone : sous 1024px les actions du compte vivaient dans
le tiroir, pas dans l'entête. Sans tiroir, la déconnexion serait devenue
inatteignable — .backend-header--plain rétablit leur affichage à toutes les
largeurs, et un test le verrouille avec l'absence d'aside et de bouton de
tiroir dans la variante.

Vérifié au navigateur en 1440 et 390 px, adhérent et administrateur : aucune
barre ni bouton de tiroir côté adhérent, actions présentes aux deux largeurs,
aucun débordement horizontal ; les dix-sept entrées de l'administrateur et son
tiroir sont intacts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QsLjRAnuLwivqxCbM4WeP
2026-09-15 06:47:10 +00:00
Claude cc33cf5fbc Informations : éditeur visuel à la place de la syntaxe Markdown
La barre d'outils écrivait des étoiles dans un textarea : l'adhérent qui
rédigeait devait connaître une syntaxe pour obtenir du gras. On édite
désormais comme dans un traitement de texte — on sélectionne, on clique sur
G, le texte devient gras à l'écran.

Le format stocké ne change pas d'un octet. C'est tout l'enjeu : le WYSIWYG
porte sur la saisie, pas sur le stockage. À chaque frappe, le navigateur
retraverse ce qu'il a édité (serializeToRichText) et n'en garde que le gras,
l'italique, les listes, les sous-titres et les liens ; le balisage part dans
un champ caché et le serveur le ré-analyse avec l'analyseur existant. Le HTML
de l'éditeur ne quitte jamais la page, donc aucun assainisseur HTML n'entre
dans le produit — le risque n'a jamais été la surface d'édition, mais la base.

- rich-text-dom.ts : sérialiseur pur, testable sans navigateur (MinimalNode).
  Il lit aussi le gras codé en style CSS, pour qu'un brouillon collé depuis
  Google Docs ne perde pas sa mise en forme ; le style explicite l'emporte sur
  la balise, sinon le <b style="font-weight:normal"> dont Docs enveloppe tout
  document mettrait le texte entier en gras.
- Un collage passe par DOMParser, dont le document est inerte : un <img
  onerror> collé ne s'exécute pas. Seul du HTML produit par nous est réinjecté.
- L'italique s'écrit _ainsi_ : **_x_** est lisible par l'analyseur là où ***x***
  serait ambigu. Un tiret bas au milieu d'un mot n'ouvre rien.
- saveInformation normalise les CRLF que l'encodage des formulaires introduit.

Vérifié au navigateur contre une base réelle : aller-retour identique après
enregistrement, et un collage hostile (script, img onerror, iframe, style,
lien javascript:) ressort en texte structuré sans qu'une seule alerte parte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QsLjRAnuLwivqxCbM4WeP
2026-09-15 06:37:52 +00:00
Claude 90b121a3c1 Merge remote-tracking branch 'origin/main' into claude/lucid-lovelace-c5is18 2026-09-15 06:13:05 +00:00
Claude b860eeaf29 Référencement : Google Analytics et Search Console réglables en backoffice
Deux réglages dans Backend › Paramètres :

- `google_analytics_id` pose les balises gtag.js. Vide = rien n'est chargé.
  `src/lib/analytics.ts` (pur) n'accepte qu'un identifiant GA4 « G-… » : la
  saisie ne peut donc pas s'échapper du script en ligne. Les deux `<script>`
  portent le nonce de la CSP, et seules les pages publiques sont mesurées
  (`isTrackedPath` écarte `PRIVATE_PATHS`, le chemin venant du nouvel en-tête
  `x-pathname` du middleware — un layout ne connaît pas l'URL demandée).
- `google_site_verification` rend la balise de validation Search Console via
  `verification.google`, après contrôle du jeton.

La CSP gagne les hôtes Google Analytics en `connect-src` : elle est posée sur
le runtime Edge, qui ne peut pas lire la base pour savoir si la mesure est
active. `script-src` est inchangé, `'strict-dynamic'` couvrant les scripts que
gtag.js charge ensuite.

Le plan du site n'a besoin de rien : `/sitemap.xml` est déjà servi et annoncé
dans `/robots.txt`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R7v5KcYUo2taDVDgehs7ZH
2026-09-15 05:12:21 +00:00
Claude 7e7d307cdd Type la redirection de la boîte mail en « never »
`redirect` lève : le déclarer supprime les `!` posés après chaque appel
pour rassurer TypeScript.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QsLjRAnuLwivqxCbM4WeP
2026-09-14 16:26:35 +00:00
Claude 39eb8cfc13 Documente le fil d'informations et la messagerie
CLAUDE.md gagne les deux sections et, surtout, les pièges qui coûteraient
une soirée à retrouver : `db.execute` qui rend les colonnes brutes, les
deux interrupteurs de boucle qu'il ne faut pas confondre, la rotation du
jeton Microsoft, l'adresse d'expédition lue chez le fournisseur, et la
raison pour laquelle les mots de passe temporaires ne passent jamais par
la file.

README et .env.example décrivent les trois transports, le piège des sept
jours côté Google, et le repli SMTP par variables d'environnement qui
permet un premier déploiement sans démarche préalable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QsLjRAnuLwivqxCbM4WeP
2026-09-14 16:23:32 +00:00
Claude 0c8542f894 Tests : file d'envoi, destinataires, navigation et invariants
`tests/security.test.ts` gagne cinq blocs qui verrouillent ce qui compte
et qu'aucun type ne protège :

- les cinq actions qui émettent un mot de passe appellent `sendNow` et
  jamais `queueMail`, et la trace journalisée porte un corps vide ;
- l'écran Boîte mail ne déchiffre aucun secret, saisit en champ masqué et
  annonce qu'un champ vide conserve la valeur enregistrée ;
- les trois fonctions qui écrivent un secret en base le chiffrent ;
- ni copie ni copie cachée dans la chaîne d'envoi, et `to_address` reste
  une adresse unique et obligatoire ;
- aucune lecture publique ne touche aux informations, et rien de ce qui
  les rend n'emprunte `dangerouslySetInnerHTML` ;
- chaque boucle de fond garde son interrupteur et reste hors du bundle
  edge.

`tests/backend-nav.test.ts` attrape l'oubli qu'on ne voit qu'à l'écran :
ajouter une page demande de toucher aux titres, aux sections et au jeu
d'icônes, et rater l'un des trois donne un en-tête vide ou une icône
absente.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QsLjRAnuLwivqxCbM4WeP
2026-09-14 16:22:07 +00:00
Claude 158958a8f4 Le studio d'e-mails et les invitations partent vraiment
Les deux écrans de composition gagnent le choix des destinataires — tous
les adhérents actifs, une catégorie de métier, ou les inscrits à la
rencontre — et un bouton qui envoie pour de bon. « M'envoyer un test »
part en direct, pour un verdict immédiat ; une diffusion passe par la
file, une ligne par destinataire.

Copier, brouillon Outlook et export HTML restent en place : ils servent
encore aux cas particuliers, et au cas où aucune boîte n'est configurée.

L'aperçu comme l'envoi s'appuient désormais sur l'adresse publique
enregistrée plutôt que sur `window.location.origin` : c'est celle que
verront les destinataires, pas celle par laquelle l'administrateur est
arrivé.

Un modérateur gère les rencontres mais n'écrit pas à tous les adhérents :
le bouton d'envoi du composeur reste conditionné à `manageEmails`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QsLjRAnuLwivqxCbM4WeP
2026-09-14 16:19:28 +00:00
Claude 877b0ece78 Diffuser une information par e-mail, à la publication
La case « Envoyer aussi par e-mail » du bouton Publier met une ligne par
adhérent dans la file : jamais de copie partagée, aucune adresse visible
des autres. `informations.email_sent_at` fait office de garde
anti-double-diffusion, comme `publishPromoShares` pour les réseaux — une
information dépubliée puis republiée ne repart pas.

La diffusion est réservée à `manageEmails` : un modérateur publie, il
n'écrit pas à tous les adhérents. Sans adresse publique enregistrée, rien
ne part et le bandeau le dit, plutôt que d'expédier un message dont le
logo et tous les liens seraient cassés.

L'image de couverture passe par `/api/informations/<id>/image` et non en
data-URI : Gmail et Outlook suppriment les `<img src="data:">`, et
l'incorporer gonflerait chaque ligne de la file à plusieurs méga-octets.
La route ne sert que les informations publiées.

Deux défauts corrigés en chemin, trouvés en jouant le parcours complet :

- `db.execute` rend les colonnes brutes, sans les rebaptiser comme le fait
  le constructeur de requêtes : `to_address` n'arrivait pas dans
  `toAddress`, et nodemailer refusait tous les messages faute de
  destinataire.
- `PROMO_SCHEDULER=off` empêchait le chargement du module de fond, donc
  coupait aussi l'envoi des e-mails. Chaque boucle a maintenant son propre
  interrupteur.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QsLjRAnuLwivqxCbM4WeP
2026-09-14 16:15:11 +00:00
Claude a7ac27e530 Les identifiants partent par e-mail, sans jamais passer par la file
Les cinq actions qui émettent un mot de passe temporaire — création d'un
adhérent, rattrapage des comptes manquants, réinitialisation, invitation
staff, approbation d'une demande — l'envoient désormais au destinataire.

L'envoi est **en ligne directe**, jamais mis en file : `mail_messages.html`
est stocké en base, et CLAUDE.md pose que le mot de passe temporaire n'est
jamais conservé. Le clair n'existe donc que dans la portée de l'action, et
seule une trace sans contenu est journalisée.

C'est un plus, jamais un point de rupture : `OneTimeCredentials` continue
d'afficher le mot de passe quoi qu'il arrive, et annonce « envoyé à … » ou
« envoi impossible, transmettez-le à la main ». Boîte non configurée,
l'écran se comporte exactement comme avant.

Au passage, le rattrapage des comptes manquants ne perd plus l'état d'envoi
de chaque ligne : la liste propage l'objet entier au lieu d'en recopier
trois champs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QsLjRAnuLwivqxCbM4WeP
2026-09-14 16:05:41 +00:00
Claude 8d4443a1a1 Boîte mail de l'association : trois transports, une file d'attente
Le site savait composer de beaux messages et ne savait pas les envoyer :
`email-client.ts` ne proposait que la copie ou le téléchargement d'un
brouillon Outlook, complété à la main. `/backend/boite-mail` branche
désormais la boîte de l'association, sur le modèle exact de l'écran
Réseaux sociaux — secrets chiffrés, jamais renvoyés au navigateur, champ
laissé vide qui conserve la valeur enregistrée, contrôle de santé qui ne
lève jamais.

Trois transports, un seul actif à la fois, désigné par l'administrateur —
pas de cascade automatique : si Google se bloque, la bascule se voit.

- **Google** passe par l'API Gmail plutôt que par SMTP avec XOAUTH2 :
  celui-ci exigerait `https://mail.google.com/`, portée *restreinte* et
  donc audit de sécurité, là où `gmail.send` est simplement *sensible*.
- **Microsoft** passe par Graph : l'authentification basique SMTP est
  désactivée depuis 2024, y compris sur outlook.com et hotmail.com.
- **SMTP** couvre le reste via `nodemailer`, seule dépendance ajoutée.

`from_address` est **lu chez le fournisseur** et non saisi : Gmail expédie
comme l'utilisateur authentifié, Graph comme la boîte. Une adresse d'un
autre domaine ferait tomber SPF et DKIM.

La file `mail_messages` porte un destinataire unique par ligne — la
confidentialité d'une diffusion est structurelle, aucune copie partagée
n'est possible. Elle est vidée par la boucle de fond, avec réclamation en
`FOR UPDATE SKIP LOCKED`, réessais espacés et reprise des verrous laissés
par un conteneur arrêté en plein envoi.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QsLjRAnuLwivqxCbM4WeP
2026-09-14 16:00:11 +00:00
Claude d57e544a18 Fil d'informations dans l'espace adhérent
L'association pouvait publier des promotions et des rencontres, mais rien
à destination de ses seuls adhérents connectés. Un troisième onglet, placé
en tête de l'espace, porte désormais ce que publie le bureau : annonces,
convocations, rappels.

Côté rédaction, `/backend/informations` (capacité `manageInformations`,
administrateur et modérateur) : titre, texte balisé avec barre de mise en
forme et aperçu, image de couverture, brouillon puis publication,
épinglage — une seule information en tête à la fois, appliqué dans
l'action plutôt que par un index conditionnel.

Côté lecture, `information_reads` porte une ligne par lecture plutôt
qu'une date « vu jusqu'ici » sur l'utilisateur : c'est ce qui permet à la
fois la pastille « Nouveau » par information et le « lue par 12 / 40 » du
back-office. Le marquage passe par une action appelée après affichage :
la pastille de l'onglet et celle de la barre latérale sont calculées par
deux composants serveur distincts, dont l'ordre de rendu n'est pas
garanti, et se seraient contredites sur la même page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QsLjRAnuLwivqxCbM4WeP
2026-09-14 15:49:42 +00:00
Claude 1701fc2834 Analyseur de texte riche pour les informations adhérents
Sous-ensemble de Markdown — gras, italique, listes, liens, sous-titres —
analysé par `src/lib/rich-text.ts` puis rendu en éléments React, jamais en
HTML brut : même principe que le journal d'activité. Un éditeur WYSIWYG
aurait imposé de stocker du HTML tiers puis de l'assainir, soit une
seconde dépendance et une seconde surface d'attaque.

Deux rendus, un seul analyseur : `richTextNodes()` pour l'écran,
`richTextToEmailHtml()` pour le message. L'aperçu du formulaire passera
par le premier, il sera donc fidèle par construction.

`safeHttpUrl()` remplace le `webUrl()` privé d'`email-templates.ts` : un
lien hors http/https perd sa cible et ne garde que son libellé.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QsLjRAnuLwivqxCbM4WeP
2026-09-14 15:28:37 +00:00
Claude 3df3e55974 Extrait le constructeur MIME dans un module pur
`downloadOutlookDraft` assemblait à la main un message RFC 822 complet
(sujet RFC 2047, base64 replié, partie `cid:` en ligne). Ce code a un
second appelant à venir : le champ `raw` de l'API Gmail attend exactement
ce format. Il part donc dans `src/lib/mime.ts`, pur et verrouillé par
`tests/mime.test.ts`, plutôt que d'importer plus tard l'entrée interne
`nodemailer/lib/mail-composer`.

Le constructeur gagne au passage ce que le brouillon Outlook n'avait pas
besoin de gérer : repli des mots encodés longs sans couper une séquence
UTF-8, `multipart/alternative` pour le repli texte, mise en forme des
adresses, et neutralisation des CR/LF dans les valeurs d'en-tête — un
retour à la ligne dans un objet permettrait sinon d'injecter un `Bcc:`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QsLjRAnuLwivqxCbM4WeP
2026-09-14 15:25:22 +00:00
Claude 67cd9d6684 Affiche les coordonnées du référent aux visiteurs connectés
L'annuaire, les pages métier et la fiche adhérent montrent désormais la
personne à joindre chez l'adhérent — nom, prénom, ligne directe — mais
uniquement à un visiteur connecté.

Trois colonnes sur `members` (`contact_first_name`, `contact_last_name`,
`contact_phone`), saisies dans les deux formulaires de fiche via le
composant partagé `MemberContactFields`.

Le filtrage ne repose pas sur du CSS ni sur un rendu conditionnel tardif :
aucune requête publique ne lit ces colonnes. Seules `getMemberContacts()`
et `getMemberContact()` les rapatrient, et les pages ne les appellent
qu'après un `getSession()` positif. Un visiteur anonyme ne reçoit donc
rien — ni dans le HTML, ni dans le JSON-LD, ni dans les props du composant
client de l'annuaire. `tests/security.test.ts` verrouille cette séparation.

`src/lib/member-contact.ts` reste pur (composition du nom, lien `tel:`) et
est couvert par `tests/member-contact.test.ts`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TAK3c4jAUqxVMzy746wWQ
2026-09-14 11:31:59 +00:00
Claude fb09bf0e3a Accueil : préciser « entreprises de service »
Sur la page d'accueil, les mentions d'« entreprises » deviennent
« entreprises de service » : texte alternatif du logo, sous-titre du
hero, phrase d'accroche et appel à adhésion (les deux variantes,
avec et sans compteur d'adhérents).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DkXREQkpPAKHFLgtnFFkCM
2026-09-14 11:04:53 +00:00
MichaelandClaude Opus 5 7d72016bfb Prochaines dates : cartes de hauteur uniforme, détail en fenêtre
Une description longue étirait sa carte, donc toute la rangée de la grille,
et laissait les voisines avec un grand vide sous leur bouton — même défaut
que les cartes de l'annuaire avant leur uniformisation.

Le titre (2 lignes), le lieu (1 ligne) et la description (3 lignes) ont
désormais une hauteur figée et sont tronqués en fin de ligne ; un espaceur
pousse les compteurs et les boutons en bas de carte. La ligne de lieu reste
réservée quand la rencontre n'en a pas, sinon sa carte remonterait tout son
bas.

Le texte complet n'est plus perdu : « Voir le détail » ouvre une fenêtre
avec l'image, la date, le lieu, la description entière, les places et le
bouton d'inscription. Le texte tronqué reste dans le HTML (troncature CSS),
l'indexation n'y perd rien.

Les dates sont formatées côté serveur et passées déjà rendues : les formater
dans le composant client les aurait rendues dans le fuseau du visiteur et
provoqué une erreur d'hydratation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MS61rkgTUGgPYH88tuVx9U
2026-09-14 06:01:57 +00:00
MichaelandClaude Opus 5 e59ae3a908 Espace adhérent : taille et format de l'image annoncés au dépôt
L'application publie le fichier tel quel sur Facebook et LinkedIn : aucun
recadrage, aucune marge. Un visuel qui n'est pas au format attendu est donc
recadré — ou entouré de bandes de couleur prises dans l'image — par la
plateforme, ce qui ne se voit nulle part côté site puisque les cartes
recadrent en `cover`.

Le formulaire de dépôt affiche maintenant les dimensions, le format et le
poids du fichier choisi, et dit ce que les réseaux en feront : format non
carré, image trop petite pour un fil d'actualité, fichier trop lourd pour
l'envoi. Purement informatif, rien n'est bloqué.

`src/lib/image-info.ts` est pur et verrouillé par `tests/image-info.test.ts`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MS61rkgTUGgPYH88tuVx9U
2026-09-14 05:52:13 +00:00
MichaelandClaude Opus 5 146250f973 Modération : la diffusion réseaux suit la date de publication
Le bloc « Diffusion demandée » du formulaire « Valider » annonçait une
publication « dès la validation », alors qu'une promotion validée avec une
date future passe en `scheduled` et n'est diffusée qu'à l'échéance, par
`releaseDuePromotions()`. Le texte décrit maintenant les deux cas.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MS61rkgTUGgPYH88tuVx9U
2026-09-14 05:44:01 +00:00
Claude dd16e05b47 Demandes & messages : boutons d'action selon l'état de la ligne
Une demande approuvée gardait ses boutons « Approuver & créer » et
« Rejeter », un message lu son « Marquer lu », un message archivé son
« Archiver » : rien n'indiquait que l'action avait déjà été faite.

Chaque ligne ne propose plus que les transitions qui ont du sens :
- demande nouvelle → Approuver & créer / Rejeter
- demande rejetée  → Rouvrir
- demande approuvée → aucun bouton (le compte existe)
- message nouveau  → Marquer lu / Archiver
- message lu       → Archiver
- message archivé  → Désarchiver

Le masquage du bouton d'approbation vit dans `ApproveRequestForm` et non
dans la page : après l'approbation, `router.refresh()` repasse la demande
en « approuvée », et un test côté serveur démonterait le bloc
d'identifiants à usage unique avant qu'il ait été lu.

Deux gardes côté serveur couvrent l'onglet resté ouvert sur l'ancien
état : `approveMembershipRequest` refuse une demande déjà traitée, et
`setRequestStatus` ne touche plus une demande approuvée.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MrfrukBXnHtMwq5iT7DwAM
2026-09-13 07:48:59 +00:00
Claude e9b6fdfd91 Annuaire : cartes adhérents de hauteur uniforme
Une description longue étirait sa carte, donc toute la rangée de la
grille, et laissait les voisines avec un grand vide sous le texte.

Le titre (2 lignes) et la description (4 lignes) ont désormais une
hauteur figée et sont tronqués en fin de ligne ; un espaceur pousse
la ligne d'adresse en bas de carte, et cette adresse tient sur une
seule ligne avec ellipsis. Toutes les cartes font la même hauteur,
quel que soit le texte saisi par l'adhérent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MrfrukBXnHtMwq5iT7DwAM
2026-09-13 07:25:22 +00:00
Claude 503adcb20e Corrige la page blanche à la création d'un adhérent en doublon
« Application error: a server-side exception has occurred », digest 2117999835,
sur /backend/adherents : l'action levait `Error("Un compte existe déjà avec cet
e-mail.")`. Next masque le message d'une exception de server action en
production et remplace la page entière par son écran d'erreur — un e-mail déjà
pris, cas de saisie parfaitement ordinaire, mettait donc la page par terre.

Ces échecs attendus sont désormais **renvoyés** (`ActionError`) au lieu d'être
jetés, dans `addMember`, `inviteAdmin` et `approveMembershipRequest` :

- Le formulaire affiche la vraie raison, l'e-mail en cause inclus.
- `AddMemberPanel` réinjecte la saisie : React vide un formulaire non contrôlé
  après chaque action, il fallait sinon tout retaper.
- `InviteAdminForm` avait un rattrapage, mais devait deviner le motif faute de
  message ; il affiche maintenant celui de l'action. Son `catch` ne couvre plus
  que l'imprévu.
- `ApproveRequestForm` n'en avait aucun et tombait de la même façon.

Le `throw` reste réservé aux violations d'accès.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
2026-09-07 07:59:25 +00:00
Claude 50b8732080 Réseaux : bouton de renouvellement du jeton, avec ses deux liens de contrôle
Renouveler un jeton demandait de deviner que « Reconnecter » servait à ça, et
les outils Meta / LinkedIn étaient enfouis sous « Comment obtenir ces
identifiants ? ». Chaque réseau connecté porte maintenant un bloc dédié :

- Un bouton « Renouveler le jeton <réseau> », qui relance l'échange OAuth
  complet (jeton court → longue durée → jeton de page).
- Les deux liens utiles à ce moment-là, chacun avec ce qu'il sert à faire :
  débogueur de jeton et outil de jetons d'accès pour Facebook, jetons de
  l'application et documentation de durée de vie pour LinkedIn.
- Le bloc passe en ambre quand le jeton est mort, expiré ou proche de l'être.

« Reconnecter » disparaît : il faisait exactement la même chose que le nouveau
bouton, à dix pixels de distance. Une fois connecté, il ne reste que
« Déconnecter » ; « Connecter » n'apparaît plus que pour la première liaison.
Les bannières d'alerte nomment désormais le bouton qui existe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
2026-09-06 15:33:44 +00:00
Claude 4b12dc5164 Espace adhérent : récapitulatif des dates, aperçu ramené à sa taille réelle
La colonne de droite portait un aperçu étiré sur toute sa largeur : le visuel
carré rendait la carte démesurée, très loin de ce que verra le visiteur.

- L'aperçu est borné à 320 px, la largeur d'une carte dans la grille du site.
- Au-dessus, un récapitulatif « Dates de votre offre » : durée de validité et
  moment de publication, les deux informations que la carte ne montre pas.
  Sans saisie il affiche les valeurs par défaut en gris (« Sans date limite »,
  « Dès la validation par l'association ») ; des bornes incohérentes s'y
  signalent en rouge, comme sous le champ.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
2026-09-06 15:10:41 +00:00
Claude 353d38bebf Promotions : la page publique suit le gabarit des pages internes
La première version reprenait le style d'une *section* de l'accueil — un grand
cartouche blanc arrondi — qui détonnait à côté de l'annuaire et de
« L'association ». Elle suit maintenant le même gabarit que l'annuaire :

- `SiteHeader … logo` : toutes les pages internes affichent le logo, seule
  l'accueil s'en passe.
- Bloc de titre sur le fond beige (pastille « N offres en cours », `<h1>` au
  même `clamp`, chapeau, étincelles) au lieu du cartouche.
- Grille de cartes à même la page, `gap` aligné sur les autres listes.
- Bandeau d'adhésion bleu en pied de page, identique à celui de l'annuaire.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
2026-09-06 14:50:41 +00:00
Claude a52e13e623 Promotions : page publique dédiée, l'accueil garde les six dernières
`/promotions` liste toutes les offres en cours ; l'accueil en montre six et
renvoie vers elle. Les deux listes partagent `PromoCard`, extrait de la page
d'accueil : un seul rendu de carte à maintenir.

- `getLivePromotions(limit)` accepte `null` pour « toutes les offres », la
  limite restant appliquée en SQL et non après coup.
- Page complète : `<h1>` unique, `<main>`, métadonnées et canonique via
  `pageMetadata`, JSON-LD `BreadcrumbList` + `ItemList`, entrée au sitemap,
  état vide qui renvoie vers l'annuaire.
- Les liens « Promotions » de l'en-tête, du pied de page et de l'espace
  adhérent visent désormais cette page. Idem pour le lien d'une publication
  réseau sans adhérent rattaché, qui pointait sur l'ancre `/#promotions`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
2026-09-06 14:34:27 +00:00
Claude cf8cf5c8e2 Promotions : le logo revient à gauche du nom de l'adhérent
L'angle de la carte est rendu à la catégorie ; la pastille du commerce se
place devant son nom, en pied de carte, là où on lit déjà qui propose l'offre.
La période de validité passe sous le nom, dans le même bloc.

Appliqué à la carte d'accueil, à l'aperçu en direct de l'espace adhérent et à
la carte de modération. Retiré de la carte de la fiche adhérent : elle n'y
affiche pas de nom de commerce, et le logo figure déjà en tête de page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
2026-09-06 14:22:03 +00:00
Claude d99ee7ec07 Promotions : logo de l'adhérent dans l'angle des cartes
Le logo du commerce identifie l'offre avant même qu'on lise le nom. Il est
posé en pastille ronde dans l'angle haut-gauche de la carte, la catégorie se
décalant juste à côté sur la même ligne.

- `src/components/MemberAvatar.tsx` : logo en `contain` sur fond blanc (les
  logos d'adhérents sont des marques sur fond clair, un `cover` les rognerait),
  repli sur les initiales colorées quand la fiche n'a pas de logo.
- Appliqué à la carte d'accueil, à la carte de la fiche adhérent, à l'aperçu
  en direct de l'espace adhérent et à la carte de modération : même composition
  partout, l'aperçu montre donc bien ce que verra le visiteur.
- `getLivePromotions` remonte `members.logo_url` ; la page espace le passe au
  formulaire.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
2026-09-06 14:02:53 +00:00
Claude e980ab6e04 Promotions : publication programmée et affichage limité à la période
Deux comportements demandés :

1. Une promotion n'apparaît sur le site que pendant sa période de validité.
   Les lectures publiques passent par `VISIBLE_PROMO` (`src/lib/queries.ts`) :
   statut `live` **et** fenêtre `starts_on` / `ends_on`, journée calculée en
   Europe/Paris pour qu'un conteneur en UTC ne retire pas une offre deux
   heures trop tôt. Le statut n'est pas touché : hors période, le backoffice
   explique pourquoi l'offre n'est pas visible (`visibilityNote`) et elle
   revient d'elle-même si les dates changent.

2. La publication peut être programmée. `promotions.publish_at` : l'adhérent
   propose une date, le modérateur la garde, la déplace ou la vide dans le
   formulaire « Valider ». Avec une échéance future la promotion passe au
   statut `scheduled` — rien n'est visible, rien n'est diffusé — et
   `releaseDuePromotions()` fait la bascule à l'heure dite, site et réseaux
   ensemble. Un bouton « Publier maintenant » court-circuite l'attente.

Mécanique :
- `src/lib/promo-schedule.ts` (pur, testé) : conversion `datetime-local` ⇄
  heure de l'association, changements d'heure compris, et refus d'une saisie
  illisible plutôt qu'une publication immédiate involontaire.
- `src/lib/promo-publish.ts` reprend `publishPromoShares()` : deux appelants
  désormais, une action serveur et la boucle de libération, qui n'a pas de
  requête et ne peut donc pas appeler `revalidatePath()`. Idem pour
  `logActivity()` dans `src/lib/activity-log.ts`.
- La bascule `scheduled → live` est un `UPDATE … RETURNING` filtré sur le
  statut : atomique, donc pas de double publication même à plusieurs
  instances, en plus de la garde existante sur `social_posts`.
- Déclencheur : `src/instrumentation.ts`, boucle d'une minute avec rattrapage
  au démarrage (`PROMO_SCHEDULER=off` la désactive). Le travail vit dans
  `instrumentation-node.ts`, écarté du bundle edge par `next.config.mjs` :
  Next compile aussi l'instrumentation pour le middleware, où `pg` ne peut
  pas être embarqué.

Migration 0016 : `promo_status += 'scheduled'`, `promotions.publish_at`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
2026-09-06 11:17:49 +00:00
Claude 02c5378a47 Promotions : période de validité (date de début / date de fin)
Une promotion peut désormais porter une date de début et une date de fin,
toutes deux facultatives et indépendantes. Sans date, rien n'est affiché.

- Schéma : `promotions.starts_on` / `ends_on` (`date`), migration 0015.
- `src/lib/promo-validity.ts` (pur, testé) : `formatValidity()` produit
  « Valable du 1er au 15 mars 2027 », « Valable jusqu'au … », « Valable à
  partir du … » en repliant mois et année quand ils se répètent ;
  `formatValidityShort()` pour les lignes de méta ; `isRangeInvalid()` pour
  la validation.
- Espace adhérent : deux champs date, aperçu en direct de la phrase et
  blocage de l'envoi si la fin précède le début.
- `publishPromo` relit et revalide les bornes côté serveur.
- Affichage : carte d'accueil, fiche adhérent, liste de l'adhérent, carte de
  modération — et le message Facebook / LinkedIn, via le même formateur.
- L'ancien texte libre `valid_until` reste comme repli d'affichage pour les
  promotions de démonstration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
2026-09-06 11:00:44 +00:00
Claude 81b99ed5fb Réseaux : contrôle réel de validité des jetons et outils d'inspection
Un jeton de page Facebook n'a pas de date d'expiration, donc `expiryStatus`
renvoyait « never » et aucune alerte ne pouvait se déclencher. Mais il meurt
quand même : mot de passe du compte changé, rôle d'administrateur perdu sur la
page, autorisation retirée, révocation par Meta. On ne l'apprenait qu'au moment
où une publication échouait.

- `checkTokenHealth()` interroge la plateforme (`/me` côté Graph, les ACL
  d'organisation côté LinkedIn) et rend un verdict motivé.
- L'écran Réseaux sociaux contrôle à chaque affichage : pastille « Jeton
  expiré », bandeau rouge et réponse brute de la plateforme quand il est mort,
  ligne verte de confirmation sinon.
- Le verdict est persisté (`social_accounts.last_check_*`) : le tableau de bord
  alerte via `tokenHealthCached()` (fenêtre de 6 h) sans appeler la plateforme à
  chaque affichage, et croise ce signal avec la dernière tentative de
  publication refusée pour motif d'autorisation.
- Bloc « Outils pour inspecter un jeton » dans l'aide de chaque réseau :
  débogueur de jeton, outil de jetons d'accès, explorateur d'API Graph,
  documentation des jetons de longue durée — en précisant que le bouton
  Connecter fait déjà l'échange court → longue durée → jeton de page, et qu'il
  n'y a aucun jeton à copier à la main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
2026-09-06 10:43:11 +00:00
Claude 17511a73c3 Connexion réseaux : adresse de retour déduite de la requête, CSP sans upgrade en HTTP
Cliquer sur « Connecter » sans avoir enregistré l'URL publique du site
renvoyait une erreur, et la redirection retombait sur https://0.0.0.0 :
la directive upgrade-insecure-requests était posée même sur une page
servie en HTTP, le navigateur basculait donc la navigation vers un
https:// inexistant.

- `publicBaseUrl()` : réglage `site_public_url`, variables d'environnement,
  puis origine de la requête (X-Forwarded-Host / X-Forwarded-Proto). L'adresse
  de retour OAuth et le lien des publications l'utilisent ; `seo-server`
  s'appuie dessus au lieu de dupliquer la déduction.
- `src/lib/site-url.ts` : déduction pure et testée de l'origine, HTTP par
  défaut pour localhost et les IP nues, HTTPS pour un nom de domaine.
- Middleware : `upgrade-insecure-requests` uniquement quand la page est servie
  en HTTPS.
- Page Réseaux : l'adresse détectée est annoncée comme valeur par défaut, avec
  le rappel que Facebook et LinkedIn exigent du HTTPS sur un domaine.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
2026-09-06 10:11:06 +00:00
Claude 23d1d14a49 Espace adhérent en deux pages, e-mail de contact, logo sans débordement, types de produits pour les promos
- Espace adhérent : /backend/espace (profil, inscriptions, droit à l'image)
  et /backend/espace/promotions (dépôt et suivi). Bandeau et onglets communs
  (EspaceHeader) avec le nombre d'offres en ligne ; la page promotions ouvre
  sur « Mes promotions en cours », puis le formulaire, puis les offres en
  attente et l'historique. Deux entrées dans le menu, titres de page dédiés,
  revalidation des deux chemins.
- Fiche adhérent : colonne members.contact_email (migration 0013), saisie
  dans l'espace adhérent et la fiche staff ; la fiche publique et le JSON-LD
  affichent contact_email, sinon l'e-mail du compte.
- VitrineImage : sans photo de couverture, le logo est contenu à ~60 % d'un
  cadre à hauteur fixe sur un fond du logo flouté ; le conteneur sans hauteur
  qui laissait le logo déborder est supprimé.
- Promotions : la catégorie devient un type de produit ou de service
  (src/lib/promo-categories.ts, 10 groupes, ~85 entrées) pré-sélectionné
  depuis le métier de l'adhérent, à la place de la liste des métiers.
- Tests : catégories de promo (largeur, unicité, pré-sélection pour chaque
  métier du référentiel).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDbKbJrPWweXXYSxA8MXW7
2026-09-06 09:02:27 +00:00
Claude 8b46cd6316 Merge remote-tracking branch 'origin/main' into claude/seo-improvement-eilfsl 2026-09-06 06:56:00 +00:00
Claude bfc82b3b3b Tags adhérents automatiques : vocabulaire par métier, suggestions et repli
- src/lib/tags.ts (pur) : mots-clés par métier pour les 104 catégories,
  vocabulaire transversal détecté dans la description (fait maison, bio,
  livraison, sur rendez-vous, devis gratuit…), suggestTags() qui combine
  métier, commune et description, autoTags() qui n'intervient qu'à vide.
- Enregistrement : addMember, updateMember et updateOwnProfile passent par
  resolveMemberTags() ; un champ laissé vide reçoit les suggestions, une
  saisie n'est jamais remplacée.
- Formulaires : composant TagsField (champ non contrôlé + pastilles de
  suggestions recalculées à chaque frappe depuis catégorie, commune et
  description ; « Tout ajouter »), dans l'espace adhérent et la fiche staff.
- Fiche publique : les fiches jamais ré-enregistrées affichent les tags
  suggérés et les transmettent dans LocalBusiness.keywords.
- tests/tags.test.ts : couverture de chaque métier, nettoyage, détection,
  ordre des suggestions, non-écrasement.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDbKbJrPWweXXYSxA8MXW7
2026-09-06 06:51:25 +00:00
Claude c8417d8560 SEO : URLs de fiche parlantes, pages métier indexables, aides de rédaction
- Fiches adhérents : URL canonique /adherents/12-au-bon-pain-frouard
  (memberPath). L'identifiant en tête suffit ; /adherents/12 et tout slug
  périmé sont redirigés de façon permanente vers la forme canonique, donc
  les anciens liens et les partages sociaux restent valides. Tous les liens
  (accueil, carrousel, annuaire, JSON-LD, sitemap, publications Facebook /
  LinkedIn) passent par memberPath.
- Pages métier /annuaire/[categorie] : une page indexable par catégorie
  avec titre, introduction (effectif, communes), grille d'adhérents, fil
  d'Ariane et ItemList. Sans adhérent : noindex et hors sitemap. Section
  « Parcourir par métier » sur l'annuaire et « Autres métiers » sur chaque
  page pour le maillage interne.
- Composants partagés MemberCard et CategoryLinks ; slugify déplacé dans
  src/lib/slug.ts.
- Backend : conseils de rédaction sous les champs description et tags des
  formulaires adhérent (espace adhérent et fiche staff).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDbKbJrPWweXXYSxA8MXW7
2026-09-06 06:29:28 +00:00
Claude f9d0d6c825 Accueil, pied de page et catégories : compteur réel, boutons alignés, référentiel complété
- Accueil : le bandeau « Faites partie de l'aventure » affiche le nombre
  d'adhérents validés (statut active) au lieu de « plus de 120 » codé en dur.
- Pied de page : les boutons Facebook / LinkedIn ont la même largeur (grille à
  une colonne dimensionnée sur le plus long) et une icône à largeur fixe.
- Catégories : référentiel unique dans src/db/categories.ts (104 métiers, dont
  assurance, bazar/discount, grande distribution, librairie-presse, tabac,
  vétérinaire, auto-école, etc.), inséré par la migration 0012 avec
  ON CONFLICT (slug) : les catégories manquantes apparaissent au prochain
  démarrage, sans écraser un libellé renommé depuis le backend. Le seed et
  l'action « Ajouter une catégorie » réutilisent ce référentiel et sa palette.
- tests/categories.test.ts vérifie unicité des slugs, contraintes de la table
  et cohérence entre le référentiel et la migration.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDbKbJrPWweXXYSxA8MXW7
2026-09-06 06:18:37 +00:00
Claude 320e63b749 Caddy : répondre aussi sur l'IP, pour installer avant que le DNS ne soit posé
Le Caddyfile gagne un bloc fourre-tout http:// à côté du bloc du nom de
domaine. L'installation complète, Caddy compris, devient donc utilisable
immédiatement sur http://IP_DU_SERVEUR, et bascule seule en HTTPS dès que le
DNS pointe sur la machine : Caddy réessaie la demande de certificat en
arrière-plan, sans intervention ni redémarrage.

Le préfixe http:// désactive le HTTPS automatique pour ce seul bloc, aucune
autorité ne délivrant de certificat pour une IP nue. Le bloc du domaine reste
prioritaire et sa redirection HTTP vers HTTPS est insérée avant le fourre-tout.
Vérifié avec le binaire Caddy 2.8.4 : configuration valide, et à l'exécution un
308 vers https:// pour l'en-tête Host du domaine contre un passage au proxy
pour l'accès par IP.

Rend inutile la surcouche docker-compose.http.yml, retirée, la surcouche Caddy
couvrant désormais les deux situations. README et .env.example décrivent ce
parcours unique.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8UUiQtBWYWNFcsea8spnX
2026-09-05 15:26:40 +00:00
Claude 8414c7201e Installation VPS : surcouche HTTP port 80 pour valider avant le domaine
Ajoute docker-compose.http.yml, qui publie l'application sur le port 80 et
laisse Compose créer le réseau proxy. Permet de valider une installation sur
l'IP du serveur avant que le DNS ne soit en place, sans HTTPS possible à ce
stade (pas de certificat pour une IP nue).

Documente les deux phases dans le README et .env.example : HTTP simple d'abord,
puis bascule vers la surcouche Caddy en changeant la seule ligne COMPOSE_FILE,
les volumes conservant base, compte admin et secret de session.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8UUiQtBWYWNFcsea8spnX
2026-09-05 15:21:31 +00:00
Claude 0562d07254 Installation VPS sans reverse proxy : Caddy + Let's Encrypt en surcouche Compose
Ajoute docker-compose.caddy.yml et un Caddyfile pour servir le site sur un
nom de domaine en 80/443 avec certificat automatique, sans Nginx Proxy
Manager. Le port 8413 n'est plus publié dans ce mode et le réseau proxy est
créé par Compose. Procédure documentée dans le README et .env.example.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8UUiQtBWYWNFcsea8spnX
2026-09-05 15:08:45 +00:00
Claude a8adb9ae6a Référencement : métadonnées complètes, données structurées, sitemap et robots
- Layout : metadataBase, gabarit de titre « %s · Plein R », description,
  mots-clés, Open Graph / Twitter, robots, icônes, manifeste, viewport et
  couleur de thème ; JSON-LD Organization + WebSite (recherche annuaire).
- Chaque page publique pose titre, description (≤ 160 caractères) et
  canonique via pageMetadata() ; l'annuaire ignore ainsi ?q=. Fiche adhérent :
  titre « Nom · Catégorie à Ville », description dérivée, LocalBusiness avec
  adresse, contacts et horaires (OpeningHoursSpecification), BreadcrumbList.
  Annuaire : ItemList des adhérents. Association : BusinessEvent par
  rencontre à venir. Rencontres passées : fil d'Ariane.
- Nouvelles routes : robots.txt, sitemap.xml (pages statiques + adhérents
  actifs), manifest.webmanifest, image de partage 1200×630 générée avec le
  logo, page 404 dédiée (noindex).
- /backend, /login, /inscription/* et les fiches inactives en noindex.
- Landmarks <main> sur toutes les pages publiques, fil d'Ariane en <nav>.
- serializeJsonLd() échappe <, > et & : les saisies des adhérents ne peuvent
  pas fermer le bloc <script>. Tests unitaires dans tests/seo.test.ts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDbKbJrPWweXXYSxA8MXW7
2026-09-05 10:41:53 +00:00
LogiFlow dd350038f0 Durcissement (lot A) : seed sûr et vide par défaut, mots de passe temporaires affichés une seule fois (#4)
Durcissement (lot A) : seed sûr et vide par défaut, mots de passe temporaires affichés une seule fois
2026-09-04 12:38:50 +02:00
Claude 741dd53b40 Base vide par défaut : données de démonstration isolées et purgeables
Le seed ne crée plus que le référentiel des catégories et le compte
administrateur initial. Les adhérents, promotions, demandes d'adhésion,
entrées du journal et comptes de démonstration sont déplacés dans
src/db/demo-data.ts et ne sont insérés qu'avec SEED_DEMO=true.

Nouveau script `npm run db:purge-demo` (bundlé en dist/purge-demo.cjs pour le
conteneur) : retire exactement ce jeu de démonstration d'une base qui l'a reçu
lors des démarrages précédents, sans toucher aux saisies de l'association ni
aux catégories. Idempotent, en transaction ; un compte réel rattaché à une
fiche de démo est détaché et non supprimé.

Vérifié en local sur Postgres 16 : seed par défaut = 59 catégories + 1 admin
et rien d'autre ; seed avec SEED_DEMO=true = jeu complet ; purge = retour à
l'état vide ; seconde purge sans effet.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RAQsCp4nnZbwexCg7NDBHE
2026-09-04 09:23:44 +00:00
Claude 3b17d7ff42 Durcissement (lot A) : seed sûr en production, mots de passe temporaires affichés une seule fois
Audit de sécurité complet de l'application, puis correctifs du lot A validés :

- Seed : plus aucun compte de démonstration (mot de passe « changeme123 »)
  sans SEED_DEMO=true. L'administrateur initial reçoit un mot de passe
  aléatoire affiché une fois dans les journaux (ou SEED_ADMIN_PASSWORD) et
  doit le changer à la première connexion. docker-compose ne fournit plus de
  mot de passe par défaut.
- Mots de passe temporaires : la colonne users.temp_password (en clair) est
  supprimée (migration 0011). Création d'adhérent, réinitialisation,
  rattrapage des comptes manquants, approbation de demande et invitation
  staff renvoient les identifiants, affichés une seule fois par le composant
  OneTimeCredentials, sans redirection. L'invitation staff, qui ne
  communiquait jamais le mot de passe, redevient utilisable et impose le
  changement à la première connexion.
- Sessions JWT limitées à 7 jours (30 auparavant).
- En-têtes : Strict-Transport-Security ajouté, X-Powered-By supprimé.
- docker-compose : port Postgres publié sur 127.0.0.1 uniquement.
- Image Docker sur node:22 (Node 20 en fin de vie).
- Dépendances : next 15.5.25, pg 8.23, nanoid 3.3.18 (avis GHSA-2v37-7h3g-55p8).

Vérifié en local : tests, typage, build de production, migration + seed sur
un Postgres 16, et parcours navigateur complet (première connexion, changement
forcé, création / réinitialisation / invitation avec affichage unique, cookie
de session à 7 jours).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RAQsCp4nnZbwexCg7NDBHE
2026-09-04 09:15:45 +00:00
Claude dd7bbfb777 Refonte du pied de page
Trois colonnes dont une très large laissaient le tiers droit vide, les titres ne
se distinguaient pas des liens, et les deux icônes sociales flottaient en bas à
gauche sans rattachement.

- Quatre colonnes : marque, Accès rapide, Informations, Nous suivre. La largeur
  est enfin occupée, et la dernière colonne accueille aussi les coordonnées de
  l'association dès qu'elles sont renseignées dans Paramètres.
- Titres en capitales espacées sur le brun de la marque : ils reprennent le
  vocabulaire des pastilles utilisées partout ailleurs sur le site, et se lisent
  désormais comme des titres.
- Icônes sociales remplacées par des boutons libellés, calés à gauche pour ne
  pas s'étirer en pavés vides.
- Filet doré en haut du bloc, pastille dorée devant « Bassin de Pompey » : la
  page se referme au lieu de s'arrêter sur une ligne grise.
- Liens plus aérés, décalage léger au survol.
- Deux entrées utiles ajoutées : Rencontres passées et Espace adhérent.
- Replis : 3 colonnes sur tablette, 2 sur téléphone avec la marque et les
  réseaux sur toute la largeur.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
2026-07-25 13:11:29 +00:00
Claude b0788d3f20 Durcissement : mot de passe actuel, sessions révocables, dépendances, CSP, tests
Suite de l'audit : les cinq points laissés en suspens sont traités.

Mot de passe
- `changeOwnPassword` exige désormais le mot de passe actuel. Un poste laissé
  ouvert ne suffit plus à s'approprier un compte. Les erreurs reviennent sur
  l'écran avec un message au lieu d'une page d'erreur brute.

Sessions révocables
- Colonne `users.session_version`, portée dans le jeton et comparée à la base.
- `getSession()` (src/lib/session.ts) remplace `auth()` sur les 20 pages et
  actions : rôle, rattachement adhérent et existence du compte sont relus à
  chaque requête. Supprimer un compte ou réinitialiser un mot de passe coupe
  immédiatement les sessions ouvertes, sans attendre l'expiration du jeton.

Dépendances — de 8 vulnérabilités (2 critiques) à zéro
- next 15.5.19 → 15.5.21, next-auth beta.25 → beta.32, drizzle-orm 0.38 → 0.45.
- postcss et sharp forcés par `overrides` sur leurs versions corrigées, Next ne
  les ayant pas encore reprises ; drizzle-kit et esbuild montés côté outillage.
- `npm audit fix --force` a été écarté : il proposait de RÉTROGRADER Next en
  9.3.3 et eslint-config-next en 12, ce qui aurait cassé l'application.
- `eslint-config-next` traînait dans node_modules sans être déclaré : retiré.

CSP complète
- Politique à nonce posée par le middleware, nonce régénéré à chaque requête,
  `script-src` sans 'unsafe-inline'. `style-src` garde 'unsafe-inline' : tout le
  design repose sur des attributs style, et une injection de style n'a pas la
  portée d'une injection de script.
- Conséquence assumée : rendu dynamique pour toutes les pages, un HTML
  pré-généré ne pouvant pas porter de nonce.

Tests
- `npm test` (runner natif node:test via tsx), 18 tests sur le filtre XSS, la
  limitation des tentatives de connexion et le chiffrement des jetons.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
2026-07-25 10:18:04 +00:00
Claude 2874d79c9e Correctifs de sécurité : XSS stocké, SSRF, force brute, aléa des mots de passe
Audit de l'application. Cinq corrections, la première critique.

XSS stocké non authentifié (critique)
- Le journal d'activité était rendu avec `dangerouslySetInnerHTML` alors qu'il
  agrège des saisies de tiers : titre de promotion d'un adhérent, et surtout nom
  laissé dans le formulaire de contact PUBLIC. N'importe quel visiteur pouvait
  donc faire exécuter du script dans le navigateur d'un administrateur, et
  prendre la main sur son compte.
- Défense aux deux bouts : `sanitizeActivityMessage` ne laisse passer que
  <strong> à l'écriture, et `activityNodes` rend le message en éléments React —
  un message déjà stocké en base ne peut plus rien injecter.

SSRF
- `loadPromoImage` allait chercher toute URL http(s) placée dans le champ image
  d'une promotion, et en poussait le contenu vers Facebook ou LinkedIn : de quoi
  atteindre un service interne ou les métadonnées de l'hébergeur. Seules les
  data-URI sont désormais acceptées, ce qui couvre tous les usages réels.
- Les images sont en plus validées à l'écriture (type et taille), côté promotion
  comme côté profil adhérent.

Authentification
- Aucun frein sur la connexion : blocage progressif après 8 échecs par compte.
- La comparaison bcrypt est menée même sans compte correspondant, sinon le temps
  de réponse révélait quelles adresses existent.

Aléa
- Les mots de passe temporaires venaient de `Math.random()`, prédictible :
  passage à `randomInt` de node:crypto, et longueur portée à 12.

En-têtes
- nosniff, X-Frame-Options, Referrer-Policy, Permissions-Policy et
  frame-ancestors. Une CSP complète demandera des nonces sur les scripts Next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
2026-07-25 09:02:25 +00:00
Claude fb3d5e337e Page Association : mission développée et bloc « ce que l'association apporte »
Le bandeau Mission tenait en une phrase et ne disait pas ce que l'adhésion
apporte concrètement.

- Mission réécrite en trois paragraphes : le réseau et les rencontres, la mise
  en avant de chaque adhérent auprès des habitants et des professionnels, et
  l'objectif double clients de proximité / courants d'affaires locaux.
- Nouveau bloc de quatre cartes sous le bandeau : se rencontrer, gagner en
  visibilité (site et réseaux sociaux), attirer des clients en B to C,
  développer des affaires en B to B.
- Les deux contenus sont éditables dans Paramètres : la mission accepte des
  paragraphes séparés par une ligne vide, les apports suivent la convention
  « Titre|Description » déjà utilisée pour le directoire.
- Migration 0009 : le texte de mission n'est remplacé que s'il est resté celui
  d'origine, une version réécrite à la main n'est jamais écrasée.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
2026-07-25 08:36:51 +00:00
Claude e86c67c17e URL publique du site réglable depuis le backoffice
Connecter un réseau social imposait de poser NEXT_PUBLIC_SITE_URL dans
docker-compose et de redémarrer le conteneur. L'écran Réseaux sociaux porte
désormais le champ, pré-rempli avec l'adresse par laquelle l'administrateur
consulte la page.

- Nouveau réglage `site_public_url` ; `siteUrl()` lit la base puis retombe sur
  les variables d'environnement, donc les déploiements actuels ne bougent pas.
- `siteUrl()`, `redirectUri()`, `authorizeUrl()`, `promoLink()` et
  `buildPromoMessage()` deviennent asynchrones.
- Seule l'origine est conservée : le schéma est ajouté s'il manque, un chemin
  est retiré, et une adresse invalide revient sur l'écran avec un message au
  lieu d'une page d'erreur brute.
- `saveSiteSettings` saute cette clé : le formulaire Paramètres ne la contient
  pas et l'aurait écrasée à chaque enregistrement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
2026-07-25 08:28:04 +00:00
Claude db361051e6 Menu d'administration regroupé et version mobile du site
Le menu empilait douze liens sous un seul intitulé, avec des carrés, ronds et
losanges qui ne portaient aucun sens. Sur téléphone, la barre latérale se
dépliait en ligne et devenait un pavé de boutons inutilisable.

Navigation
- Six rubriques au lieu d'une pile : Pilotage, Vie du réseau, Rencontres,
  Communication, Configuration, Côté adhérent. Le quotidien est ainsi séparé de
  ce qu'on ne touche qu'occasionnellement.
- Une icône par entrée (`BackendIcons.tsx`) à la place des formes génériques.
- Le menu devient une table de données filtrée par capacité, au lieu de douze
  blocs JSX répétant les mêmes appels à `can()`.

Mobile
- Sous 1024px la barre latérale devient un tiroir : voile de fond, fermeture par
  Échap, par clic hors zone et à la navigation, défilement de la page bloqué,
  actions Voir le site / Déconnexion rapatriées dans le tiroir.
- `min-height: 0` sur la zone de navigation : sans lui l'enfant flex refusait de
  rétrécir et la carte utilisateur recouvrait les derniers liens, interceptant
  les clics.
- En-tête public repensé : logo toujours présent sur téléphone (seul retour à
  l'accueil), accès adhérent sur la même ligne, navigation répartie en dessous
  au lieu de se replier en escalier.
- Liste des adhérents : le tableau à cinq colonnes devient une fiche par
  adhérent, sur deux lignes constantes, sans défilement horizontal.
- Compteurs du tableau de bord en deux colonnes plutôt qu'empilés.

Vérifié : aucun débordement horizontal sur les 23 pages en 320, 390 et 768 px ;
tiroir et cibles tactiles testés au doigt sur toutes les interactions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
2026-07-25 00:25:56 +00:00
Claude 19d1104ab0 Connexion Facebook / LinkedIn en OAuth depuis le backoffice
Les jetons se posaient à la main dans docker-compose. Un écran Backend ›
Réseaux sociaux permet désormais de coller les identifiants de l'application,
cliquer Connecter, choisir la page, et ne plus y revenir.

- Table `social_accounts` : identifiants, jetons et cible par réseau. Tous les
  secrets sont chiffrés en AES-256-GCM (`src/lib/crypto.ts`), clé
  `SOCIAL_TOKEN_KEY` avec repli sur `AUTH_SECRET`.
- Routes `api/social/[network]/{connect,callback}` : `state` anti-CSRF en cookie
  httpOnly, échange du code, récupération des pages administrées. Aucun jeton ne
  transite par une URL — les cibles sont relistées côté serveur au moment de la
  sélection.
- Facebook : jeton utilisateur longue durée puis jeton de PAGE, qui n'expire
  pas. LinkedIn : jeton 60 jours, rafraîchi automatiquement si l'application a
  obtenu les jetons de rafraîchissement programmatiques.
- Faute de quoi le backoffice affiche la date d'expiration, un bandeau sur le
  tableau de bord à J-7 et un bouton Reconnecter.
- `social.ts` lit les identifiants via `social-accounts.ts` : base d'abord,
  variables d'environnement ensuite. Les installations existantes continuent de
  fonctionner sans modification.
- Le secret d'application n'est jamais renvoyé au navigateur : champ vide =
  valeur conservée.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
2026-07-24 23:00:08 +00:00
Claude 8bc1b17fc5 Diffusion réseaux pilotée par la validation de la promotion
Le bouton libre « Publier sur X » permettait de diffuser n'importe quelle promo
en ligne, à tout moment. Le choix appartient désormais à l'adhérent, la
publication part à la validation, et le choix est figé ensuite.

- Colonnes `promotions.share_facebook` / `share_linkedin` : réseaux demandés à la
  soumission, modifiables tant que la promo est `pending`.
- L'adhérent coche les réseaux dans le formulaire de soumission et peut encore
  les changer depuis Mes promotions tant qu'elle est en attente ; une fois
  validée, il ne voit plus qu'un état en lecture seule.
- Le modérateur retrouve ces cases pré-cochées dans le formulaire « Valider » et
  peut les ajuster : c'est le dernier moment où la diffusion est modifiable.
  Valider met en ligne puis publie, dans cet ordre, afin qu'un réseau en panne
  ne bloque pas la mise en ligne.
- `publishPromoShares` devient le seul point de publication. Elle ne lève jamais
  et ignore tout réseau ayant déjà une publication réussie : une promo n'est
  jamais postée deux fois, y compris après un cycle suspension → remise en ligne.
- `sharePromoToSocial` est remplacée par `retryPromoShare`, limitée au rattrapage
  d'un échec sur un réseau déjà choisi, sur une promo en ligne uniquement.
- Un réseau non configuré sur le serveur enregistre un échec explicite plutôt
  que d'échouer en silence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
2026-07-24 22:41:25 +00:00
Claude e3a4771edf Refonte du bloc infos pratiques, suspension des promos et publication réseaux
Bloc « Informations pratiques » (fiche adhérent)
- Suppression des dégradés hors palette (radial doré de la section, dégradés
  crème/bleu des deux sous-cartes) au profit du vocabulaire visuel du site :
  carte blanche bordée, sous-cartes plates sur le fond de champ, variables CSS.
- La pastille « Préparer votre visite » devient une puce arrondie identique aux
  badges du reste du site ; le chip d'ouverture reprend les teintes de statut.
- Les glyphes texte ◷ et ↗ sont remplacés par des icônes SVG cohérentes.

Suspension des promotions
- Nouveau statut `suspended` + colonnes `suspended_by` / `suspended_by_id` /
  `suspended_at`.
- L'adhérent suspend et réactive ses propres promotions depuis Mon espace ;
  l'association suspend et réactive n'importe laquelle depuis le backoffice.
- Une suspension décidée par l'association ne peut pas être levée par
  l'adhérent : le motif est affiché dans son espace.
- Les lectures publiques filtrant déjà sur `live`, une promo suspendue quitte
  immédiatement l'accueil, l'annuaire et la fiche.

Publication Facebook / LinkedIn
- `src/lib/social.ts` : publication d'une promo en ligne sur la page Facebook
  (Graph API, upload multipart car les images sont en data-URI) et sur la page
  LinkedIn (Posts API + Images API en trois étapes).
- Jetons d'accès exclusivement en variables d'environnement ; un réseau non
  configuré masque son bouton.
- Table `social_posts` : chaque tentative est tracée, un échec est rattrapé et
  affiché sur la carte de la promo sans interrompre le backoffice.
- Nouvelle capacité RBAC `publishSocial` (admin + modérateur).

Liens sociaux publics
- Réglages `association_facebook` / `association_linkedin` éditables dans
  Paramètres, préremplis avec les pages de l'association.
- Section « Plein R sur les réseaux » sur l'accueil et icônes dans le pied de
  page, masquées si le réglage est vide.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
2026-07-24 22:20:51 +00:00
Michael dd9697019c Refonte des horaires et contacts adhérents 2026-07-23 16:22:28 +02:00
Michael 5e7233f98d polish meeting and consent views 2026-07-18 04:17:28 +02:00
Michael afc805c96a separate meetings and add member registrations 2026-07-18 03:56:54 +02:00
Michael b2d428b08b Add Plein R email composers 2026-07-17 19:53:42 +02:00
Michael 28ada88155 Add association meetings and GDPR settings 2026-07-17 17:47:24 +02:00
Michael b4c8967a3c Améliore les modales et les informations du site 2026-07-17 09:00:29 +02:00
192 changed files with 44491 additions and 7127 deletions

No files matched your search

+70 -1
View File
@@ -23,9 +23,78 @@ AUTH_URL=
# ---- First admin user (created by the seed script) ----
SEED_ADMIN_EMAIL=admin@plein-r.fr
SEED_ADMIN_PASSWORD=changeme123
# Laissez vide : un mot de passe aléatoire est généré au premier démarrage,
# affiché UNE FOIS dans les journaux, et doit être changé à la première connexion.
SEED_ADMIN_PASSWORD=
SEED_ADMIN_NAME=Administrateur Plein R
# Données de démonstration (adhérents, promotions, demandes, journal et
# comptes au mot de passe connu de tous) : uniquement sur un poste de
# développement, jamais en production. Par défaut la base reste vide, avec le
# référentiel des catégories et le compte administrateur seulement.
# Pour retirer des données de démo déjà en base : npm run db:purge-demo
# (ou, dans le conteneur : node dist/purge-demo.cjs).
SEED_DEMO=false
# ---- App ----
NODE_ENV=production
PORT=3000
# ---- VPS sans reverse proxy existant (voir README) ----
# Caddy devant l'application : ports 80 et 443, certificat Let's Encrypt
# automatique pour SITE_DOMAIN, et réponse immédiate sur http://IP_DU_SERVEUR
# tant que le DNS n'est pas configuré. Renseignez SITE_DOMAIN même si le
# domaine ne pointe pas encore : la bascule en HTTPS se fera toute seule.
#COMPOSE_FILE=docker-compose.yml:docker-compose.caddy.yml
#SITE_DOMAIN=pleinr.example.fr
#CADDY_EMAIL=vous@example.fr
# ---- Publication des promotions sur les réseaux sociaux ----
# La configuration se fait désormais dans Backend › Réseaux sociaux : on y colle
# les identifiants de l'application, on clique Connecter, et les jetons sont
# récupérés et stockés chiffrés automatiquement. Rien à mettre ici dans le cas
# normal.
#
# Clé de chiffrement des jetons en base. Si elle est vide, AUTH_SECRET est
# utilisé. La définir explicitement permet de faire tourner AUTH_SECRET sans
# perdre les connexions réseaux.
# openssl rand -base64 32
SOCIAL_TOKEN_KEY=
# Versions d'API (facultatif)
FACEBOOK_GRAPH_VERSION=v21.0
LINKEDIN_API_VERSION=202506
# --- Repli historique ---
# Ces variables restent lues si aucun compte n'est connecté depuis le
# backoffice. Utile pour un jeton posé à la main ; sinon, laissez vide.
FACEBOOK_PAGE_ID=
FACEBOOK_PAGE_ACCESS_TOKEN=
LINKEDIN_ORGANIZATION_URN=
LINKEDIN_ORGANIZATION_ID=
LINKEDIN_ACCESS_TOKEN=
# ---- Boîte mail de l'association ----
# La configuration se fait dans Backend › Boîte mail : on y branche Google
# (API Gmail), Microsoft (API Graph) ou un serveur SMTP, et les secrets sont
# stockés chiffrés. Rien à mettre ici dans le cas normal.
#
# Les jetons et mots de passe utilisent la même clé que les réseaux sociaux
# (SOCIAL_TOKEN_KEY, avec repli sur AUTH_SECRET).
#
# `off` arrête la file d'envoi sans toucher au reste (le libérateur de
# publications programmées a son propre interrupteur, PROMO_SCHEDULER).
#MAIL_WORKER=off
# Débit de la file, en messages par passage (défaut : 20). Gmail plafonne
# autour de 500 destinataires par jour sur un compte gratuit.
#MAIL_RATE_PER_MINUTE=20
# --- Repli avant toute configuration depuis le backoffice ---
# Permet un premier déploiement sans passer par l'écran. Dès qu'un compte est
# enregistré dans Backend › Boîte mail, il prime sur ces variables.
SMTP_HOST=
SMTP_PORT=465
SMTP_SECURE=true
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=
SMTP_FROM_NAME=
+392 -2
View File
@@ -23,7 +23,8 @@ npm run dev # dev server
npm run build # production build
npm run db:generate # regenerate SQL after editing src/db/schema.ts
npm run db:migrate # apply migrations
npm run db:seed # seed demo data
npm run db:seed # catégories + admin initial ; données de démo si SEED_DEMO=true
npm run db:purge-demo # retire les données de démo d'une base qui les a reçues
docker compose up --build # full stack
```
@@ -33,19 +34,408 @@ docker compose up --build # full stack
design-system in `src/app/globals.css` (palette as CSS vars, fonts, twinkle/float
animations, hover lifts, responsive grid helpers). No Tailwind.
- **Mutations**: server actions in `src/app/backend/actions.ts`. Each action
re-checks auth + capability via `auth()` and `can()` before writing, then
re-checks auth + capability via `getSession()` and `can()` before writing, then
`revalidatePath()`.
- **Access control**: `src/middleware.ts` gates `/backend/*`; each page further
guards by role (`isStaff`, `can`) and redirects.
- **Data reads** for the public site live in `src/lib/queries.ts`.
- After changing `src/db/schema.ts`, run `npm run db:generate` and commit the new
file under `drizzle/`.
- **Catégories** : le référentiel vit dans `src/db/categories.ts` (slugs stables,
jamais renommés). Il est inséré par la migration `0012_referentiel_categories`
(`ON CONFLICT (slug)` : seul `sort` est réaligné, un libellé renommé depuis le
backend est conservé) et rejoué par le seed. Pour ajouter une catégorie, on
l'ajoute au référentiel **et** on génère une nouvelle migration `--custom`
du même modèle ; `tests/categories.test.ts` vérifie la cohérence.
## Promotions
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 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
- `src/lib/social.ts` publie une promo sur la page Facebook (Graph API) ou
LinkedIn (Posts API). `src/lib/social-accounts.ts` gère la configuration : OAuth,
jetons, cibles. Réseau non configuré = case masquée.
- Les identifiants et jetons vivent en base (`social_accounts`), **chiffrés** via
`src/lib/crypto.ts` (AES-256-GCM, clé `SOCIAL_TOKEN_KEY` ou `AUTH_SECRET`), posés
depuis `/backend/reseaux`. Les variables d'environnement restent lues en repli.
Aucun secret ne doit jamais repartir vers le navigateur.
- `isNetworkConfigured()` / `configuredNetworks()` / `siteUrl()` / `redirectUri()`
sont **asynchrones** (accès base).
- L'URL publique est le réglage `site_public_url`, édité sur `/backend/reseaux`
(pré-rempli depuis les en-têtes de la requête). `saveSiteSettings` **saute**
cette clé : le formulaire Paramètres ne la contient pas et l'écraserait.
- Routes OAuth : `src/app/api/social/[network]/{connect,callback}`. Le `state`
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.
- 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 `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.
- `getSession()` (`src/lib/session.ts`) remplace `auth()` partout : le rôle et le
rattachement adhérent sont relus en base à chaque requête, et
`users.session_version` invalide les jetons émis avant un changement de mot de
passe. N'appelez plus `auth()` directement depuis une page ou une action.
- Les images ne sont acceptées qu'en data-URI (`asImageDataUri`) : une URL ferait
appeler par le serveur une cible choisie par l'utilisateur (SSRF).
- La CSP à nonce est posée par `src/middleware.ts`. Elle impose un rendu
dynamique : `export const dynamic = "force-dynamic"` est dans `app/layout.tsx`,
un HTML pré-généré ne pouvant pas porter de nonce.
- Les mots de passe temporaires (création d'adhérent, réinitialisation,
invitation staff) ne sont **jamais stockés** : l'action les renvoie et le
composant `OneTimeCredentials` les affiche une seule fois, sans redirection.
`users.must_change_password` seul persiste.
- Le seed ne crée en production que le référentiel des catégories et
l'administrateur initial, avec un mot de passe aléatoire affiché une fois
dans les journaux (ou `SEED_ADMIN_PASSWORD`) et un changement obligatoire à
la première connexion. Toutes les données de démonstration (adhérents,
promotions, demandes, journal, comptes `changeme123`) vivent dans
`src/db/demo-data.ts`, exigent `SEED_DEMO=true`, et `npm run db:purge-demo`
les retire d'une base existante.
- Sessions JWT limitées à 7 jours (`auth.config.ts`), HSTS et suppression de
`X-Powered-By` dans `next.config.mjs`. Le port Postgres de `docker-compose`
n'est publié que sur `127.0.0.1`.
- `npm test` verrouille ces protections (`tests/security.test.ts`) : XSS du
journal, limitation des connexions, chiffrement des secrets, coordonnées du
référent, **mots de passe temporaires jamais mis en file**, secrets de
messagerie jamais renvoyés au navigateur, aucune copie cachée dans la chaîne
d'envoi, rien rendu en HTML brut côté informations, et **le HTML de l'éditeur
visuel qui ne quitte jamais la page** (formulaire à champ caché, collage passé
par `DOMParser`, réinjection limitée à `richTextToEditorHtml`).
## Référencement (SEO)
- `src/lib/seo.ts` est **pur** (constantes, `pageMetadata()`, générateurs JSON-LD,
`serializeJsonLd()` qui échappe `<>&`) et verrouillé par `tests/seo.test.ts`.
`src/lib/seo-server.ts` fournit `publicBaseUrl()` : réglage `site_public_url`,
puis variables d'environnement, puis en-têtes de la requête.
- `app/layout.tsx` pose `metadataBase`, le gabarit de titre `%s · Plein R`,
Open Graph / Twitter, `robots`, le manifeste, et les JSON-LD `Organization` +
`WebSite`. Chaque page publique appelle `pageMetadata({ title, description,
path })` : le `path` sert de canonique (l'annuaire ignore ainsi `?q=`).
- Données structurées par page via `<JsonLd data={…} />` : `BreadcrumbList`
partout, `ItemList` sur l'annuaire, `LocalBusiness` (+ horaires) sur la fiche
adhérent, `BusinessEvent` pour chaque rencontre à venir.
- `app/robots.ts`, `app/sitemap.ts` (pages statiques + adhérents actifs),
`app/manifest.ts` et `app/opengraph-image.tsx` (vignette 1200×630 générée).
- `/backend`, `/login`, `/inscription/*` et les fiches non actives sont en
`NOINDEX` ; les pages publiques utilisent `<main>` et un seul `<h1>`.
- **Mesure d'audience** : `src/lib/analytics.ts` est **pur**
(`tests/analytics.test.ts`). `normalizeMeasurementId()` n'accepte qu'un
identifiant GA4 (`G-…`, ni `UA-…` ni texte libre) : c'est la garde qui interdit
d'injecter une chaîne arbitraire dans le script en ligne. Le réglage
`google_analytics_id` (Paramètres) vide = **aucune balise posée**.
`<Analytics>` (`src/components/Analytics.tsx`) rend les deux `<script>` avec le
nonce de la CSP et n'agit que sur les pages publiques : `isTrackedPath()`
écarte `PRIVATE_PATHS`, le chemin venant de l'en-tête `x-pathname` posé par le
middleware (un layout ne connaît pas l'URL demandée). La CSP liste les hôtes
`*.google-analytics.com` / `*.analytics.google.com` / `*.googletagmanager.com`
en `connect-src` sans condition : elle est posée sur le runtime Edge, qui ne
peut pas interroger la base. `script-src` n'a pas besoin d'eux —
`'strict-dynamic'` couvre les scripts que gtag.js charge ensuite.
- **Search Console** : le réglage `google_site_verification` alimente
`verification.google` dans `app/layout.tsx` (balise `<meta>`).
`normalizeSiteVerification()` refuse la balise `<meta>` complète collée telle
quelle. Le plan du site est déjà servi sur `/sitemap.xml` et annoncé dans
`/robots.txt` : il n'y a aucun fichier à déposer, seulement l'URL à déclarer.
- **Tags adhérents** : `src/lib/tags.ts` (pur) porte un vocabulaire par métier
(`CATEGORY_TAGS`, un par slug du référentiel, vérifié par `tests/tags.test.ts`)
et un vocabulaire transversal détecté dans la description. `autoTags()` ne
remplit le champ qu'à vide : à l'enregistrement (`resolveMemberTags` dans
`backend/actions.ts`) et à l'affichage de la fiche publique. Le composant
`TagsField` propose les suggestions en pastilles cliquables dans les deux
formulaires. Un nouveau métier dans `categories.ts` exige son entrée dans
`CATEGORY_TAGS`.
- **URLs de fiche** : `memberPath({ id, name, city })` donne
`/adherents/12-au-bon-pain-frouard`. L'identifiant en tête suffit
(`parseMemberParam`), toute autre écriture est redirigée en 301 vers la forme
canonique par la page : ne construisez jamais `/adherents/${id}` à la main.
- **Pages métier** : `/annuaire/[categorie]` (slug de `categories`) rend une page
indexable par activité (titre, intro, grille `MemberCard`, `ItemList`) ;
sans adhérent elle passe en `NOINDEX` et sort du sitemap. L'annuaire et ces
pages listent les métiers via `CategoryLinks` pour le maillage interne.
## Adhérents
- L'espace adhérent est en deux pages : `/backend/espace` (profil, inscriptions,
droit à l'image) et `/backend/espace/promotions` (dépôt et suivi des promos,
les offres **en ligne** en tête). `EspaceHeader` porte le bandeau et les
onglets ; toute action qui touche l'espace revalide les deux chemins.
- **Pas de barre latérale hors staff** : `BackendShell` branche sur
`isStaff(user.role)`. Chez un adhérent la barre ne porterait que la section
« Côté adhérent », c'est-à-dire les trois entrées déjà rendues en onglets par
`EspaceHeader` — deux menus pour la même chose. Il ne reste qu'un bandeau de
marque, aligné sur la colonne de contenu (`max-width` 1040). Conséquence à ne
pas perdre de vue : sous 1024px les actions du compte vivaient **dans le
tiroir** ; sans tiroir, `.backend-header--plain` rétablit leur affichage dans
l'entête, sinon la déconnexion deviendrait inatteignable sur téléphone.
`tests/backend-nav.test.ts` verrouille les deux points. Le staff garde la
barre : elle y est la navigation principale, et « Côté adhérent » son seul
chemin vers son propre espace.
- La catégorie d'une promotion est un **type de produit ou de service**
(`src/lib/promo-categories.ts`, groupes pour `<optgroup>`), pas le métier de
l'adhérent ; `defaultPromoCategory(slug)` pré-sélectionne depuis le métier.
- **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
JSON-LD affichent `contact_email || email` : ne montrez jamais `email` seul.
- `VitrineImage` rend la couverture en `cover`, sinon le logo en `contain`
(~60 % d'un cadre à hauteur fixe) sur un fond du logo flouté, sinon un
placeholder rayé. Ne pas l'entourer d'un conteneur sans hauteur : les
`max-height` en % ne seraient plus résolus et le logo déborderait.
## Roles
`admin` > `moderator` > `editor` are staff; `member` is an adhérent linked to a
`members` row via `users.memberId`. Capability matrix is in `src/lib/rbac.ts`.
`manageInformations` (admin + modérateur) ouvre la rédaction des informations ;
écrire à tous les adhérents reste sous `manageEmails` (admin seul), et la
configuration de la boîte mail sous `manageSettings`. Un modérateur publie donc
une information sans pouvoir la diffuser.
## Informations adhérents
Le fil de l'espace adhérent (`/backend/espace/informations`, onglet en tête)
porte ce que publie le bureau depuis `/backend/informations`. Deux tables :
`informations` (brouillon → publiée, `pinned`, `email_sent_at`) et
`information_reads`.
- **Texte riche** : `src/lib/rich-text.ts` est **pur** (`tests/rich-text.test.ts`)
et analyse un sous-ensemble de Markdown — `**gras**`, `_italique_`, `- puce`,
`1. numéro`, `[texte](https://…)`, `## sous-titre`. Trois rendus, un seul
analyseur : `richTextNodes()` pour l'écran (des éléments React, jamais
`dangerouslySetInnerHTML`), `richTextToEmailHtml()` pour le message,
`richTextToEditorHtml()` pour remplir l'éditeur. Un lien hors `http`/`https`
perd sa cible et ne garde que son libellé (`safeHttpUrl`, partagé avec
`email-templates.ts`). L'italique s'écrit avec des tirets bas : `***x***`
serait ambigu pour l'analyseur, `**_x_**` ne l'est pas ; `*étoiles*` reste
accepté en lecture, et un tiret bas au milieu d'un mot
(`fichier_de_sauvegarde`) n'ouvre rien.
- **La syntaxe ne se montre jamais** : `RichTextEditor` est un
`contentEditable` avec une barre d'outils (G, I, Sous-titre, listes, lien).
L'adhérent qui rédige voit du gras, pas des étoiles. Le balisage voyage dans
un `<input type="hidden">`.
- **Le WYSIWYG porte sur la saisie, pas sur le stockage.** C'est l'invariant à
ne pas casser : à chaque frappe, `src/lib/rich-text-dom.ts`
(`serializeToRichText`, **pur**, `tests/rich-text-dom.test.ts`) retraverse le
contenu édité et n'en garde que le gras, l'italique, les listes, les
sous-titres et les liens. Le HTML de l'éditeur ne quitte jamais la page : la
base ne reçoit que le format balisé, que le serveur ré-analyse avec le même
analyseur qu'avant. Aucun assainisseur HTML n'est donc nécessaire.
Le module lit aussi le gras/italique **codés en style CSS**
(`<span style="font-weight:700">` de Google Docs), le style explicite
l'emportant sur la balise — sans quoi le `<b style="font-weight:normal">` dont
Docs enveloppe tout document mettrait le texte entier en gras.
- **Un collage est analysé par `DOMParser`**, jamais par une affectation
d'`innerHTML` : le document produit est inerte, donc un `<img onerror>` collé
ne s'exécute pas. Ce qui est réinjecté dans l'éditeur est du HTML **produit
par nous** (`richTextToEditorHtml`), jamais celui du presse-papier.
- **Les sauts de ligne sont normalisés à l'enregistrement** : l'encodage des
formulaires HTML convertit tout `\n` en `\r\n`, et le balisage stocké
différerait sinon de celui que l'éditeur a produit (`saveInformation`).
- **Une seule image**, en couverture, jamais dans le corps : une data-URI
recopiée dans chaque ligne de la file pèserait 3 Mo par destinataire. Elle est
servie aux clients mail par `/api/informations/[id]/image`, qui ne répond que
pour une information publiée — Gmail et Outlook suppriment les
`<img src="data:">`.
- **Non-lues** : une ligne par lecture (`information_reads`) plutôt qu'une date
« vu jusqu'ici ». C'est ce qui permet à la fois la pastille « Nouveau » par
information et le « lue par 12 / 40 » du back-office. Le marquage passe par
l'action `markInformationsRead`, appelée **après** affichage par
`MarkInformationsRead` : la pastille de l'onglet et celle de la barre latérale
sont calculées par deux composants serveur distincts, dont l'ordre de rendu
n'est pas garanti, et se contrediraient sur la même page.
- **Une seule épinglée** : appliqué dans l'action (`unpinOthers`), pas par un
index conditionnel que drizzle-kit ne génère pas.
- Les trois pages de l'espace passent `infoBadge` : sans cela le compteur
disparaîtrait en changeant d'onglet.
## Envoi d'e-mails
`/backend/boite-mail` est le décalque de `/backend/reseaux` : mêmes règles,
mêmes garanties. Trois transports dans `mail_accounts`, un seul `is_active` —
pas de cascade automatique, la bascule est un choix visible.
- **Google → API Gmail** (`gmail.send`) et non SMTP+XOAUTH2 : celui-ci exigerait
`https://mail.google.com/`, portée *restreinte* donc audit de sécurité.
**Microsoft → API Graph** (`/me/sendMail`) : l'authentification basique SMTP
est désactivée depuis 2024, y compris sur outlook.com et hotmail.com.
**SMTP générique** via `nodemailer`, seule dépendance d'envoi.
- **Port et chiffrement SMTP sont un seul choix.** `src/lib/smtp-config.ts` est
**pur** (`tests/smtp-config.test.ts`) : `impliedSecure()` dit que seul le port
465 chiffre dès l'ouverture, les autres (587, 25, 2525) passent par STARTTLS,
et `tlsMismatch()` prévient quand la case et le port se contredisent. Le
formulaire (`SmtpAccountForm`, client) réaligne la case sur le port choisi :
un TLS tenté sur le 587 échoue sur la bannière en clair (« wrong version
number »), un dialogue en clair sur le 465 attend une réponse qui ne vient
jamais. `describeSmtpError()` traduit ces échecs avant affichage —
l'explication d'abord, la trace ensuite, parce que l'écran tronque.
- Le transport pose `requireTLS` hors TLS implicite : sans lui, STARTTLS est
*opportuniste* et l'identifiant partirait en clair chez un serveur qui ne
l'annonce pas. Les délais (`connectionTimeout`, `greetingTimeout`) sont
ramenés à quelques secondes : ces connexions s'ouvrent depuis une action
serveur, et un port filtré en sortie — cas courant chez les hébergeurs — ne
doit pas bloquer l'écran deux minutes.
- `mail_accounts.from_address` est **lu chez le fournisseur** au retour OAuth,
jamais saisi : Gmail expédie comme l'utilisateur authentifié, Graph comme la
boîte, et une adresse d'un autre domaine ferait tomber SPF/DKIM.
- Microsoft fait **tourner** le jeton de rafraîchissement à chaque
renouvellement : `ensureAccessToken` réenregistre celui qui revient, sinon
l'envoi meurt au bout d'une heure.
- `src/lib/mime.ts` est **pur** (`tests/mime.test.ts`) : extrait de
`downloadOutlookDraft`, il sert le brouillon `.eml` **et** le champ `raw` de
l'API Gmail. CRLF stricts, mots encodés RFC 2047 repliés sans couper une
séquence UTF-8, CR/LF neutralisés dans les valeurs d'en-tête — un retour à la
ligne dans un objet permettrait sinon d'injecter un `Bcc:`.
- `src/lib/mailer.ts` porte `sendNow()`, qui **ne lève jamais** : un envoi raté
est un verdict, pas une exception.
- `src/lib/mail-outbox.ts` porte la file. Réclamation en
`UPDATE … FOR UPDATE SKIP LOCKED` — `releaseDuePromotions` s'en passe parce
que la transition de statut y fait office de verrou, pas ici. Faucheur des
verrous laissés par un conteneur arrêté en plein envoi, donc livraison **au
moins une fois**. ⚠ `db.execute` rend les colonnes **brutes** : il faut
reconvertir `to_address` en `toAddress`, le constructeur de requêtes le fait,
pas lui.
- **Les mots de passe temporaires ne passent jamais par la file** :
`mail_messages.html` est stocké en base. Les cinq actions à identifiants
appellent `sendNow` en ligne directe et journalisent une trace sans contenu
(`logSentMail`). `tests/security.test.ts` le verrouille. L'envoi est un plus,
jamais un point de rupture : `OneTimeCredentials` affiche le mot de passe quoi
qu'il arrive.
- **Une ligne `mail_messages` par destinataire** : `to_address` est un `varchar`
unique, la confidentialité d'une diffusion est structurelle.
- La configuration mail ne passe **surtout pas** par `site_settings` :
`saveSiteSettings` boucle sur toutes les clés de `SITE_SETTING_DEFAULTS` et
écrase d'une chaîne vide celles qu'aucun champ ne porte.
- La boucle d'envoi vit dans `src/instrumentation-node.ts`, à côté du libérateur
de promotions, et `next.config.mjs` déclare `serverExternalPackages:
["nodemailer"]`. **Chaque boucle a son propre interrupteur** (`MAIL_WORKER`,
`PROMO_SCHEDULER`) : couper l'un dans `instrumentation.ts` couperait l'autre,
puisque le module entier ne serait plus chargé.
- `emailBrand(settings)` (`src/lib/site-settings.ts`) compose l'identité en pied
de tous les messages : un seul endroit à changer.
## Docker notes
- Migrations + optional seed run on container start via `docker-entrypoint.sh`.
+29
View File
@@ -0,0 +1,29 @@
# Reverse proxy Caddy pour Plein R (voir docker-compose.caddy.yml).
#
# Deux entrées, pour que l'installation soit utilisable AVANT que le DNS ne
# soit configuré, et bascule toute seule ensuite :
#
# 1. {$SITE_DOMAIN} — le nom de domaine. Caddy obtient et renouvelle seul le
# certificat Let's Encrypt et redirige HTTP vers HTTPS. Tant que le DNS ne
# pointe pas sur le serveur, la demande de certificat échoue et Caddy
# réessaie en arrière-plan, avec un délai croissant : rien à relancer, le
# site devient joignable en HTTPS peu après la mise en place du DNS.
#
# 2. http:// — fourre-tout HTTP, qui répond notamment sur http://IP_DU_SERVEUR.
# Le préfixe http:// désactive explicitement le HTTPS automatique pour ce
# bloc (aucun certificat n'existe pour une IP nue). Le bloc du domaine étant
# plus spécifique, il reste prioritaire, et la redirection HTTP vers HTTPS
# du domaine est insérée avant ce fourre-tout.
{
email {$CADDY_EMAIL}
}
{$SITE_DOMAIN} {
encode zstd gzip
reverse_proxy pleinr-app:3000
}
http:// {
encode zstd gzip
reverse_proxy pleinr-app:3000
}
+3 -3
View File
@@ -1,13 +1,13 @@
# syntax=docker/dockerfile:1
# ---- deps: install all dependencies ----
FROM node:20-alpine AS deps
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
# ---- builder: build Next.js (standalone) + bundle db scripts ----
FROM node:20-alpine AS builder
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
@@ -16,7 +16,7 @@ RUN npm run build
RUN npm run build:scripts
# ---- runner: minimal production image ----
FROM node:20-alpine AS runner
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
+179 -1
View File
@@ -45,6 +45,57 @@ charge des données de démonstration. Ensuite :
> **Postgres déjà existant ?** Supprimez le service `postgres` de `docker-compose.yml`
> et pointez `DATABASE_URL` vers votre instance.
## Installation sur un VPS sans reverse proxy
Le `docker-compose.yml` suppose un Nginx Proxy Manager déjà en place (réseau
externe `nginx_default`). Sur un serveur nu, `docker-compose.caddy.yml` ajoute
**Caddy** devant l'application, sans modifier le fichier de base.
Caddy sert deux entrées à la fois (voir `Caddyfile`) :
| Adresse | Sert | Certificat |
|---|---|---|
| `http://IP_DU_SERVEUR` | tout de suite | aucun (impossible sur une IP nue) |
| `https://votre-domaine.fr` | dès que le DNS pointe sur le serveur | Let's Encrypt, automatique |
On installe donc **tout d'un coup**, on valide par l'IP, et la bascule en HTTPS
se fait ensuite d'elle-même : il n'y a **rien à relancer** au moment de
configurer le DNS. Tant que le domaine ne résout pas, Caddy réessaie la demande
de certificat en arrière-plan avec un délai croissant.
1. Installer Docker : `curl -fsSL https://get.docker.com | sh`
2. Ouvrir les ports : `sudo ufw allow 80,443/tcp`
3. Cloner le dépôt, copier `.env.example` en `.env` et renseigner au minimum
`POSTGRES_PASSWORD`, `DATABASE_URL`, `SEED_ADMIN_EMAIL`, puis :
```dotenv
COMPOSE_FILE=docker-compose.yml:docker-compose.caddy.yml
SITE_DOMAIN=votre-domaine.fr
CADDY_EMAIL=vous@example.fr
```
Renseigner `SITE_DOMAIN` **même si le DNS n'est pas encore en place** : c'est
ce qui rend la bascule automatique. Laisser `AUTH_URL` **vide** : Auth.js
déduit alors l'URL des en-têtes de la requête, et suit donc l'IP puis le
domaine sans reconfiguration.
4. Lancer : `docker compose up -d --build`
5. Relever le mot de passe administrateur, affiché une seule fois :
`docker logs pleinr-app 2>&1 | grep -i -A2 "mot de passe"`
Aucun port applicatif n'est publié : tout passe par Caddy. Les certificats
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** : 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`.
## Comptes de démonstration (seed)
| E-mail | Mot de passe | Rôle |
@@ -62,11 +113,91 @@ charge des données de démonstration. Ensuite :
| Tableau de bord | ✅ | ✅ | ✅ | — |
| Adhérents (CRUD) | ✅ | ✅ | ✅ | — |
| Modération des promotions | ✅ | ✅ | — | — |
| Publication réseaux sociaux | ✅ | ✅ | — | — |
| Informations adhérents | ✅ | ✅ | — | — |
| E-mails & boîte mail | ✅ | — | — | — |
| Administrateurs | ✅ | — | — | — |
| Mon espace (publier une promo) | — | — | — | ✅ |
| Mon espace (promos, informations) | — | — | — | ✅ |
`/backend` est protégé par le middleware ; chaque vue affine l'accès selon le rôle.
## Cycle de vie d'une promotion
`pending` → `live` → `suspended` ⇄ `live`, ou suppression.
- L'adhérent soumet la promotion depuis **Mon espace** : elle part en `pending`.
- L'association la valide dans **Backend › Promotions** : elle passe `live` et
s'affiche sur l'accueil, l'annuaire et la fiche de l'adhérent.
- **Suspension** : l'adhérent peut suspendre *ses* promotions depuis Mon espace,
l'association peut suspendre n'importe laquelle. Une promotion suspendue sort
immédiatement du site public et peut être remise en ligne à tout moment.
Nuance importante : une suspension décidée par l'association ne peut pas être
levée par l'adhérent (`promotions.suspended_by` mémorise l'auteur).
## Publication sur Facebook / LinkedIn
La diffusion est **pilotée par la validation**, jamais déclenchée à la main :
1. L'adhérent coche Facebook et/ou LinkedIn en soumettant sa promotion. Rien
n'est publié à ce stade ; il peut modifier son choix tant que la promo est
`pending`.
2. Le modérateur voit ces cases pré-cochées sur la carte en attente et peut les
ajuster — c'est le dernier moment où le choix est modifiable.
3. **Valider** met la promo en ligne *et* publie sur les réseaux retenus (image +
texte + lien vers la fiche adhérent). Après validation, le choix est figé.
Chaque tentative est tracée dans `social_posts` : succès avec le lien du post, ou
échec avec le message d'erreur affiché sur la carte. En cas d'échec, un bouton
**Réessayer** apparaît, limité aux réseaux déjà choisis — il ne permet pas
d'élargir la diffusion.
Une promotion n'est jamais publiée deux fois : un réseau ayant déjà une
publication réussie est systématiquement ignoré, y compris sur un cycle
suspension → remise en ligne.
### Connecter les comptes
Tout se passe dans **Backend › Réseaux sociaux** (administrateurs) : on colle les
identifiants de l'application, on clique **Connecter**, on choisit la page. Les
jetons sont récupérés par OAuth et stockés **chiffrés** (AES-256-GCM, clé
`SOCIAL_TOKEN_KEY` ou à défaut `AUTH_SECRET`) ; ils ne ressortent jamais vers le
navigateur. Un réseau non connecté voit simplement sa case disparaître du
formulaire de promotion.
Le même écran porte l'**URL publique du site**, pré-remplie avec l'adresse par
laquelle vous consultez le backoffice : elle sert à l'adresse de retour OAuth et
au lien inséré dans les publications. Seule l'origine est conservée (le schéma
est ajouté si vous l'omettez, un éventuel chemin est retiré).
L'écran affiche ensuite l'URL de redirection à déclarer sur le portail
développeur — c'est l'erreur de configuration la plus fréquente.
**Facebook.** Créez une application « Business » sur
[developers.facebook.com](https://developers.facebook.com/apps), ajoutez le
produit Connexion Facebook, déclarez l'URL de redirection. Gardez l'application
en **mode développement** avec le compte de l'association comme administrateur :
publier sur votre propre page ne demande alors aucune revue Meta. Le jeton de
page obtenu **n'expire pas** — une connexion suffit, définitivement.
**LinkedIn.** Créez une application sur
[linkedin.com/developers](https://www.linkedin.com/developers/apps) rattachée à
la page de l'association, puis demandez le produit **Community Management API**.
Deux limites à connaître avant de vous lancer :
- l'accès est soumis à une revue (page vérifiée, nom légal, adresse, politique
de confidentialité) ; ce n'est pas garanti ni immédiat ;
- les jetons LinkedIn durent **60 jours** et le rafraîchissement programmatique
est réservé à certains partenaires. En pratique il faut donc recliquer sur
**Reconnecter** environ tous les deux mois. Le backoffice affiche la date
d'expiration et un bandeau d'alerte 7 jours avant.
Les variables d'environnement (`FACEBOOK_PAGE_ACCESS_TOKEN`, etc.) restent lues
en **repli** si aucun compte n'est connecté, pour ne pas casser une installation
antérieure.
Les **liens publics** vers les deux pages (affichés sur l'accueil et dans le pied
de page) se règlent, eux, dans **Backend › Paramètres**.
## Développement local (sans Docker)
```bash
@@ -88,6 +219,7 @@ npm run dev # http://localhost:3000
| `npm run db:generate` | Génère les migrations Drizzle |
| `npm run db:migrate` | Applique les migrations |
| `npm run db:seed` | Insère les données de démonstration |
| `npm test` | Tests unitaires et de sécurité (XSS, chiffrement, MIME, file d'envoi) |
## Variables d'environnement
@@ -98,6 +230,52 @@ Voir [`.env.example`](./.env.example). Les principales :
- `AUTH_URL` — URL publique de l'application
- `SEED_ON_START` — `true` pour seeder au démarrage du conteneur
- `SEED_ADMIN_EMAIL` / `SEED_ADMIN_PASSWORD` / `SEED_ADMIN_NAME` — premier admin
- `NEXT_PUBLIC_SITE_URL` — repli pour l'URL publique du site ; elle se règle
normalement dans **Backend › Réseaux sociaux**, aucune variable n'est requise.
- `SOCIAL_TOKEN_KEY` — clé de chiffrement des jetons réseaux **et de la boîte
mail** (défaut : `AUTH_SECRET`)
- `MAIL_WORKER=off` — arrête la file d'envoi ; `PROMO_SCHEDULER=off` arrête le
libérateur de publications programmées. Les deux sont indépendants.
- `MAIL_RATE_PER_MINUTE` — débit de la file d'envoi (défaut : 20)
- `SMTP_HOST` / `SMTP_USER` / `SMTP_PASSWORD` … — repli avant toute
configuration depuis **Backend › Boîte mail**
- `FACEBOOK_PAGE_ID` / `FACEBOOK_PAGE_ACCESS_TOKEN`, `LINKEDIN_ORGANIZATION_URN` /
`LINKEDIN_ACCESS_TOKEN` — repli si aucun compte n'est connecté via le backoffice
## Informations aux adhérents
L'association publie ses annonces dans **Backend › Informations** : titre, texte
avec une mise en forme simple (`**gras**`, `*italique*`, listes, liens), image de
couverture, brouillon puis publication, et une seule information épinglée en tête.
Les adhérents les retrouvent sous l'onglet **Informations** de leur espace, avec
une pastille « Nouveau » et un compteur de non-lues qui retombe à la lecture.
À la publication, une case permet d'**envoyer aussi le message par e-mail** : un
message par adhérent, jamais de copie partagée.
## Envoi d'e-mails
Le site expédie depuis la boîte de l'association, branchée dans
**Backend › Boîte mail**. Trois transports, un seul actif à la fois :
| Transport | Chemin | À savoir |
|---|---|---|
| Google | API Gmail (`gmail.send`) | Une application laissée en mode « Test » voit son autorisation expirer au bout de 7 jours : passez-la « En production », ou déclarez-la « Interne » avec un compte Workspace. |
| Microsoft | API Graph (`Mail.Send`) | Fonctionne pour `@outlook.com` et `@hotmail.com` comme pour une boîte 365 ; l'authentification SMTP par mot de passe y est désactivée depuis 2024. |
| Autre serveur | SMTP (`nodemailer`) | Couvre aussi un mot de passe d'application Gmail, Outlook professionnel, OVH, Ionos. Marche immédiatement, sans démarche préalable. |
Pour Google et Microsoft, l'adresse d'expédition est **lue chez le fournisseur**
et non saisie : expédier depuis un autre domaine ferait échouer SPF et DKIM.
Ce qui part du site : mots de passe temporaires (création, réinitialisation,
invitation), invitations aux rencontres, diffusion d'une information, et les
envois du studio de composition. Les diffusions passent par une file d'attente
vidée en tâche de fond ; les mots de passe partent en direct, sans jamais être
écrits en base.
Sans boîte configurée, rien ne casse : les mots de passe temporaires restent
affichés une fois à l'écran, comme auparavant.
## Note sur le logo
+50
View File
@@ -0,0 +1,50 @@
# Plein R — variante « VPS sans reverse proxy existant ».
#
# Ajoute Caddy devant l'application : il écoute en 80 et 443, obtient et
# renouvelle tout seul le certificat Let's Encrypt pour SITE_DOMAIN, et
# redirige automatiquement HTTP vers HTTPS.
#
# Utilisation : dans .env, poser
# COMPOSE_FILE=docker-compose.yml:docker-compose.caddy.yml
# SITE_DOMAIN=pleinr.example.fr
# CADDY_EMAIL=vous@example.fr
# AUTH_URL=https://pleinr.example.fr
# puis `docker compose up -d --build` comme d'habitude.
#
# Pré-requis : le DNS du domaine pointe vers le VPS et les ports 80/443 sont
# ouverts dans le pare-feu.
services:
caddy:
image: caddy:2-alpine
container_name: pleinr-caddy
restart: unless-stopped
depends_on:
- app
environment:
SITE_DOMAIN: ${SITE_DOMAIN:?SITE_DOMAIN manquant dans .env}
CADDY_EMAIL: ${CADDY_EMAIL:?CADDY_EMAIL manquant dans .env (alertes d expiration Let's Encrypt)}
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
networks:
- proxy
app:
# L'application n'est plus publiée directement sur l'hôte : tout passe
# par Caddy (sinon http://IP:8413 contournerait le HTTPS).
ports: !reset []
volumes:
caddy_data:
caddy_config:
networks:
# Sans Nginx Proxy Manager, personne ne crée ce réseau : Compose s'en charge.
proxy:
external: false
+22 -3
View File
@@ -16,8 +16,9 @@ services:
volumes:
- pgdata:/var/lib/postgresql/data
ports:
# Host port rarely used (container stays on 5432). Change the left side if needed.
- "54329:5432"
# Lié à 127.0.0.1 : la base ne doit jamais être joignable depuis le réseau.
# Retirez le préfixe uniquement pour un accès distant volontaire (et protégé).
- "127.0.0.1:54329:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-pleinr} -d ${POSTGRES_DB:-pleinr}"]
interval: 5s
@@ -47,9 +48,26 @@ services:
AUTH_URL: ${AUTH_URL:-}
# Lets Auth.js trust the X-Forwarded-* headers from the proxy.
AUTH_TRUST_HOST: "true"
# URL publique, reprise dans le texte des posts Facebook / LinkedIn.
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-}
# Chiffrement des jetons réseaux stockés en base (à défaut : AUTH_SECRET).
SOCIAL_TOKEN_KEY: ${SOCIAL_TOKEN_KEY:-}
# Repli historique : la configuration normale se fait dans le backoffice.
FACEBOOK_PAGE_ID: ${FACEBOOK_PAGE_ID:-}
FACEBOOK_PAGE_ACCESS_TOKEN: ${FACEBOOK_PAGE_ACCESS_TOKEN:-}
FACEBOOK_GRAPH_VERSION: ${FACEBOOK_GRAPH_VERSION:-}
LINKEDIN_ORGANIZATION_URN: ${LINKEDIN_ORGANIZATION_URN:-}
LINKEDIN_ORGANIZATION_ID: ${LINKEDIN_ORGANIZATION_ID:-}
LINKEDIN_ACCESS_TOKEN: ${LINKEDIN_ACCESS_TOKEN:-}
LINKEDIN_API_VERSION: ${LINKEDIN_API_VERSION:-}
SEED_ON_START: ${SEED_ON_START:-true}
SEED_ADMIN_EMAIL: ${SEED_ADMIN_EMAIL:-admin@plein-r.fr}
SEED_ADMIN_PASSWORD: ${SEED_ADMIN_PASSWORD:-changeme123}
# Vide = un mot de passe aléatoire est généré au premier démarrage et
# affiché une seule fois dans les journaux du conteneur.
SEED_ADMIN_PASSWORD: ${SEED_ADMIN_PASSWORD:-}
# Données de démonstration (adhérents, promotions, comptes au mot de
# passe connu) : jamais en production. Base vide par défaut.
SEED_DEMO: ${SEED_DEMO:-false}
SEED_ADMIN_NAME: ${SEED_ADMIN_NAME:-Administrateur Plein R}
ports:
# Host port rarely used (container stays on 3000). App reachable at http://HOST:8413
@@ -75,5 +93,6 @@ networks:
proxy:
# The reverse proxy's existing network. Must already exist (created by the
# NPM/nginx stack). Override the name with PROXY_NETWORK if it differs.
# Sans proxy existant : voir docker-compose.caddy.yml (README).
external: true
name: ${PROXY_NETWORK:-nginx_default}
+66
View File
@@ -0,0 +1,66 @@
CREATE TABLE "image_consents" (
"id" serial PRIMARY KEY NOT NULL,
"member_id" integer NOT NULL,
"decision" varchar(20) NOT NULL,
"scopes" text,
"signatory_name" varchar(200),
"signature_png" text,
"consent_version" varchar(40) NOT NULL,
"ip" varchar(120),
"user_agent" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "meeting_registrations" (
"id" serial PRIMARY KEY NOT NULL,
"meeting_id" integer NOT NULL,
"member_id" integer,
"attendee_name" varchar(200) NOT NULL,
"attendee_email" varchar(200),
"image_consent" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "meetings" (
"id" serial PRIMARY KEY NOT NULL,
"title" varchar(200) NOT NULL,
"starts_at" timestamp with time zone NOT NULL,
"location" varchar(240),
"description" text,
"capacity" integer DEFAULT 30 NOT NULL,
"image_url" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "past_meeting_photos" (
"id" serial PRIMARY KEY NOT NULL,
"past_meeting_id" integer NOT NULL,
"image_url" text NOT NULL,
"caption" varchar(200),
"position" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "past_meetings" (
"id" serial PRIMARY KEY NOT NULL,
"title" varchar(200) NOT NULL,
"event_date" timestamp with time zone NOT NULL,
"location" varchar(240),
"description" text,
"participants" text,
"meeting_id" integer,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "site_settings" (
"key" varchar(120) PRIMARY KEY NOT NULL,
"value" text,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "image_consents" ADD CONSTRAINT "image_consents_member_id_members_id_fk" FOREIGN KEY ("member_id") REFERENCES "public"."members"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "meeting_registrations" ADD CONSTRAINT "meeting_registrations_meeting_id_meetings_id_fk" FOREIGN KEY ("meeting_id") REFERENCES "public"."meetings"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "meeting_registrations" ADD CONSTRAINT "meeting_registrations_member_id_members_id_fk" FOREIGN KEY ("member_id") REFERENCES "public"."members"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "past_meeting_photos" ADD CONSTRAINT "past_meeting_photos_past_meeting_id_past_meetings_id_fk" FOREIGN KEY ("past_meeting_id") REFERENCES "public"."past_meetings"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "past_meetings" ADD CONSTRAINT "past_meetings_meeting_id_meetings_id_fk" FOREIGN KEY ("meeting_id") REFERENCES "public"."meetings"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "meeting_registrations_meeting_member_idx" ON "meeting_registrations" USING btree ("meeting_id","member_id");
+11
View File
@@ -0,0 +1,11 @@
DROP INDEX "meeting_registrations_meeting_member_idx";--> statement-breakpoint
ALTER TABLE "meeting_registrations" ADD COLUMN "attendee_company" varchar(200) DEFAULT '' NOT NULL;--> statement-breakpoint
ALTER TABLE "meeting_registrations" ADD COLUMN "attendee_phone" varchar(40);--> statement-breakpoint
ALTER TABLE "meeting_registrations" ADD COLUMN "status" varchar(20) DEFAULT 'pending' NOT NULL;--> statement-breakpoint
UPDATE "meeting_registrations" AS r
SET "attendee_company" = m."name", "attendee_phone" = m."phone", "status" = 'confirmed'
FROM "members" AS m
WHERE r."member_id" = m."id";--> statement-breakpoint
ALTER TABLE "meetings" ADD COLUMN "participants_per_account" integer DEFAULT 1 NOT NULL;--> statement-breakpoint
CREATE INDEX "meeting_registrations_meeting_idx" ON "meeting_registrations" USING btree ("meeting_id");--> statement-breakpoint
CREATE INDEX "meeting_registrations_member_meeting_idx" ON "meeting_registrations" USING btree ("member_id","meeting_id");
+23
View File
@@ -0,0 +1,23 @@
CREATE TYPE "public"."promo_suspended_by" AS ENUM('member', 'staff');--> statement-breakpoint
CREATE TYPE "public"."social_network" AS ENUM('facebook', 'linkedin');--> statement-breakpoint
CREATE TYPE "public"."social_post_status" AS ENUM('posted', 'failed');--> statement-breakpoint
ALTER TYPE "public"."promo_status" ADD VALUE 'suspended';--> statement-breakpoint
CREATE TABLE "social_posts" (
"id" serial PRIMARY KEY NOT NULL,
"promotion_id" integer NOT NULL,
"network" "social_network" NOT NULL,
"status" "social_post_status" NOT NULL,
"external_id" varchar(200),
"url" text,
"error" text,
"posted_by_id" integer,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "promotions" ADD COLUMN "suspended_by" "promo_suspended_by";--> statement-breakpoint
ALTER TABLE "promotions" ADD COLUMN "suspended_by_id" integer;--> statement-breakpoint
ALTER TABLE "promotions" ADD COLUMN "suspended_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "social_posts" ADD CONSTRAINT "social_posts_promotion_id_promotions_id_fk" FOREIGN KEY ("promotion_id") REFERENCES "public"."promotions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "social_posts" ADD CONSTRAINT "social_posts_posted_by_id_users_id_fk" FOREIGN KEY ("posted_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "social_posts_promotion_idx" ON "social_posts" USING btree ("promotion_id");--> statement-breakpoint
ALTER TABLE "promotions" ADD CONSTRAINT "promotions_suspended_by_id_users_id_fk" FOREIGN KEY ("suspended_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE "promotions" ADD COLUMN "share_facebook" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "promotions" ADD COLUMN "share_linkedin" boolean DEFAULT false NOT NULL;
+17
View File
@@ -0,0 +1,17 @@
CREATE TABLE "social_accounts" (
"id" serial PRIMARY KEY NOT NULL,
"network" "social_network" NOT NULL,
"app_id" varchar(200) NOT NULL,
"app_secret" text NOT NULL,
"access_token" text,
"refresh_token" text,
"expires_at" timestamp with time zone,
"target_id" varchar(200),
"target_name" varchar(200),
"connected_by_id" integer,
"connected_at" timestamp with time zone,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "social_accounts_network_unique" UNIQUE("network")
);
--> statement-breakpoint
ALTER TABLE "social_accounts" ADD CONSTRAINT "social_accounts_connected_by_id_users_id_fk" FOREIGN KEY ("connected_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
+15
View File
@@ -0,0 +1,15 @@
-- Texte de mission enrichi sur la page Association.
--
-- La clause sur `value` est volontaire : le texte n'est remplacé que s'il est
-- resté celui d'origine. Une mission déjà réécrite depuis Paramètres n'est
-- jamais écrasée. Les « apports » n'ont pas de ligne en base tant qu'ils n'ont
-- pas été édités : leur valeur par défaut s'applique d'elle-même.
UPDATE "site_settings"
SET "value" = 'Plein R fédère les commerçants, artisans et entreprises du Bassin de Pompey autour d''une conviction simple : on va plus loin ensemble. L''association crée les occasions de se rencontrer, de se connaître et de travailler les uns avec les autres.
Nous mettons chaque adhérent en avant auprès des habitants comme des autres professionnels du territoire : une fiche dans l''annuaire, des bons plans relayés sur le site et sur nos réseaux sociaux, une place dans nos rencontres et nos publications.
L''objectif est double : amener des clients aux commerces de proximité, et faire naître des courants d''affaires entre les entreprises du bassin.',
"updated_at" = now()
WHERE "key" = 'association_mission'
AND "value" = 'Créer du lien entre adhérents, encourager les échanges de proximité et porter une dynamique collective au service du Bassin de Pompey.';
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "users" ADD COLUMN "session_version" integer DEFAULT 0 NOT NULL;
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "users" DROP COLUMN "temp_password";
+109
View File
@@ -0,0 +1,109 @@
-- Référentiel des catégories (source : src/db/categories.ts).
-- Insère les catégories manquantes ; pour celles qui existent déjà, seul l'ordre
-- d'affichage est réaligné : un libellé renommé depuis le backend est conservé.
INSERT INTO "categories" ("slug", "label", "accent", "tint", "sort") VALUES
('alimentation', 'Alimentation / Épicerie', '#E0A63C', '#f6efdc', 1),
('epicerie-fine', 'Épicerie fine – Produits du terroir', '#6FB0C6', '#e7f0f3', 2),
('supermarche', 'Supermarché – Grande distribution', '#9a6638', '#f4ebda', 3),
('boulangerie', 'Boulangerie – Pâtisserie', '#2C6FB3', '#eaf0f6', 4),
('boucherie-charcuterie', 'Boucherie – Charcuterie', '#5a7a5a', '#eef0ec', 5),
('poissonnerie', 'Poissonnerie', '#7a6f9c', '#efe9f3', 6),
('primeur', 'Primeur – Fruits & Légumes', '#c98a2e', '#f7efe0', 7),
('fromagerie', 'Fromagerie – Crèmerie', '#3f8aa3', '#e6eff2', 8),
('cave', 'Cave – Vins & Spiritueux', '#E0A63C', '#f6efdc', 9),
('brasserie-artisanale', 'Brasserie artisanale – Boissons', '#6FB0C6', '#e7f0f3', 10),
('chocolaterie', 'Chocolaterie – Confiserie', '#9a6638', '#f4ebda', 11),
('glacier-salon-de-the', 'Glacier – Salon de thé', '#2C6FB3', '#eaf0f6', 12),
('traiteur', 'Traiteur', '#5a7a5a', '#eef0ec', 13),
('restauration', 'Restauration', '#7a6f9c', '#efe9f3', 14),
('pizzeria', 'Pizzeria', '#c98a2e', '#f7efe0', 15),
('restauration-rapide', 'Restauration rapide – Food truck', '#3f8aa3', '#e6eff2', 16),
('cafe-bar', 'Café – Bar – Brasserie', '#E0A63C', '#f6efdc', 17),
('mode-beaute', 'Mode & Beauté', '#6FB0C6', '#e7f0f3', 18),
('mode-vetements', 'Mode & Vêtements', '#9a6638', '#f4ebda', 19),
('lingerie', 'Lingerie', '#2C6FB3', '#eaf0f6', 20),
('vetements-enfants', 'Vêtements enfants', '#5a7a5a', '#eef0ec', 21),
('chaussures-maroquinerie', 'Chaussures – Maroquinerie', '#7a6f9c', '#efe9f3', 22),
('bijouterie', 'Bijouterie – Horlogerie', '#c98a2e', '#f7efe0', 23),
('coiffure', 'Coiffure', '#3f8aa3', '#e6eff2', 24),
('barbier', 'Barbier', '#E0A63C', '#f6efdc', 25),
('esthetique', 'Esthétique – Institut', '#6FB0C6', '#e7f0f3', 26),
('onglerie', 'Onglerie – Prothésie ongulaire', '#9a6638', '#f4ebda', 27),
('parfumerie', 'Parfumerie', '#2C6FB3', '#eaf0f6', 28),
('bien-etre', 'Bien-être – Spa – Massage', '#5a7a5a', '#eef0ec', 29),
('optique', 'Optique', '#7a6f9c', '#efe9f3', 30),
('tatouage', 'Tatouage – Piercing', '#c98a2e', '#f7efe0', 31),
('bazar-discount', 'Bazar – Discount – Équipement de la maison', '#3f8aa3', '#e6eff2', 32),
('decoration', 'Décoration – Ameublement', '#E0A63C', '#f6efdc', 33),
('cuisiniste', 'Cuisines – Salles de bain', '#6FB0C6', '#e7f0f3', 34),
('literie', 'Literie', '#9a6638', '#f4ebda', 35),
('arts-de-la-table', 'Arts de la table – Cadeaux', '#2C6FB3', '#eaf0f6', 36),
('electromenager-multimedia', 'Électroménager – Multimédia', '#5a7a5a', '#eef0ec', 37),
('fleuriste', 'Fleuriste', '#7a6f9c', '#efe9f3', 38),
('jardinerie', 'Jardinerie – Motoculture', '#c98a2e', '#f7efe0', 39),
('bricolage-jardinage', 'Bricolage – Jardinage', '#3f8aa3', '#e6eff2', 40),
('animalerie', 'Animalerie – Services animaliers', '#E0A63C', '#f6efdc', 41),
('artisanat', 'Artisanat', '#6FB0C6', '#e7f0f3', 42),
('batiment', 'Bâtiment – Rénovation', '#9a6638', '#f4ebda', 43),
('architecte', 'Architecte – Maître d''œuvre', '#2C6FB3', '#eaf0f6', 44),
('maconnerie', 'Maçonnerie – Gros œuvre', '#5a7a5a', '#eef0ec', 45),
('couverture-toiture', 'Couverture – Toiture – Zinguerie', '#7a6f9c', '#efe9f3', 46),
('plomberie', 'Plomberie – Chauffage', '#c98a2e', '#f7efe0', 47),
('electricite', 'Électricité', '#3f8aa3', '#e6eff2', 48),
('isolation-energie', 'Isolation – Énergies renouvelables', '#E0A63C', '#f6efdc', 49),
('menuiserie', 'Menuiserie – Ébénisterie', '#6FB0C6', '#e7f0f3', 50),
('serrurerie-metallerie', 'Serrurerie – Métallerie', '#9a6638', '#f4ebda', 51),
('carrelage-sols', 'Carrelage – Revêtements de sols', '#2C6FB3', '#eaf0f6', 52),
('peinture', 'Peinture – Décoration intérieure', '#5a7a5a', '#eef0ec', 53),
('paysagiste', 'Paysagiste – Espaces verts', '#7a6f9c', '#efe9f3', 54),
('depannage-multiservices', 'Dépannage – Multiservices', '#c98a2e', '#f7efe0', 55),
('automobile', 'Automobile – Garage', '#3f8aa3', '#e6eff2', 56),
('carrosserie', 'Carrosserie', '#E0A63C', '#f6efdc', 57),
('controle-technique', 'Contrôle technique', '#6FB0C6', '#e7f0f3', 58),
('station-service-lavage', 'Station-service – Lavage auto', '#9a6638', '#f4ebda', 59),
('location-vehicules', 'Location de véhicules', '#2C6FB3', '#eaf0f6', 60),
('cycles-motos', 'Cycles – Motos', '#5a7a5a', '#eef0ec', 61),
('auto-ecole', 'Auto-école', '#7a6f9c', '#efe9f3', 62),
('taxi-vtc', 'Taxi – VTC', '#c98a2e', '#f7efe0', 63),
('sante', 'Santé – Pharmacie', '#3f8aa3', '#e6eff2', 64),
('medical', 'Médical – Paramédical', '#E0A63C', '#f6efdc', 65),
('audioprothese', 'Audioprothèse', '#6FB0C6', '#e7f0f3', 66),
('veterinaire', 'Vétérinaire', '#9a6638', '#f4ebda', 67),
('banque-assurance', 'Banque – Assurance', '#2C6FB3', '#eaf0f6', 68),
('assurance', 'Assurance – Mutuelle – Prévoyance', '#5a7a5a', '#eef0ec', 69),
('courtier-credit', 'Courtier – Crédit – Financement', '#7a6f9c', '#efe9f3', 70),
('immobilier', 'Immobilier', '#c98a2e', '#f7efe0', 71),
('diagnostic-immobilier', 'Diagnostic immobilier – Expertise', '#3f8aa3', '#e6eff2', 72),
('services', 'Services', '#E0A63C', '#f6efdc', 73),
('services-a-la-personne', 'Services à la personne – Aide à domicile', '#6FB0C6', '#e7f0f3', 74),
('pressing-retouches', 'Pressing – Retouches – Cordonnerie', '#9a6638', '#f4ebda', 75),
('photographe', 'Photographe – Vidéo', '#2C6FB3', '#eaf0f6', 76),
('pompes-funebres', 'Pompes funèbres', '#5a7a5a', '#eef0ec', 77),
('agence-voyages', 'Agence de voyages', '#7a6f9c', '#efe9f3', 78),
('demenagement', 'Déménagement – Garde-meubles', '#c98a2e', '#f7efe0', 79),
('comptabilite', 'Comptabilité – Gestion', '#3f8aa3', '#e6eff2', 80),
('juridique', 'Juridique – Notaire', '#E0A63C', '#f6efdc', 81),
('conseil', 'Conseil aux entreprises', '#6FB0C6', '#e7f0f3', 82),
('communication', 'Communication – Web – Marketing', '#9a6638', '#f4ebda', 83),
('imprimerie-enseignes', 'Imprimerie – Enseignes – Signalétique', '#2C6FB3', '#eaf0f6', 84),
('informatique', 'Informatique – Téléphonie', '#5a7a5a', '#eef0ec', 85),
('nettoyage', 'Nettoyage – Entretien', '#7a6f9c', '#efe9f3', 86),
('transport', 'Transport – Logistique', '#c98a2e', '#f7efe0', 87),
('securite', 'Sécurité', '#3f8aa3', '#e6eff2', 88),
('coworking', 'Coworking – Bureaux', '#E0A63C', '#f6efdc', 89),
('interim-rh', 'Intérim – Ressources humaines', '#6FB0C6', '#e7f0f3', 90),
('recyclage-dechets', 'Recyclage – Gestion des déchets', '#9a6638', '#f4ebda', 91),
('librairie-papeterie', 'Librairie – Papeterie – Presse', '#2C6FB3', '#eaf0f6', 92),
('tabac-presse', 'Tabac – Presse – Loto', '#5a7a5a', '#eef0ec', 93),
('jouets-puericulture', 'Jouets – Puériculture', '#7a6f9c', '#efe9f3', 94),
('sport-loisirs', 'Sport – Loisirs', '#c98a2e', '#f7efe0', 95),
('articles-de-sport', 'Articles de sport', '#3f8aa3', '#e6eff2', 96),
('fitness', 'Salle de sport – Fitness', '#E0A63C', '#f6efdc', 97),
('culture-arts', 'Culture – Arts', '#6FB0C6', '#e7f0f3', 98),
('education-formation', 'Éducation – Formation', '#9a6638', '#f4ebda', 99),
('tourisme-hotellerie', 'Tourisme – Hôtellerie', '#2C6FB3', '#eaf0f6', 100),
('evenementiel', 'Événementiel', '#5a7a5a', '#eef0ec', 101),
('industrie', 'Industrie – Production', '#7a6f9c', '#efe9f3', 102),
('agriculture', 'Agriculture – Producteur local', '#c98a2e', '#f7efe0', 103),
('partenaire-institutionnel', 'Partenaire institutionnel – Collectivité', '#3f8aa3', '#e6eff2', 104)
ON CONFLICT ("slug") DO UPDATE SET "sort" = EXCLUDED."sort";
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "members" ADD COLUMN "contact_email" varchar(200);
+3
View File
@@ -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;
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE "promotions" ADD COLUMN "starts_on" date;--> statement-breakpoint
ALTER TABLE "promotions" ADD COLUMN "ends_on" date;
+2
View File
@@ -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;
+3
View File
@@ -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);
+27
View File
@@ -0,0 +1,27 @@
CREATE TYPE "public"."info_status" AS ENUM('draft', 'published');--> statement-breakpoint
CREATE TABLE "information_reads" (
"id" serial PRIMARY KEY NOT NULL,
"information_id" integer NOT NULL,
"user_id" integer NOT NULL,
"read_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "informations" (
"id" serial PRIMARY KEY NOT NULL,
"title" varchar(200) NOT NULL,
"body" text NOT NULL,
"image_url" text,
"status" "info_status" DEFAULT 'draft' NOT NULL,
"pinned" boolean DEFAULT false NOT NULL,
"published_at" timestamp with time zone,
"author_id" integer,
"email_sent_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "information_reads" ADD CONSTRAINT "information_reads_information_id_informations_id_fk" FOREIGN KEY ("information_id") REFERENCES "public"."informations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "information_reads" ADD CONSTRAINT "information_reads_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "informations" ADD CONSTRAINT "informations_author_id_users_id_fk" FOREIGN KEY ("author_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "information_reads_user_info_idx" ON "information_reads" USING btree ("user_id","information_id");--> statement-breakpoint
CREATE INDEX "informations_feed_idx" ON "informations" USING btree ("status","published_at");
+58
View File
@@ -0,0 +1,58 @@
CREATE TYPE "public"."mail_kind" AS ENUM('credentials', 'information', 'meeting', 'studio', 'test');--> statement-breakpoint
CREATE TYPE "public"."mail_provider" AS ENUM('google', 'microsoft', 'smtp');--> statement-breakpoint
CREATE TYPE "public"."mail_status" AS ENUM('queued', 'sending', 'sent', 'failed', 'cancelled');--> statement-breakpoint
CREATE TABLE "mail_accounts" (
"id" serial PRIMARY KEY NOT NULL,
"provider" "mail_provider" NOT NULL,
"from_address" varchar(200),
"from_name" varchar(200),
"is_active" boolean DEFAULT false NOT NULL,
"app_id" varchar(200),
"app_secret" text,
"access_token" text,
"refresh_token" text,
"expires_at" timestamp with time zone,
"smtp_host" varchar(200),
"smtp_port" integer,
"smtp_secure" boolean DEFAULT true NOT NULL,
"smtp_user" varchar(200),
"smtp_password" text,
"connected_by_id" integer,
"connected_at" timestamp with time zone,
"last_check_at" timestamp with time zone,
"last_check_ok" boolean,
"last_check_error" text,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "mail_accounts_provider_unique" UNIQUE("provider")
);
--> statement-breakpoint
CREATE TABLE "mail_messages" (
"id" serial PRIMARY KEY NOT NULL,
"kind" "mail_kind" NOT NULL,
"status" "mail_status" DEFAULT 'queued' NOT NULL,
"to_address" varchar(200) NOT NULL,
"to_name" varchar(200),
"subject" varchar(300) NOT NULL,
"html" text NOT NULL,
"text" text,
"reply_to" varchar(200),
"information_id" integer,
"meeting_id" integer,
"member_id" integer,
"created_by_id" integer,
"provider" "mail_provider",
"attempts" integer DEFAULT 0 NOT NULL,
"next_attempt_at" timestamp with time zone DEFAULT now() NOT NULL,
"locked_at" timestamp with time zone,
"sent_at" timestamp with time zone,
"error" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "mail_accounts" ADD CONSTRAINT "mail_accounts_connected_by_id_users_id_fk" FOREIGN KEY ("connected_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "mail_messages" ADD CONSTRAINT "mail_messages_information_id_informations_id_fk" FOREIGN KEY ("information_id") REFERENCES "public"."informations"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "mail_messages" ADD CONSTRAINT "mail_messages_meeting_id_meetings_id_fk" FOREIGN KEY ("meeting_id") REFERENCES "public"."meetings"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "mail_messages" ADD CONSTRAINT "mail_messages_member_id_members_id_fk" FOREIGN KEY ("member_id") REFERENCES "public"."members"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "mail_messages" ADD CONSTRAINT "mail_messages_created_by_id_users_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "mail_messages_due_idx" ON "mail_messages" USING btree ("status","next_attempt_at");--> statement-breakpoint
CREATE INDEX "mail_messages_information_idx" ON "mail_messages" USING btree ("information_id");
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
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
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
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
+112
View File
@@ -29,6 +29,118 @@
"when": 1782545608919,
"tag": "0003_outstanding_jimmy_woo",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1784303079565,
"tag": "0004_tiny_vanisher",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1784339279276,
"tag": "0005_round_exiles",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1784930674056,
"tag": "0006_tiresome_clea",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1784931989372,
"tag": "0007_foamy_vindicator",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1784933339220,
"tag": "0008_neat_makkari",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1784968500537,
"tag": "0009_texte_mission",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1784972681534,
"tag": "0010_woozy_luminals",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1788512273181,
"tag": "0011_drop_temp_password",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1788675288708,
"tag": "0012_referentiel_categories",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"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
},
{
"idx": 18,
"version": "7",
"when": 1789399747132,
"tag": "0018_lazy_big_bertha",
"breakpoints": true
},
{
"idx": 19,
"version": "7",
"when": 1789401051538,
"tag": "0019_nostalgic_warstar",
"breakpoints": true
}
]
}
+46
View File
@@ -2,10 +2,56 @@
const nextConfig = {
output: "standalone",
reactStrictMode: true,
// Pas d'en-tête X-Powered-By : inutile de renseigner un attaquant sur la pile.
poweredByHeader: false,
// `nodemailer` charge net/tls/dns par des requires dynamiques : laissé au
// bundler, il se retrouve à moitié inliné et casse à l'exécution.
serverExternalPackages: ["nodemailer"],
experimental: {
// 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 [
{
source: "/:path*",
headers: [
// HSTS : une fois le site vu en HTTPS, le navigateur refuse le HTTP
// clair pendant un an. Sans effet tant que le site est servi en HTTP.
{
key: "Strict-Transport-Security",
value: "max-age=31536000; includeSubDomains",
},
// Empêche l'interprétation d'une réponse selon un type deviné.
{ key: "X-Content-Type-Options", value: "nosniff" },
// Le site ne doit pas être encadré par un tiers (clickjacking).
{ key: "X-Frame-Options", value: "SAMEORIGIN" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{
key: "Permissions-Policy",
value: "camera=(), microphone=(), geolocation=(), interest-cohort=()",
},
// La CSP complète est posée par le middleware : elle a besoin d'un
// nonce différent à chaque requête, ce qu'une valeur statique ne peut
// pas fournir.
],
},
];
},
};
export default nextConfig;
+435 -5997
View File
File diff suppressed because it is too large. Load diff
+16 -8
View File
@@ -10,16 +10,18 @@
"db:generate": "drizzle-kit generate",
"db:migrate": "tsx src/db/migrate.ts",
"db:seed": "tsx src/db/seed.ts",
"build:scripts": "esbuild src/db/migrate.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/migrate.cjs --external:pg-native && esbuild src/db/seed.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/seed.cjs --external:pg-native"
"db:purge-demo": "tsx src/db/purge-demo.ts",
"build:scripts": "esbuild src/db/migrate.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/migrate.cjs --external:pg-native && esbuild src/db/seed.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/seed.cjs --external:pg-native && esbuild src/db/purge-demo.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/purge-demo.cjs --external:pg-native",
"test": "tsx --test tests/**/*.test.ts"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"dotenv": "^16.4.7",
"drizzle-orm": "^0.38.3",
"eslint-config-next": "^15.5.19",
"next": "^15.5.19",
"next-auth": "5.0.0-beta.25",
"pg": "^8.13.1",
"drizzle-orm": "^0.45.2",
"next": "^15.5.25",
"next-auth": "^5.0.0-beta.32",
"nodemailer": "^8.0.11",
"pg": "^8.23.0",
"react": "19.0.0",
"react-dom": "19.0.0",
"zod": "^3.24.1"
@@ -27,12 +29,18 @@
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/node": "^22.10.5",
"@types/nodemailer": "^8.0.1",
"@types/pg": "^8.11.10",
"@types/react": "^19.0.7",
"@types/react-dom": "^19.0.3",
"drizzle-kit": "^0.30.1",
"esbuild": "^0.24.2",
"drizzle-kit": "^0.31.10",
"esbuild": "^0.28.1",
"tsx": "^4.19.2",
"typescript": "^5.7.3"
},
"overrides": {
"postcss": "^8.5.23",
"sharp": "^0.35.3",
"esbuild": "^0.28.1"
}
}
+3 -1
View File
@@ -3,13 +3,15 @@
import { revalidatePath } from "next/cache";
import { db } from "@/db";
import { activityLog, contactMessages, membershipRequests } from "@/db/schema";
import { sanitizeActivityMessage } from "@/lib/activity";
export type SubmitResult = { ok: boolean; error?: string };
const isEmail = (v: string) => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v);
async function logActivity(message: string, dot: string) {
await db.insert(activityLog).values({ message, dot });
// Saisie publique non authentifiée : filtrage obligatoire.
await db.insert(activityLog).values({ message: sanitizeActivityMessage(message), dot });
}
// ---- Demande pour devenir membre (public, sans authentification) ----
+254 -51
View File
@@ -1,12 +1,34 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { notFound, permanentRedirect } from "next/navigation";
import type { Metadata } from "next";
import { Sparkle } from "@/components/Sparkle";
import { SiteHeader } from "@/components/SiteHeader";
import { SiteFooter } from "@/components/SiteFooter";
import { VitrineImage } from "@/components/VitrineImage";
import { PromoImage } from "@/components/PromoImage";
import { getMemberLivePromotions, getPublicMember } from "@/lib/queries";
import { JsonLd } from "@/components/JsonLd";
import { getMemberContact, getMemberLivePromotions, getPublicMember } from "@/lib/queries";
import { getSession } from "@/lib/session";
import { telHref } from "@/lib/member-contact";
import {
NOINDEX,
breadcrumbJsonLd,
localBusinessJsonLd,
memberPath,
metaDescription,
pageMetadata,
parseMemberParam,
} from "@/lib/seo";
import { publicBaseUrl } from "@/lib/seo-server";
import { autoTags, formatTags, parseTags } from "@/lib/tags";
import {
formatSlots,
getOpenStatus,
normalizeWebsite,
parseMemberHours,
websiteLabel,
} from "@/lib/member-profile";
import { formatValidity } from "@/lib/promo-validity";
export const dynamic = "force-dynamic";
@@ -31,15 +53,80 @@ function initialsOf(name: string) {
return name.split(/\s+/).map((w) => w[0]).join("").slice(0, 2).toUpperCase();
}
type ContactIconName = "phone" | "mail" | "website" | "pin" | "clock" | "chat";
function ContactIcon({ name }: { name: ContactIconName }) {
const paths: Record<ContactIconName, React.ReactNode> = {
phone: <path d="M7.4 3.8 9 7.2 7.2 8.6a14.4 14.4 0 0 0 6.2 6.2L14.8 13l3.4 1.6-.8 3.6c-.2.9-1 1.5-1.9 1.5C8.2 19.7 2.3 13.8 2.3 6.5c0-.9.6-1.7 1.5-1.9l3.6-.8Z" />,
mail: <><rect x="2.5" y="4.5" width="19" height="15" rx="2.5" /><path d="m4 6 8 6 8-6" /></>,
website: <><circle cx="12" cy="12" r="9.5" /><path d="M2.8 12h18.4M12 2.5c2.4 2.6 3.6 5.8 3.6 9.5S14.4 18.9 12 21.5M12 2.5C9.6 5.1 8.4 8.3 8.4 12s1.2 6.9 3.6 9.5" /></>,
pin: <><path d="M20 10c0 5.5-8 11.5-8 11.5S4 15.5 4 10a8 8 0 1 1 16 0Z" /><circle cx="12" cy="10" r="2.5" /></>,
clock: <><circle cx="12" cy="12" r="9.5" /><path d="M12 6.6V12l3.6 2.4" /></>,
chat: <><path d="M20.5 12.6c0 3.9-3.8 7.1-8.5 7.1-.9 0-1.8-.1-2.6-.3l-5.4 1.7 1.7-4.2a6.6 6.6 0 0 1-2.2-4.8c0-3.9 3.8-7.1 8.5-7.1s8.5 3.2 8.5 7.1Z" /><path d="M8.8 11.9h.01M12 11.9h.01M15.2 11.9h.01" /></>,
};
return (
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
{paths[name]}
</svg>
);
}
function ContactAction({
href,
icon,
label,
detail,
external = false,
}: {
href: string;
icon: ContactIconName;
label: string;
detail: string;
external?: boolean;
}) {
return (
<a
href={href}
className="contact-action"
target={external ? "_blank" : undefined}
rel={external ? "noopener noreferrer" : undefined}
>
<span className="contact-action__icon"><ContactIcon name={icon} /></span>
<span className="contact-action__copy">
<strong>{label}</strong>
<span>{detail}</span>
</span>
<span className="contact-action__arrow" aria-hidden="true">{external ? "↗" : "→"}</span>
</a>
);
}
/** Titre de la fiche : « Nom · Catégorie à Ville », le plus parlant en résultat de recherche. */
function memberTitle(member: { name: string; categoryLabel: string | null; city: string | null }) {
const where = member.city ? ` à ${member.city}` : " · Bassin de Pompey";
return member.categoryLabel ? `${member.name} · ${member.categoryLabel}${where}` : `${member.name}${where}`;
}
export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>;
}): Promise<Metadata> {
const { id } = await params;
const member = await getPublicMember(Number(id));
if (!member) return { title: "Adhérent — Plein R" };
return { title: `${member.name} — Plein R`, description: member.description ?? undefined };
const memberId = parseMemberParam(id);
const member = memberId ? await getPublicMember(memberId) : null;
if (!member || member.status !== "active") {
return { title: "Adhérent introuvable", robots: NOINDEX };
}
const fallback =
`${member.name}, ${member.categoryLabel ? `${member.categoryLabel.toLowerCase()} ` : ""}` +
`adhérent Plein R${member.city ? ` à ${member.city}` : " sur le Bassin de Pompey"} : coordonnées, horaires et bons plans.`;
return pageMetadata({
title: memberTitle(member),
description: metaDescription(member.description, fallback),
path: memberPath(member),
ogType: "profile",
});
}
export default async function FicheAdherentPage({
@@ -48,13 +135,27 @@ export default async function FicheAdherentPage({
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const memberId = Number(id);
const memberId = parseMemberParam(id);
if (!memberId) notFound();
const member = await getPublicMember(memberId);
if (!member || member.status !== "active") notFound();
const promos = await getMemberLivePromotions(memberId);
// Une seule URL par fiche : l'ancienne forme `/adherents/12` et tout slug
// périmé (renommage, changement de commune) sont redirigés définitivement.
const canonical = memberPath(member);
if (`/adherents/${id}` !== canonical) permanentRedirect(canonical);
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(" ")]
@@ -64,11 +165,23 @@ export default async function FicheAdherentPage({
[member.address, member.postalCode, member.city].filter(Boolean).join(", ")
);
const paragraphs = (member.description ?? "").split("\n").map((s) => s.trim()).filter(Boolean);
const tags = (member.tags ?? "").split(",").map((s) => s.trim()).filter(Boolean);
const hours = (member.hours ?? "")
.split("\n")
.map((line) => line.split("|").map((s) => s.trim()))
.filter((p) => p[0]);
// Fiche jamais ré-enregistrée depuis l'arrivée des tags automatiques : on
// affiche les suggestions du métier plutôt qu'une rubrique vide.
const tags = parseTags(
autoTags(member.tags, {
categorySlug: member.categorySlug,
categoryLabel: member.categoryLabel,
city: member.city,
description: member.description,
})
);
const hours = parseMemberHours(member.hours);
const openStatus = getOpenStatus(hours);
const normalizedWebsite = normalizeWebsite(member.website);
const shortWebsite = websiteLabel(member.website);
// E-mail public : celui saisi pour la fiche, sinon l'e-mail du compte.
const contactEmail = (member.contactEmail ?? "").trim() || member.email;
const phoneHref = member.phone ? `tel:${member.phone.replace(/[^\d+]/g, "")}` : null;
const sectionCard: React.CSSProperties = {
background: "#fff",
@@ -86,15 +199,25 @@ export default async function FicheAdherentPage({
return (
<div style={{ background: "#F6F2E8", minHeight: "100vh", fontFamily: "'Public Sans',sans-serif", color: "#33291D" }}>
<SiteHeader active="annuaire" />
<JsonLd
data={[
breadcrumbJsonLd(baseUrl, [
{ name: "Accueil", path: "/" },
{ name: "Annuaire", path: "/annuaire" },
{ name: member.name, path: canonical },
]),
localBusinessJsonLd(baseUrl, { ...member, email: contactEmail, tags: formatTags(tags) }, hours, normalizedWebsite),
]}
/>
<div className="container" style={{ paddingBottom: 56 }}>
<main className="container" style={{ paddingBottom: 56 }}>
{/* breadcrumb */}
<div style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 13.5, color: "#9a8d72", padding: "6px 0 18px", flexWrap: "wrap" }}>
<nav aria-label="Fil d'Ariane" style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 13.5, color: "#9a8d72", padding: "6px 0 18px", flexWrap: "wrap" }}>
<Link href="/annuaire" style={{ textDecoration: "none", color: "#9a6638", fontWeight: 600 }}>Annuaire</Link>
{member.categoryLabel && (<><span>›</span><span style={{ color: "#9a8d72" }}>{member.categoryLabel}</span></>)}
<span>›</span>
<span style={{ color: "#3c3322", fontWeight: 600 }}>{member.name}</span>
</div>
<span style={{ color: "#3c3322", fontWeight: 600 }} aria-current="page">{member.name}</span>
</nav>
{/* cover + identity */}
<section style={{ position: "relative", borderRadius: 22, overflow: "hidden", border: "1px solid #e6dcc6" }}>
@@ -149,11 +272,14 @@ export default async function FicheAdherentPage({
</div>
</div>
<div style={{ display: "flex", gap: 10, paddingBottom: 4, flexWrap: "wrap" }}>
{member.phone && (
<a href={`tel:${member.phone.replace(/\s/g, "")}`} className="font-display" style={{ textDecoration: "none", background: "#2C6FB3", color: "#fff", fontWeight: 700, fontSize: 14, padding: "11px 18px", borderRadius: 11 }}>Appeler</a>
{phoneHref && (
<a href={phoneHref} className="font-display profile-hero-action profile-hero-action--primary">Appeler</a>
)}
{normalizedWebsite && (
<a href={normalizedWebsite} target="_blank" rel="noopener noreferrer" className="font-display profile-hero-action">Site web ↗</a>
)}
{mapQuery && (
<a href={`https://www.google.com/maps/dir/?api=1&destination=${mapQuery}`} target="_blank" rel="noopener" className="font-display" style={{ textDecoration: "none", background: "#fff", border: "1px solid #d8cdb4", color: "#3c3322", fontWeight: 700, fontSize: 14, padding: "11px 18px", borderRadius: 11 }}>Itinéraire</a>
<a href={`https://www.google.com/maps/dir/?api=1&destination=${mapQuery}`} target="_blank" rel="noopener noreferrer" className="font-display profile-hero-action">Itinéraire</a>
)}
</div>
</div>
@@ -186,44 +312,111 @@ export default async function FicheAdherentPage({
</section>
{/* practical info */}
<section style={sectionCard}>
<h2 className="font-display" style={{ ...h2, marginBottom: 16 }}>Informations pratiques</h2>
<div className="grid grid-2" style={{ gap: 22 }}>
<section className="practical-section">
<div className="practical-section__heading">
<div>
<div style={{ fontSize: 12.5, letterSpacing: "0.06em", textTransform: "uppercase", color: "#a99c82", fontWeight: 700, marginBottom: 10 }}>Horaires</div>
<span className="eyebrow">Préparer votre visite</span>
<h2 className="font-display" style={{ ...h2, margin: "10px 0 0" }}>Informations pratiques</h2>
</div>
{openStatus && (
<div className={`open-status${openStatus.isOpen ? " is-open" : ""}`}>
<span />
<div>
<strong>{openStatus.label}</strong>
{openStatus.detail && <small>{openStatus.detail}</small>}
</div>
</div>
)}
</div>
<div className="practical-grid">
<section className="schedule-card" aria-labelledby="schedule-title">
<div className="practical-card-title">
<span className="practical-card-title__icon" aria-hidden="true"><ContactIcon name="clock" /></span>
<div>
<span>Cette semaine</span>
<h3 id="schedule-title" className="font-display">Horaires d&apos;ouverture</h3>
</div>
</div>
{hours.length > 0 ? (
<div style={{ display: "flex", flexDirection: "column", gap: 7, fontSize: 14 }}>
{hours.map(([label, range], i) => {
const closed = (range ?? "").toLowerCase().includes("ferm");
<div className="schedule-list">
{hours.map((day) => {
const isToday = openStatus?.todayKey === day.key;
return (
<div key={i} style={{ display: "flex", justifyContent: "space-between", gap: 12 }}>
<span style={{ color: "#6c6150" }}>{label}</span>
<span style={{ color: closed ? "#c0392b" : "#3c3322", fontWeight: 600 }}>{range}</span>
<div
key={day.key}
className={`schedule-row${isToday ? " is-today" : ""}`}
aria-current={isToday ? "date" : undefined}
>
<span className="schedule-row__day">
{day.label}
{isToday && <small>Aujourd&apos;hui</small>}
</span>
<strong className={day.closed ? "is-closed" : undefined}>
{day.closed ? "Fermé" : formatSlots(day.slots)}
</strong>
</div>
);
})}
</div>
) : (
<div style={{ fontSize: 14, color: "#a99c82" }}>Horaires non communiqués.</div>
<div className="practical-empty">Horaires non communiqués.</div>
)}
</div>
<div>
<div style={{ fontSize: 12.5, letterSpacing: "0.06em", textTransform: "uppercase", color: "#a99c82", fontWeight: 700, marginBottom: 10 }}>Contact</div>
<div style={{ display: "flex", flexDirection: "column", gap: 9, fontSize: 14, color: "#5e5444" }}>
{member.phone && (
<div style={{ display: "flex", alignItems: "center", gap: 9 }}><span style={{ width: 7, height: 7, borderRadius: "50%", background: "#E0A63C" }} />{member.phone}</div>
</section>
<section className="contact-card" aria-labelledby="contact-title">
<div className="practical-card-title">
<span className="practical-card-title__icon practical-card-title__icon--blue" aria-hidden="true"><ContactIcon name="chat" /></span>
<div>
<span>Contact direct</span>
<h3 id="contact-title" className="font-display">Joindre l&apos;établissement</h3>
</div>
</div>
<div className="contact-actions">
{phoneHref && member.phone && (
<ContactAction href={phoneHref} icon="phone" label="Appeler" detail={member.phone} />
)}
{member.email && (
<a href={`mailto:${member.email}`} style={{ display: "flex", alignItems: "center", gap: 9, color: "#5e5444", textDecoration: "none" }}><span style={{ width: 7, height: 7, borderRadius: "50%", background: "#6FB0C6" }} />{member.email}</a>
{contactEmail && (
<ContactAction href={`mailto:${contactEmail}`} icon="mail" label="Envoyer un e-mail" detail={contactEmail} />
)}
{member.website && (
<a href={member.website} target="_blank" rel="noopener" style={{ display: "flex", alignItems: "center", gap: 9, color: "#2C6FB3", textDecoration: "none", fontWeight: 600 }}><span style={{ width: 7, height: 7, borderRadius: "50%", background: "#2C6FB3" }} />{member.website.replace(/^https?:\/\//, "")}</a>
{normalizedWebsite && shortWebsite && (
<ContactAction href={normalizedWebsite} icon="website" label="Visiter le site" detail={shortWebsite} external />
)}
{!member.phone && !member.email && !member.website && (
<div style={{ color: "#a99c82" }}>Coordonnées non communiquées.</div>
{mapQuery && fullAddress && (
<ContactAction
href={`https://www.google.com/maps/dir/?api=1&destination=${mapQuery}`}
icon="pin"
label="Itinéraire"
detail={fullAddress}
external
/>
)}
{!phoneHref && !contactEmail && !normalizedWebsite && !fullAddress && (
<div className="practical-empty">Coordonnées non communiquées.</div>
)}
</div>
</div>
{contact && (
<div className="contact-referent">
<div className="contact-referent__label">
<span style={{ width: 7, height: 7, borderRadius: "50%", background: accent }} />
Contact adhérent
</div>
{contact.name && <div className="contact-referent__name">{contact.name}</div>}
{contact.phone &&
(contactTel ? (
<a className="contact-referent__phone" href={contactTel}>
{contact.phone}
</a>
) : (
<div className="contact-referent__phone">{contact.phone}</div>
))}
<div className="contact-referent__hint">
Visible uniquement parce que vous êtes connecté : ces coordonnées ne sont pas
publiées sur le site.
</div>
</div>
)}
</section>
</div>
</section>
@@ -277,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>
@@ -287,14 +480,24 @@ export default async function FicheAdherentPage({
</section>
{/* contact CTA */}
<section style={{ background: "linear-gradient(120deg,#13324F,#1d4a72)", borderRadius: 18, padding: 22, color: "#fff" }}>
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 17, margin: "0 0 6px" }}>Une question pour ce commerçant ?</h3>
<p style={{ margin: "0 0 14px", fontSize: 13.5, color: "#bcd3e6" }}>Contactez {member.name} directement.</p>
<a href={member.email ? `mailto:${member.email}` : "#"} className="font-display" style={{ textDecoration: "none", display: "block", textAlign: "center", background: "#E0A63C", color: "#33291D", fontWeight: 700, fontSize: 14.5, padding: 12, borderRadius: 11 }}>Envoyer un message</a>
</section>
{(contactEmail || phoneHref || normalizedWebsite) && (
<section style={{ background: "linear-gradient(120deg,#13324F,#1d4a72)", borderRadius: 18, padding: 22, color: "#fff" }}>
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 17, margin: "0 0 6px" }}>Une question pour ce commerçant ?</h3>
<p style={{ margin: "0 0 14px", fontSize: 13.5, color: "#bcd3e6" }}>Contactez {member.name} directement.</p>
<a
href={contactEmail ? `mailto:${contactEmail}` : phoneHref ?? normalizedWebsite ?? undefined}
target={!contactEmail && !phoneHref ? "_blank" : undefined}
rel={!contactEmail && !phoneHref ? "noopener noreferrer" : undefined}
className="font-display"
style={{ textDecoration: "none", display: "block", textAlign: "center", background: "#E0A63C", color: "#33291D", fontWeight: 700, fontSize: 14.5, padding: 12, borderRadius: 11 }}
>
{contactEmail ? "Envoyer un message" : phoneHref ? "Appeler maintenant" : "Visiter le site"}
</a>
</section>
)}
</aside>
</div>
</div>
</main>
<SiteFooter />
</div>
+8 -57
View File
@@ -1,28 +1,16 @@
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import { VitrineImage } from "@/components/VitrineImage";
import { MemberCard, type MemberCardData } from "@/components/MemberCard";
import type { MemberContact } from "@/lib/member-contact";
export type DirectoryMember = {
id: number;
name: string;
description: string | null;
city: string | null;
address: string | null;
coverUrl: string | null;
logoUrl: string | null;
categoryLabel: string | null;
accent: string | null;
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;
};
const STRIPE_WARM =
"repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)";
const STRIPE_COOL =
"repeating-linear-gradient(45deg,#eef0ec,#eef0ec 12px,#e2e8e6 12px,#e2e8e6 24px)";
export function AnnuaireClient({
members,
categories,
@@ -190,46 +178,9 @@ export function AnnuaireClient({
{/* grid / empty */}
{list.length > 0 ? (
<div className="grid grid-3" style={{ gap: 20 }}>
{list.map((m, i) => {
return (
<Link
key={m.id}
href={`/adherents/${m.id}`}
className="lift-card"
style={{ textDecoration: "none", color: "inherit", background: "#fff", border: "1px solid #e6dcc6", borderRadius: 18, overflow: "hidden", display: "flex", flexDirection: "column" }}
>
<VitrineImage
coverUrl={m.coverUrl}
logoUrl={m.logoUrl}
height={150}
stripe={i % 2 === 0 ? STRIPE_WARM : STRIPE_COOL}
>
{m.categoryLabel && (
<span style={{ position: "absolute", top: 12, left: 12, background: m.accent ?? "#9a6638", color: "#fff", borderRadius: 999, padding: "5px 12px", fontSize: 11, fontWeight: 700 }}>
{m.categoryLabel}
</span>
)}
{m.hasPromo && m.promoBadge && (
<span className="font-display" style={{ position: "absolute", top: 0, right: 0, background: "#d8472b", color: "#fff", fontWeight: 800, fontSize: 12.5, padding: "6px 12px", borderRadius: "0 0 0 14px" }}>
{m.promoBadge}
</span>
)}
</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" }}>
{m.name}
</h3>
<p style={{ margin: "6px 0 12px", fontSize: 13, color: "#8c8068", lineHeight: 1.5, flex: 1 }}>
{m.description}
</p>
<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(" · ")}
</div>
</div>
</Link>
);
})}
{list.map((m, i) => (
<MemberCard key={m.id} m={m} index={i} contact={m.contact} />
))}
</div>
) : (
<div style={{ background: "#fff", border: "1px dashed #d8cdb4", borderRadius: 18, padding: 54, textAlign: "center" }}>
+186
View File
@@ -0,0 +1,186 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { CategoryLinks } from "@/components/CategoryLinks";
import { JsonLd } from "@/components/JsonLd";
import { MemberCard } from "@/components/MemberCard";
import { MembershipModalButton } from "@/components/MembershipModalButton";
import { SiteFooter } from "@/components/SiteFooter";
import { SiteHeader } from "@/components/SiteHeader";
import { Sparkle } from "@/components/Sparkle";
import {
getActiveMembersByCategory,
getCategoriesInUse,
getCategoryBySlug,
getLiveBadgesByMember,
getMemberContacts,
} from "@/lib/queries";
import { getSession } from "@/lib/session";
import {
NOINDEX,
breadcrumbJsonLd,
categoryPath,
memberListJsonLd,
metaDescription,
pageMetadata,
} from "@/lib/seo";
import { publicBaseUrl } from "@/lib/seo-server";
export const dynamic = "force-dynamic";
type Params = Promise<{ categorie: string }>;
/** Communes représentées, dans l'ordre d'effectif, pour le texte d'introduction. */
function listCities(members: { city: string | null }[], max = 4): string[] {
const counts = new Map<string, number>();
for (const m of members) if (m.city) counts.set(m.city, (counts.get(m.city) ?? 0) + 1);
return [...counts.entries()]
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0], "fr"))
.slice(0, max)
.map(([city]) => city);
}
function joinFr(items: string[]): string {
if (items.length <= 1) return items.join("");
return `${items.slice(0, -1).join(", ")} et ${items[items.length - 1]}`;
}
/** Titre et introduction d'une page métier, réutilisés par les métadonnées et le rendu. */
function categoryCopy(label: string, members: { city: string | null }[]) {
const n = members.length;
const cities = listCities(members);
const where = cities.length > 0 ? ` à ${joinFr(cities)}` : " sur le Bassin de Pompey";
const title = `${label} sur le Bassin de Pompey`;
const intro =
n === 0
? `Aucun adhérent Plein R n'est encore référencé dans la catégorie ${label}. Vous exercez ce métier sur le Bassin de Pompey ? Rejoignez l'association pour apparaître ici.`
: `${n} ${n > 1 ? "professionnels adhérents" : "professionnel adhérent"} Plein R dans la catégorie ${label}${where} : coordonnées, horaires d'ouverture et bons plans du moment. Des commerçants et entreprises de proximité, membres du réseau économique du Bassin de Pompey.`;
return { title, intro };
}
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
const { categorie } = await params;
const category = await getCategoryBySlug(categorie);
if (!category) return { title: "Catégorie introuvable", robots: NOINDEX };
const members = await getActiveMembersByCategory(category.id);
const { title, intro } = categoryCopy(category.label, members);
return pageMetadata({
title,
description: metaDescription(intro),
path: categoryPath(category.slug),
// Une catégorie sans adhérent n'a rien à offrir aux moteurs : on la laisse
// visible pour les visiteurs, mais hors index.
robots: members.length === 0 ? NOINDEX : undefined,
});
}
export default async function CategoriePage({ params }: { params: Params }) {
const { categorie } = await params;
const category = await getCategoryBySlug(categorie);
if (!category) notFound();
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);
}
const members = rawMembers.map((m) => ({
...m,
hasPromo: badgeByMember.has(m.id),
promoBadge: badgeByMember.get(m.id) ?? null,
}));
const { title, intro } = categoryCopy(category.label, members);
const accent = category.accent || "#E0A63C";
return (
<div style={{ background: "#F6F2E8", minHeight: "100vh", fontFamily: "'Public Sans',sans-serif", color: "#33291D" }}>
<SiteHeader active="annuaire" logo />
<JsonLd
data={[
breadcrumbJsonLd(baseUrl, [
{ name: "Accueil", path: "/" },
{ name: "Annuaire", path: "/annuaire" },
{ name: category.label, path: categoryPath(category.slug) },
]),
memberListJsonLd(baseUrl, title, members),
]}
/>
<main className="container" style={{ paddingBottom: 56 }}>
<nav aria-label="Fil d'Ariane" style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 13.5, color: "#9a8d72", padding: "6px 0 18px", flexWrap: "wrap" }}>
<Link href="/annuaire" style={{ textDecoration: "none", color: "#9a6638", fontWeight: 600 }}>Annuaire</Link>
<span>›</span>
<span style={{ color: "#3c3322", fontWeight: 600 }} aria-current="page">{category.label}</span>
</nav>
<section style={{ position: "relative", padding: "6px 0 22px", overflow: "hidden" }}>
<Sparkle color={accent} size={18} style={{ top: 12, right: 30 }} duration={3.2} />
<Sparkle color="#6FB0C6" size={12} style={{ top: 52, right: 90 }} duration={2.7} delay={0.5} />
<div
style={{
display: "inline-flex",
alignItems: "center",
gap: 9,
background: category.tint || "#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: accent }} />
{members.length} {members.length > 1 ? "adhérents" : "adhérent"}
</div>
<h1 className="font-display" style={{ fontWeight: 800, fontSize: "clamp(26px, 6vw, 40px)", letterSpacing: "-0.02em", margin: "0 0 10px", color: "#26201a" }}>
{title}
</h1>
<p style={{ margin: 0, fontSize: 16, color: "#6c6150", maxWidth: 680, lineHeight: 1.6 }}>{intro}</p>
</section>
{members.length > 0 ? (
<div className="grid grid-3" style={{ gap: 20 }}>
{members.map((m, i) => (
<MemberCard key={m.id} m={m} index={i} contact={contacts?.get(m.id) ?? null} />
))}
</div>
) : (
<div style={{ background: "#fff", border: "1px dashed #d8cdb4", borderRadius: 18, padding: 44, textAlign: "center" }}>
<div className="font-display" style={{ fontWeight: 700, fontSize: 19, color: "#26201a", marginBottom: 14 }}>
Soyez le premier référencé dans cette catégorie
</div>
<MembershipModalButton
label="Adhérer à l'association"
className="font-display"
style={{ border: "none", background: "#E0A63C", color: "#33291D", fontWeight: 700, fontSize: 15, padding: "12px 22px", borderRadius: 12 }}
/>
</div>
)}
{categoriesInUse.length > 0 && (
<section style={{ marginTop: 34, background: "#fff", border: "1px solid #e6dcc6", borderRadius: 18, padding: "22px 24px" }}>
<h2 className="font-display" style={{ fontWeight: 700, fontSize: 19, margin: "0 0 14px", color: "#26201a" }}>
Autres métiers de l&apos;annuaire
</h2>
<CategoryLinks categories={categoriesInUse} currentSlug={category.slug} />
</section>
)}
</main>
<SiteFooter />
</div>
);
}
+55 -7
View File
@@ -6,17 +6,27 @@ import { MembershipModalButton } from "@/components/MembershipModalButton";
import {
getActiveMembersWithCategory,
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";
import { publicBaseUrl } from "@/lib/seo-server";
import { AnnuaireClient, type DirectoryMember } from "./AnnuaireClient";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Annuaire — Plein R",
// Canonique sans paramètre de recherche : /annuaire?q=… ne crée pas de doublon.
export const metadata: Metadata = pageMetadata({
title: "Annuaire des commerçants du Bassin de Pompey",
description:
"L'annuaire des commerçants et entreprises du Bassin de Pompey. Trouvez un professionnel et ses bons plans.",
};
"Trouvez un commerçant, artisan ou entreprise du Bassin de Pompey (Pompey, Frouard, Liverdun, " +
"Champigneulles…) : coordonnées, horaires et bons plans.",
path: "/annuaire",
});
export default async function AnnuairePage({
searchParams,
@@ -24,12 +34,21 @@ export default async function AnnuairePage({
searchParams: Promise<{ q?: string }>;
}) {
const { q } = await searchParams;
const [rawMembers, categories, badges] = 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)) {
@@ -41,13 +60,23 @@ export default async function AnnuairePage({
...m,
hasPromo: badgeByMember.has(m.id),
promoBadge: badgeByMember.get(m.id) ?? null,
contact: contacts?.get(m.id) ?? null,
}));
return (
<div style={{ background: "#F6F2E8", minHeight: "100vh", fontFamily: "'Public Sans',sans-serif", color: "#33291D" }}>
<SiteHeader active="annuaire" logo />
<JsonLd
data={[
breadcrumbJsonLd(baseUrl, [
{ name: "Accueil", path: "/" },
{ name: "Annuaire", path: "/annuaire" },
]),
memberListJsonLd(baseUrl, "Annuaire des adhérents Plein R", rawMembers),
]}
/>
<div className="container" style={{ paddingBottom: 56 }}>
<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} />
@@ -78,10 +107,29 @@ 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 ?? ""} />
{/* Pages métier : une URL indexable par catégorie, en plus du filtre. */}
{categoriesInUse.length > 0 && (
<section style={{ marginTop: 34, background: "#fff", border: "1px solid #e6dcc6", borderRadius: 18, padding: "22px 24px" }}>
<h2 className="font-display" style={{ fontWeight: 700, fontSize: 19, margin: "0 0 6px", color: "#26201a" }}>
Parcourir par métier
</h2>
<p style={{ margin: "0 0 14px", fontSize: 14, color: "#6c6150" }}>
Tous les commerçants, artisans et entreprises du Bassin de Pompey, classés par activité.
</p>
<CategoryLinks categories={categoriesInUse} />
</section>
)}
{/* join CTA */}
<section
style={{
@@ -113,7 +161,7 @@ export default async function AnnuairePage({
style={{ border: "none", background: "#E0A63C", color: "#33291D", fontWeight: 700, fontSize: 15.5, padding: "14px 26px", borderRadius: 12, whiteSpace: "nowrap" }}
/>
</section>
</div>
</main>
<SiteFooter />
</div>
@@ -0,0 +1,42 @@
import { NextResponse } from "next/server";
import { getInformation } from "@/lib/informations";
export const dynamic = "force-dynamic";
/**
* Sert l'image de couverture d'une information **publiée**, en octets.
*
* Elle est stockée en data-URI, ce qui convient au site mais pas aux e-mails :
* Gmail et Outlook suppriment les `<img src="data:">`. Cette route donne au
* gabarit une URL ordinaire, et évite au passage de recopier plusieurs
* méga-octets dans chaque ligne de la file d'attente.
*
* Seules les informations publiées répondent : un brouillon ne doit pas fuiter
* par une URL devinable.
*/
const DATA_URI = /^data:(image\/(?:png|jpeg|jpg|webp|gif));base64,([A-Za-z0-9+/=\s]+)$/;
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id: raw } = await params;
const id = Number(raw);
if (!Number.isInteger(id) || id <= 0) return new NextResponse("Introuvable", { status: 404 });
const information = await getInformation(id);
if (!information || information.status !== "published" || !information.imageUrl) {
return new NextResponse("Introuvable", { status: 404 });
}
const match = DATA_URI.exec(information.imageUrl);
if (!match) return new NextResponse("Introuvable", { status: 404 });
const bytes = Buffer.from(match[2].replace(/\s+/g, ""), "base64");
return new NextResponse(bytes, {
headers: {
"Content-Type": match[1],
"Content-Length": String(bytes.length),
// L'image d'une information ne change pas : les clients mail peuvent la
// garder, et un message relu six mois plus tard l'affiche encore.
"Cache-Control": "public, max-age=86400, stale-while-revalidate=604800",
},
});
}
@@ -0,0 +1,84 @@
import { NextResponse } from "next/server";
import { getSession } from "@/lib/session";
import { can } from "@/lib/rbac";
import {
exchangeMailCode,
getActiveMailAccount,
getDecryptedAppSecret,
getMailAccount,
saveMailConnection,
setActiveMailProvider,
MAIL_PROVIDERS,
} from "@/lib/mail-accounts";
import { publicBaseUrl } from "@/lib/social-accounts";
import type { MailProvider } from "@/db/schema";
export const dynamic = "force-dynamic";
const SETTINGS = "/backend/boite-mail";
function back(request: Request, params: Record<string, string>) {
const url = new URL(SETTINGS, request.url);
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
const response = NextResponse.redirect(url);
// Le state a fait son office, quel que soit le résultat.
for (const provider of MAIL_PROVIDERS) response.cookies.delete(`plr_mail_oauth_${provider}`);
return response;
}
export async function GET(request: Request, { params }: { params: Promise<{ provider: string }> }) {
const session = await getSession();
if (!can(session?.user.role, "manageSettings")) {
return NextResponse.redirect(new URL("/backend", request.url));
}
const { provider: raw } = await params;
const provider = raw as MailProvider;
if (!MAIL_PROVIDERS.includes(provider) || provider === "smtp") {
return back(request, { error: "Fournisseur inconnu." });
}
const url = new URL(request.url);
const failure = url.searchParams.get("error_description") ?? url.searchParams.get("error");
if (failure) return back(request, { error: `Autorisation refusée : ${failure}` });
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
const expected = request.headers
.get("cookie")
?.split(";")
.map((cookie) => cookie.trim())
.find((cookie) => cookie.startsWith(`plr_mail_oauth_${provider}=`))
?.split("=")[1];
if (!code || !state || !expected || state !== expected) {
return back(request, { error: "Requête de retour invalide (state). Relancez la connexion." });
}
const account = await getMailAccount(provider);
const appSecret = await getDecryptedAppSecret(provider);
if (!account?.appId || !appSecret) {
return back(request, { error: "Identifiants d'application introuvables." });
}
const base = await publicBaseUrl();
if (!base) return back(request, { error: "Adresse publique du site introuvable." });
try {
const result = await exchangeMailCode(provider, account.appId, appSecret, code, base);
const userId = Number(session?.user.id);
await saveMailConnection({
provider,
...result,
connectedById: Number.isFinite(userId) ? userId : null,
});
// Première boîte connectée : elle devient l'expéditeur, sinon l'écran
// afficherait une connexion réussie sans qu'aucun message ne parte. Si un
// autre fournisseur expédie déjà, on ne le détrône pas dans son dos.
if (!(await getActiveMailAccount())) await setActiveMailProvider(provider);
return back(request, { connected: provider });
} catch (caught) {
const message = caught instanceof Error ? caught.message : "Échec de la connexion.";
return back(request, { error: message.slice(0, 400) });
}
}
@@ -0,0 +1,62 @@
import { randomBytes } from "node:crypto";
import { NextResponse } from "next/server";
import { getSession } from "@/lib/session";
import { can } from "@/lib/rbac";
import {
getDecryptedAppSecret,
getMailAccount,
mailAuthorizeUrl,
MAIL_PROVIDERS,
} from "@/lib/mail-accounts";
import { publicBaseUrl } from "@/lib/social-accounts";
import type { MailProvider } from "@/db/schema";
export const dynamic = "force-dynamic";
const SETTINGS = "/backend/boite-mail";
function back(request: Request, error: string) {
return NextResponse.redirect(new URL(`${SETTINGS}?error=${encodeURIComponent(error)}`, request.url));
}
export async function GET(request: Request, { params }: { params: Promise<{ provider: string }> }) {
const session = await getSession();
if (!can(session?.user.role, "manageSettings")) {
return NextResponse.redirect(new URL("/backend", request.url));
}
const { provider: raw } = await params;
const provider = raw as MailProvider;
if (!MAIL_PROVIDERS.includes(provider) || provider === "smtp") {
return back(request, "Fournisseur inconnu.");
}
// Réglage enregistré, sinon l'adresse par laquelle l'administrateur est
// arrivé : c'est celle où le fournisseur nous renverra.
const base = await publicBaseUrl();
if (!base) {
return back(
request,
"Impossible de déterminer l'adresse publique du site : renseignez-la dans Backend › Réseaux sociaux."
);
}
const account = await getMailAccount(provider);
const appSecret = await getDecryptedAppSecret(provider);
if (!account?.appId || !appSecret) {
return back(request, "Enregistrez d'abord l'identifiant et le secret de l'application.");
}
// `state` en cookie httpOnly : vérifié au retour pour écarter toute requête
// de rappel forgée. Aucun jeton ne transite jamais par une URL.
const state = randomBytes(24).toString("hex");
const response = NextResponse.redirect(await mailAuthorizeUrl(provider, account.appId, state, base));
response.cookies.set(`plr_mail_oauth_${provider}`, state, {
httpOnly: true,
sameSite: "lax",
secure: new URL(base).protocol === "https:",
path: "/",
maxAge: 600,
});
return response;
}
@@ -0,0 +1,85 @@
import { NextResponse } from "next/server";
import { getSession } from "@/lib/session";
import { can } from "@/lib/rbac";
import {
exchangeCode,
getDecryptedAppSecret,
getSocialAccount,
saveConnection,
SOCIAL_NETWORKS,
type SocialNetwork,
} from "@/lib/social-accounts";
export const dynamic = "force-dynamic";
const SETTINGS = "/backend/reseaux";
function back(request: Request, params: Record<string, string>) {
const url = new URL(SETTINGS, request.url);
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
const response = NextResponse.redirect(url);
// Le state a fait son office, quel que soit le résultat.
for (const network of SOCIAL_NETWORKS) response.cookies.delete(`plr_oauth_${network}`);
return response;
}
export async function GET(
request: Request,
{ params }: { params: Promise<{ network: string }> }
) {
const session = await getSession();
if (!can(session?.user.role, "manageSettings")) {
return NextResponse.redirect(new URL("/backend", request.url));
}
const { network: raw } = await params;
const network = raw as SocialNetwork;
if (!SOCIAL_NETWORKS.includes(network)) {
return back(request, { error: "Réseau inconnu." });
}
const url = new URL(request.url);
const error = url.searchParams.get("error_description") ?? url.searchParams.get("error");
if (error) {
return back(request, { error: `Autorisation refusée : ${error}` });
}
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
const expected = request.headers
.get("cookie")
?.split(";")
.map((c) => c.trim())
.find((c) => c.startsWith(`plr_oauth_${network}=`))
?.split("=")[1];
if (!code || !state || !expected || state !== expected) {
return back(request, { error: "Requête de retour invalide (state). Relancez la connexion." });
}
const account = await getSocialAccount(network);
const appSecret = await getDecryptedAppSecret(network);
if (!account?.appId || !appSecret) {
return back(request, { error: "Identifiants d'application introuvables." });
}
try {
const result = await exchangeCode(network, account.appId, appSecret, code);
const userId = Number(session?.user.id);
// Une seule page administrée : on la sélectionne d'office. Sinon on laisse
// choisir, en conservant le jeton utilisateur le temps de la sélection.
if (result.targets.length === 1) {
await saveConnection(network, result, result.targets[0], Number.isFinite(userId) ? userId : null);
return back(request, { connected: network });
}
// Plusieurs pages : on garde le jeton utilisateur, l'écran relistera les
// cibles côté serveur. Rien de sensible ne transite par l'URL.
await saveConnection(network, result, null, Number.isFinite(userId) ? userId : null);
return back(request, { choose: network });
} catch (caught) {
const message = caught instanceof Error ? caught.message : "Échec de la connexion.";
return back(request, { error: message.slice(0, 400) });
}
}
@@ -0,0 +1,65 @@
import { randomBytes } from "node:crypto";
import { NextResponse } from "next/server";
import { getSession } from "@/lib/session";
import { can } from "@/lib/rbac";
import {
authorizeUrl,
getDecryptedAppSecret,
getSocialAccount,
publicBaseUrl,
SOCIAL_NETWORKS,
type SocialNetwork,
} from "@/lib/social-accounts";
export const dynamic = "force-dynamic";
const SETTINGS = "/backend/reseaux";
function back(request: Request, error: string) {
return NextResponse.redirect(new URL(`${SETTINGS}?error=${encodeURIComponent(error)}`, request.url));
}
export async function GET(
request: Request,
{ params }: { params: Promise<{ network: string }> }
) {
const session = await getSession();
if (!can(session?.user.role, "manageSettings")) {
return NextResponse.redirect(new URL("/backend", request.url));
}
const { network: raw } = await params;
const network = raw as SocialNetwork;
if (!SOCIAL_NETWORKS.includes(network)) {
return back(request, "Réseau inconnu.");
}
// 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,
"Impossible de déterminer l'adresse publique du site : renseignez-la dans la section « URL publique du site »."
);
}
const account = await getSocialAccount(network);
const appSecret = await getDecryptedAppSecret(network);
if (!account?.appId || !appSecret) {
return back(request, "Enregistrez d'abord l'identifiant et le secret de l'application.");
}
// `state` en cookie httpOnly : vérifié au retour pour écarter toute requête
// de rappel forgée.
const state = randomBytes(24).toString("hex");
const response = NextResponse.redirect(await authorizeUrl(network, account.appId, state));
response.cookies.set(`plr_oauth_${network}`, state, {
httpOnly: true,
sameSite: "lax",
secure: new URL(base).protocol === "https:",
path: "/",
maxAge: 600,
});
return response;
}
+353
View File
@@ -0,0 +1,353 @@
import type { Metadata } from "next";
import Link from "next/link";
import { and, asc, desc, eq, gte, inArray, sql } from "drizzle-orm";
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";
import {
getSiteSettings,
parseBoardMembers,
parseParagraphs,
parsePillars,
splitLines,
} from "@/lib/site-settings";
import { breadcrumbJsonLd, eventJsonLd, pageMetadata } from "@/lib/seo";
import { publicBaseUrl } from "@/lib/seo-server";
export const dynamic = "force-dynamic";
const ASSOCIATION_TITLE = "Plein R, l'association des entreprises du Bassin de Pompey";
export const metadata: Metadata = {
...pageMetadata({
title: ASSOCIATION_TITLE,
ogTitle: ASSOCIATION_TITLE,
description:
"Plein R fédère commerçants, artisans et entreprises du Bassin de Pompey : mission, équipe, " +
"prochaines rencontres. Rejoignez le réseau économique local.",
path: "/association",
}),
// Le nom de l'association est déjà dans le titre : pas de suffixe « · Plein R ».
title: { absolute: ASSOCIATION_TITLE },
};
function formatDate(date: Date) {
return new Intl.DateTimeFormat("fr-FR", {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
}).format(date);
}
function formatTime(date: Date) {
return new Intl.DateTimeFormat("fr-FR", { hour: "2-digit", minute: "2-digit" }).format(date);
}
export default async function AssociationPage() {
const [settings, session, baseUrl] = await Promise.all([getSiteSettings(), auth(), publicBaseUrl()]);
const now = new Date();
const memberId = session?.user.memberId ?? null;
const upcoming = await db
.select({
id: meetings.id,
title: meetings.title,
startsAt: meetings.startsAt,
location: meetings.location,
description: meetings.description,
capacity: meetings.capacity,
participantsPerAccount: meetings.participantsPerAccount,
imageUrl: meetings.imageUrl,
registered: sql<number>`count(${meetingRegistrations.id})`,
})
.from(meetings)
.leftJoin(meetingRegistrations, eq(meetingRegistrations.meetingId, meetings.id))
.where(gte(meetings.startsAt, now))
.groupBy(meetings.id)
.orderBy(asc(meetings.startsAt));
const past = await db.select().from(pastMeetings).orderBy(desc(pastMeetings.eventDate)).limit(3);
const pastIds = past.map((meeting) => meeting.id);
const linkedMeetingIds = past.map((meeting) => meeting.meetingId).filter((id): id is number => id !== null);
const [pastPhotos, linkedRegistrations] = await Promise.all([
pastIds.length > 0
? db
.select()
.from(pastMeetingPhotos)
.where(inArray(pastMeetingPhotos.pastMeetingId, pastIds))
.orderBy(asc(pastMeetingPhotos.position), asc(pastMeetingPhotos.id))
: [],
linkedMeetingIds.length > 0
? db
.select({ meetingId: meetingRegistrations.meetingId, id: meetingRegistrations.id })
.from(meetingRegistrations)
.where(inArray(meetingRegistrations.meetingId, linkedMeetingIds))
: [],
]);
const myRegistrations =
memberId && upcoming.length > 0
? await db
.select({ meetingId: meetingRegistrations.meetingId })
.from(meetingRegistrations)
.where(
and(
eq(meetingRegistrations.memberId, memberId),
inArray(
meetingRegistrations.meetingId,
upcoming.map((meeting) => meeting.id)
)
)
)
: [];
const myMeetingIds = new Set(myRegistrations.map((row) => row.meetingId));
const photosByPast = new Map<number, typeof pastPhotos>();
for (const photo of pastPhotos) {
photosByPast.set(photo.pastMeetingId, [...(photosByPast.get(photo.pastMeetingId) ?? []), photo]);
}
const registrationCounts = new Map<number, number>();
for (const registration of linkedRegistrations) {
registrationCounts.set(registration.meetingId, (registrationCounts.get(registration.meetingId) ?? 0) + 1);
}
const officers = [
["Vice-présidence", settings.association_vice_president],
["Trésorerie", settings.association_treasurer],
["Secrétariat", settings.association_secretary],
].filter(([, name]) => name);
const board = parseBoardMembers(settings.association_board_members);
const missionParagraphs = parseParagraphs(settings.association_mission);
const pillars = parsePillars(settings.association_pillars);
return (
<div style={{ background: "#F6F2E8", minHeight: "100vh", fontFamily: "'Public Sans',sans-serif", color: "#33291D" }}>
<SiteHeader active="association" logo />
<JsonLd
data={[
breadcrumbJsonLd(baseUrl, [
{ name: "Accueil", path: "/" },
{ name: "L'association", path: "/association" },
]),
...upcoming.map((meeting) => eventJsonLd(baseUrl, meeting)),
]}
/>
<main>
<section className="container" style={{ paddingTop: 34, paddingBottom: 52 }}>
<div style={{ maxWidth: 900 }}>
<div style={{ color: "#9a6638", fontSize: 12, letterSpacing: "0.14em", textTransform: "uppercase", fontWeight: 800, marginBottom: 12 }}>
Association
</div>
<h1 className="font-display" style={{ margin: 0, color: "#26201a", fontSize: "clamp(42px, 8vw, 76px)", lineHeight: 0.94, letterSpacing: "-0.03em" }}>
{settings.association_name}
</h1>
<p style={{ margin: "18px 0 0", maxWidth: 760, color: "#6c6150", fontSize: "clamp(17px, 2vw, 21px)", lineHeight: 1.65 }}>
{settings.association_intro}
</p>
</div>
</section>
<section style={{ background: "#13324F", color: "#fff" }}>
<div className="container" style={{ paddingTop: 44, paddingBottom: 48 }}>
<div className="grid grid-2" style={{ alignItems: "start" }}>
<div>
<div style={{ color: "#E0A63C", fontSize: 12, letterSpacing: "0.14em", textTransform: "uppercase", fontWeight: 800, marginBottom: 10 }}>
Mission
</div>
<h2 className="font-display" style={{ margin: 0, fontSize: "clamp(28px, 4vw, 44px)" }}>
Réseau, rencontres, réussite locale.
</h2>
</div>
<div>
{missionParagraphs.map((paragraph, index) => (
<p
key={index}
style={{
margin: index === 0 ? 0 : "14px 0 0",
color: "#cfe0ee",
fontSize: 16,
lineHeight: 1.75,
}}
>
{paragraph}
</p>
))}
</div>
</div>
{pillars.length > 0 && (
<>
<div style={{ color: "#E0A63C", fontSize: 12, letterSpacing: "0.14em", textTransform: "uppercase", fontWeight: 800, margin: "44px 0 16px" }}>
Ce que l&apos;association vous apporte
</div>
<div className="pillars-grid">
{pillars.map((pillar, index) => (
<article key={pillar.title} className="pillar-card">
<span className="font-display pillar-card__index">
{String(index + 1).padStart(2, "0")}
</span>
<h3 className="font-display pillar-card__title">{pillar.title}</h3>
{pillar.description && (
<p className="pillar-card__text">{pillar.description}</p>
)}
</article>
))}
</div>
</>
)}
</div>
</section>
<section className="container" style={{ paddingTop: 54, paddingBottom: 54 }}>
<div style={{ display: "flex", justifyContent: "space-between", gap: 18, alignItems: "end", flexWrap: "wrap", marginBottom: 20 }}>
<div>
<div style={{ color: "#9a6638", fontSize: 12, letterSpacing: "0.14em", textTransform: "uppercase", fontWeight: 800 }}>
Rencontres
</div>
<h2 className="font-display" style={{ margin: "8px 0 0", color: "#26201a", fontSize: 34 }}>
Prochaines dates
</h2>
</div>
{!session?.user && (
<Link href="/login" style={{ textDecoration: "none", background: "#9a6638", color: "#fff", borderRadius: 999, padding: "11px 18px", fontWeight: 800 }}>
Se connecter pour s'inscrire
</Link>
)}
</div>
{upcoming.length === 0 && (
<div style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 14, padding: 24, color: "#8c8068" }}>
Aucune rencontre programmée pour le moment.
</div>
)}
<div className="grid grid-3" style={{ gap: 18 }}>
{upcoming.map((meeting) => {
const registered = Number(meeting.registered);
const remaining = Math.max(0, meeting.capacity - registered);
const isRegistered = myMeetingIds.has(meeting.id);
return (
<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>
</section>
<section style={{ background: "#EFE9DA" }}>
<div className="container" style={{ paddingTop: 54, paddingBottom: 54 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "end", gap: 18, flexWrap: "wrap", marginBottom: 20 }}>
<div>
<div style={{ color: "#9a6638", fontSize: 12, letterSpacing: "0.14em", textTransform: "uppercase", fontWeight: 800 }}>
Ils y étaient
</div>
<h2 className="font-display" style={{ margin: "8px 0 0", color: "#26201a", fontSize: 34 }}>
Nos dernières rencontres
</h2>
</div>
<Link href="/rencontres-passees" style={{ display: "inline-block", background: "#13324F", color: "#fff", borderRadius: 8, padding: "11px 16px", fontSize: 13.5, fontWeight: 800, textDecoration: "none" }}>
Voir toutes les rencontres passées
</Link>
</div>
{past.length === 0 ? (
<div style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 24, color: "#8c8068" }}>
Aucune rencontre passée publiée pour le moment.
</div>
) : (
<div className="grid grid-3" style={{ gap: 18 }}>
{past.map((meeting) => {
const photos = photosByPast.get(meeting.id) ?? [];
const participantCount = meeting.meetingId
? registrationCounts.get(meeting.meetingId) ?? 0
: splitLines(meeting.participants ?? "").length;
return (
<article key={meeting.id} style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 18 }}>
<div style={{ color: "#9a6638", fontSize: 12.5, fontWeight: 800 }}>{formatDate(meeting.eventDate)}</div>
<h3 className="font-display" style={{ color: "#26201a", fontSize: 22, margin: "7px 0" }}>{meeting.title}</h3>
{meeting.location && <div style={{ color: "#6c6150", fontSize: 13, fontWeight: 700 }}>{meeting.location}</div>}
{meeting.description && <p style={{ color: "#8c8068", fontSize: 13.5, lineHeight: 1.65 }}>{meeting.description}</p>}
<div style={{ color: "#6c6150", fontSize: 12.5, fontWeight: 800, marginBottom: 12 }}>
{participantCount} participant{participantCount > 1 ? "s" : ""} · {photos.length} photo{photos.length > 1 ? "s" : ""}
</div>
<PastMeetingGallery title={meeting.title} photos={photos} />
</article>
);
})}
</div>
)}
</div>
</section>
<section className="container" style={{ paddingTop: 54, paddingBottom: 64 }}>
<div style={{ color: "#9a6638", fontSize: 12, letterSpacing: "0.14em", textTransform: "uppercase", fontWeight: 800 }}>
Gouvernance
</div>
<h2 className="font-display" style={{ margin: "8px 0 20px", color: "#26201a", fontSize: 34 }}>
Directoire
</h2>
<div className="grid grid-2" style={{ gap: 18 }}>
<article style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 12, padding: 22 }}>
{settings.association_president_photo && (
// eslint-disable-next-line @next/next/no-img-element
<img src={settings.association_president_photo} alt="" style={{ width: 86, height: 86, objectFit: "cover", borderRadius: "50%", marginBottom: 14 }} />
)}
<div style={{ color: "#9a6638", fontSize: 12, fontWeight: 800 }}>{settings.association_president_role}</div>
<h3 className="font-display" style={{ color: "#26201a", fontSize: 24, margin: "6px 0" }}>
{settings.association_president || "Présidence à renseigner"}
</h3>
{settings.association_president_message && (
<p style={{ color: "#6c6150", lineHeight: 1.7 }}>{settings.association_president_message}</p>
)}
</article>
<div className="grid" style={{ gap: 12 }}>
{[...officers.map(([role, name]) => ({ role, name })), ...board].map((person, index) => (
<article key={`${person.role}-${person.name}-${index}`} style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 12, padding: "16px 18px" }}>
<div style={{ color: "#9a6638", fontSize: 12, fontWeight: 800 }}>{person.role}</div>
<div className="font-display" style={{ color: "#26201a", fontSize: 19, fontWeight: 800, marginTop: 3 }}>
{person.name}
</div>
</article>
))}
{officers.length === 0 && board.length === 0 && (
<article style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 12, padding: 18, color: "#8c8068" }}>
Les membres du directoire seront affichés ici après configuration.
</article>
)}
</div>
</div>
</section>
</main>
<SiteFooter />
</div>
);
}
+244 -166
View File
@@ -2,8 +2,9 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import type { ReactNode } from "react";
import { can, ROLE_LABELS } from "@/lib/rbac";
import { useEffect, useState, type ReactNode } from "react";
import { BackendIcon, type BackendIconName } from "@/components/BackendIcons";
import { can, isStaff, ROLE_LABELS } from "@/lib/rbac";
import type { AppRole } from "@/types/next-auth";
import { doSignOut } from "./actions";
@@ -15,12 +16,89 @@ const TITLES: Record<string, [string, string]> = {
"Modération des promotions",
"Validez les offres soumises par les adhérents",
],
"/backend/rencontres": ["Rencontres", "Gérer les prochaines dates"],
"/backend/inscriptions": ["Inscriptions", "Gérer les participants aux rencontres"],
"/backend/rencontres-passees": ["Rencontres passées", "Publier les archives et leurs photos"],
"/backend/informations": ["Informations", "Ce que l'association publie dans l'espace adhérent"],
"/backend/emails": ["Création d'e-mails", "Composer des messages aux couleurs de Plein R"],
"/backend/reseaux": ["Réseaux sociaux", "Connecter les pages Facebook et LinkedIn"],
"/backend/boite-mail": ["Boîte mail", "Brancher la boîte de l'association pour les envois du site"],
"/backend/administrateurs": ["Administrateurs", "Gérer les accès à l'administration"],
"/backend/categories": ["Catégories", "Gérer les métiers de l'annuaire"],
"/backend/espace": ["Mon espace adhérent", "Publiez et suivez vos promotions"],
"/backend/parametres": ["Paramètres du site", "Configurer l'association et les mentions légales"],
"/backend/espace": ["Mon profil", "Votre fiche publique, vos inscriptions et votre droit à l'image"],
"/backend/espace/promotions": ["Mes promotions", "Proposez une offre et suivez celles en ligne"],
"/backend/espace/informations": ["Informations", "Les annonces de l'association"],
"/backend/changer-mot-de-passe": ["Mot de passe", "Sécurisez votre compte"],
};
type Capability = Parameters<typeof can>[1];
type BadgeKey = "inbox" | "promos" | "infos";
type NavItem = {
href: string;
label: string;
icon: BackendIconName;
capability?: Capability;
badge?: BadgeKey;
/** Les fiches adhérent sont des sous-routes : l'entrée doit rester active. */
prefix?: boolean;
};
type NavSection = { label: string; items: NavItem[] };
/**
* Menu groupé par usage plutôt qu'en une seule pile : on distingue le quotidien
* (réseau, rencontres) de ce qu'on ne touche qu'occasionnellement (configuration).
*/
const SECTIONS: NavSection[] = [
{
label: "Pilotage",
items: [{ href: "/backend", label: "Tableau de bord", icon: "dashboard", capability: "viewDashboard" }],
},
{
label: "Vie du réseau",
items: [
{ href: "/backend/adherents", label: "Adhérents", icon: "members", capability: "manageMembers", prefix: true },
{ href: "/backend/demandes", label: "Demandes & messages", icon: "inbox", capability: "manageMembers", badge: "inbox" },
{ href: "/backend/promotions", label: "Promotions", icon: "promos", capability: "moderatePromos", badge: "promos" },
],
},
{
label: "Rencontres",
items: [
{ href: "/backend/rencontres", label: "Prochaines dates", icon: "meetings", capability: "manageMeetings" },
{ href: "/backend/inscriptions", label: "Inscriptions", icon: "registrations", capability: "manageMeetings" },
{ href: "/backend/rencontres-passees", label: "Archives & photos", icon: "gallery", capability: "manageMeetings" },
],
},
{
label: "Communication",
items: [
{ href: "/backend/informations", label: "Informations", icon: "news", capability: "manageInformations" },
{ href: "/backend/emails", label: "E-mails", icon: "emails", capability: "manageEmails" },
{ href: "/backend/reseaux", label: "Réseaux sociaux", icon: "social", capability: "manageSettings" },
],
},
{
label: "Configuration",
items: [
{ href: "/backend/categories", label: "Catégories", icon: "categories", capability: "manageCategories" },
{ href: "/backend/boite-mail", label: "Boîte mail", icon: "mailbox", capability: "manageSettings" },
{ href: "/backend/parametres", label: "Paramètres du site", icon: "settings", capability: "manageSettings" },
{ href: "/backend/administrateurs", label: "Administrateurs", icon: "admins", capability: "manageAdmins" },
],
},
{
label: "Côté adhérent",
items: [
{ href: "/backend/espace/informations", label: "Informations", icon: "news", badge: "infos" },
{ href: "/backend/espace", label: "Mon profil", icon: "space" },
{ href: "/backend/espace/promotions", label: "Mes promotions", icon: "promos" },
],
},
];
function initialsOf(name: string) {
return name
.split(/\s+/)
@@ -30,19 +108,21 @@ function initialsOf(name: string) {
.toUpperCase();
}
function NavLink({
href,
active,
children,
}: {
href: string;
active: boolean;
children: ReactNode;
}) {
function Badge({ value }: { value: number }) {
return (
<Link href={href} className={`nav-btn${active ? " active" : ""}`}>
{children}
</Link>
<span
style={{
marginLeft: "auto",
background: "#E0A63C",
color: "#33291D",
fontSize: 11,
fontWeight: 800,
borderRadius: 999,
padding: "1px 8px",
}}
>
{value}
</span>
);
}
@@ -50,127 +130,163 @@ export function BackendShell({
user,
pendingCount,
inboxCount = 0,
infoCount = 0,
children,
}: {
user: { name: string; role: AppRole };
pendingCount: number;
inboxCount?: number;
infoCount?: number;
children: ReactNode;
}) {
const pathname = usePathname();
const [menuOpen, setMenuOpen] = useState(false);
const [title, sub] =
TITLES[pathname] ??
(pathname.startsWith("/backend/adherents")
? TITLES["/backend/adherents"]
: ["Administration", "Plein R"]);
const dot = (
<span style={{ width: 18, height: 18, border: "2px solid currentColor", borderRadius: 5, display: "inline-block" }} />
// Le tiroir se referme dès qu'on navigue.
useEffect(() => {
setMenuOpen(false);
}, [pathname]);
// Pas de défilement de la page derrière le tiroir ouvert.
useEffect(() => {
if (!menuOpen) return;
const previous = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = previous;
};
}, [menuOpen]);
useEffect(() => {
if (!menuOpen) return;
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") setMenuOpen(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [menuOpen]);
const badges: Record<BadgeKey, number> = { inbox: inboxCount, promos: pendingCount, infos: infoCount };
const sections = SECTIONS.map((section) => ({
...section,
items: section.items.filter((item) => !item.capability || can(user.role, item.capability)),
})).filter((section) => section.items.length > 0);
const siteLink = (
<Link href="/" className="backend-action">
← Voir le site
</Link>
);
const dotRound = (
<span style={{ width: 18, height: 18, border: "2px solid currentColor", borderRadius: "50%", display: "inline-block" }} />
);
const dotDiamond = (
<span style={{ width: 18, height: 18, border: "2px solid currentColor", borderRadius: 5, display: "inline-block", transform: "rotate(45deg)" }} />
const signOut = (
<form action={doSignOut}>
<button type="submit" className="backend-action backend-action--strong">
Déconnexion
</button>
</form>
);
/**
* Un adhérent n'a pas de barre latérale : elle ne porterait que la section
* « Côté adhérent », c'est-à-dire les trois entrées déjà présentes en onglets
* sous le bandeau de l'espace. Deux menus pour la même chose, dont un qui
* mange un quart de l'écran. Il ne reste donc qu'un bandeau de marque, et le
* contenu prend toute la largeur — recentré pour ne pas s'étirer.
*
* Le staff la garde : chez lui elle est la navigation principale, et
* « Côté adhérent » y est le seul chemin vers son propre espace.
*/
if (!isStaff(user.role)) {
return (
<div className="backend backend--plain" style={{ fontFamily: "'Public Sans',sans-serif", color: "#33291D" }}>
<header className="backend-header backend-header--plain">
<Link href="/backend/espace/informations" className="backend-brand">
<span className="backend-brand__mark">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/assets/logo.png" alt="Plein R" style={{ height: 34, width: "auto", display: "block" }} />
</span>
<span className="backend-brand__text">
<span className="font-display backend-brand__name">Plein R</span>
<span className="backend-brand__sub">Espace adhérent</span>
</span>
</Link>
<div className="backend-header__actions">
{siteLink}
{signOut}
</div>
</header>
{/* Le bandeau de l'espace porte déjà le nom et les onglets : répéter
ici le titre de la page ferait un troisième étage d'en-tête. */}
<div className="backend-content backend-content--plain">{children}</div>
</div>
);
}
return (
<div className="backend" style={{ fontFamily: "'Public Sans',sans-serif", color: "#33291D" }}>
{/* sidebar */}
<aside className="sidebar">
<div style={{ display: "flex", alignItems: "center", gap: 11, padding: "6px 8px 22px" }}>
{menuOpen && (
<button
type="button"
aria-label="Fermer le menu"
className="sidebar-backdrop"
onClick={() => setMenuOpen(false)}
/>
)}
<aside className={`sidebar${menuOpen ? " is-open" : ""}`} id="backend-nav">
<div className="sidebar-brand">
<span style={{ display: "inline-flex", background: "#F6F2E8", borderRadius: 11, padding: 6 }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/assets/logo.png" alt="Plein R" style={{ height: 34, width: "auto", display: "block" }} />
</span>
<div>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="font-display" style={{ fontWeight: 800, fontSize: 17, color: "#fff", lineHeight: 1 }}>
Plein R
</div>
<div style={{ fontSize: 11, color: "#7f9bb4", marginTop: 3 }}>Administration</div>
</div>
<button
type="button"
className="sidebar-close"
aria-label="Fermer le menu"
onClick={() => setMenuOpen(false)}
>
<BackendIcon name="close" size={20} />
</button>
</div>
<div style={{ flex: "1 1 auto", display: "flex", flexDirection: "column", minWidth: 200 }}>
{can(user.role, "viewDashboard") && (
<>
<div style={sectionLabel}>Pilotage</div>
<NavLink href="/backend" active={pathname === "/backend"}>
{dot}Tableau de bord
</NavLink>
{can(user.role, "manageMembers") && (
<NavLink href="/backend/adherents" active={pathname.startsWith("/backend/adherents")}>
{dotRound}Adhérents
</NavLink>
)}
{can(user.role, "manageMembers") && (
<NavLink href="/backend/demandes" active={pathname === "/backend/demandes"}>
{dot}Demandes &amp; messages
{inboxCount > 0 && (
<span
style={{
marginLeft: "auto",
background: "#E0A63C",
color: "#33291D",
fontSize: 11,
fontWeight: 800,
borderRadius: 999,
padding: "1px 8px",
}}
>
{inboxCount}
</span>
)}
</NavLink>
)}
{can(user.role, "moderatePromos") && (
<NavLink href="/backend/promotions" active={pathname === "/backend/promotions"}>
{dotDiamond}Promotions
<span
style={{
marginLeft: "auto",
background: "#E0A63C",
color: "#33291D",
fontSize: 11,
fontWeight: 800,
borderRadius: 999,
padding: "1px 8px",
}}
<nav className="sidebar-nav">
{sections.map((section) => (
<div key={section.label}>
<div className="sidebar-section">{section.label}</div>
{section.items.map((item) => {
const active = item.prefix ? pathname.startsWith(item.href) : pathname === item.href;
const badge = item.badge ? badges[item.badge] : 0;
return (
<Link
key={item.href}
href={item.href}
className={`nav-btn${active ? " active" : ""}`}
aria-current={active ? "page" : undefined}
>
{pendingCount}
</span>
</NavLink>
)}
{can(user.role, "manageCategories") && (
<NavLink href="/backend/categories" active={pathname === "/backend/categories"}>
{dotDiamond}Catégories
</NavLink>
)}
{can(user.role, "manageAdmins") && (
<NavLink href="/backend/administrateurs" active={pathname === "/backend/administrateurs"}>
{dot}Administrateurs
</NavLink>
)}
</>
)}
<BackendIcon name={item.icon} />
{item.label}
{item.badge && badge > 0 && <Badge value={badge} />}
</Link>
);
})}
</div>
))}
</nav>
<div style={sectionLabel}>Côté adhérent</div>
<NavLink href="/backend/espace" active={pathname === "/backend/espace"}>
{dotRound}Mon espace
</NavLink>
</div>
<div
style={{
marginTop: "auto",
display: "flex",
alignItems: "center",
gap: 11,
background: "rgba(255,255,255,0.06)",
borderRadius: 12,
padding: "11px 12px",
}}
>
<div className="sidebar-user">
<span
className="font-display"
style={{
@@ -196,76 +312,38 @@ export function BackendShell({
<div style={{ fontSize: 11, color: "#7f9bb4" }}>{ROLE_LABELS[user.role]}</div>
</div>
</div>
{/* Sur téléphone, l'entête n'a pas la place : les actions vivent ici. */}
<div className="sidebar-actions">
{siteLink}
{signOut}
</div>
</aside>
{/* main */}
<main style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column" }}>
<header
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "18px 32px",
background: "#F6F2E8",
borderBottom: "1px solid #e2d6bd",
position: "sticky",
top: 0,
zIndex: 5,
gap: 16,
}}
>
<div>
<h1 className="font-display" style={{ fontWeight: 800, fontSize: 22, margin: 0, color: "#26201a" }}>
{title}
</h1>
<div style={{ fontSize: 13, color: "#9a8d72", marginTop: 2 }}>{sub}</div>
<header className="backend-header">
<button
type="button"
className="backend-burger"
aria-label="Ouvrir le menu"
aria-expanded={menuOpen}
aria-controls="backend-nav"
onClick={() => setMenuOpen(true)}
>
<BackendIcon name="menu" size={22} />
</button>
<div style={{ minWidth: 0, flex: 1 }}>
<h1 className="font-display backend-header__title">{title}</h1>
<div className="backend-header__sub">{sub}</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<Link
href="/"
style={{
textDecoration: "none",
fontSize: 13.5,
fontWeight: 600,
color: "#6f6450",
border: "1px solid #d8cdb4",
padding: "9px 15px",
borderRadius: 10,
}}
>
← Voir le site
</Link>
<form action={doSignOut}>
<button
type="submit"
style={{
background: "#fff",
border: "1px solid #d8cdb4",
color: "#9a6638",
fontSize: 13.5,
fontWeight: 700,
padding: "9px 15px",
borderRadius: 10,
cursor: "pointer",
}}
>
Déconnexion
</button>
</form>
<div className="backend-header__actions">
{siteLink}
{signOut}
</div>
</header>
<div style={{ padding: "28px 32px 44px", flex: 1 }}>{children}</div>
<div className="backend-content">{children}</div>
</main>
</div>
);
}
const sectionLabel = {
fontSize: 10.5,
letterSpacing: "0.14em",
textTransform: "uppercase" as const,
color: "#5f7d97",
fontWeight: 700,
padding: "20px 10px 8px",
};
+1510 -91
View File
File diff suppressed because it is too large. Load diff
+43 -10
View File
@@ -1,9 +1,11 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { BASSIN_POMPEY_COMMUNES } from "@/lib/communes";
import { addMember } from "../actions";
import { OneTimeCredentials } from "@/components/OneTimeCredentials";
import { addMember, type CreatedMemberAccount } from "../actions";
export function AddMemberPanel({
categories,
@@ -11,10 +13,24 @@ export function AddMemberPanel({
categories: { id: number; label: string }[];
}) {
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 (
<div style={{ display: "flex", flexDirection: "column", gap: 0 }}>
{created && (
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginBottom: 14 }}>
<OneTimeCredentials items={[created]} title="Adhérent créé — identifiants à transmettre" />
<Link href={`/backend/adherents/${created.memberId}`} style={{ color: "#2C6FB3", fontWeight: 700, fontSize: 13, textDecoration: "none" }}>
Ouvrir la fiche adhérent →
</Link>
</div>
)}
<div style={{ display: "flex", justifyContent: "flex-end" }}>
<button
onClick={() => setOpen((o) => !o)}
@@ -27,26 +43,43 @@ export function AddMemberPanel({
{open && (
<form
key={attempt}
action={async (fd) => {
const id = await addMember(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);
// Redirige vers la fiche : le login + mot de passe temporaire y sont affichés.
if (id) router.push(`/backend/adherents/${id}`);
// On reste sur la page : le mot de passe temporaire n'est affiché
// qu'ici, une seule fois, et n'est conservé nulle part.
setCreated(result ?? null);
router.refresh();
}}
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&apos;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}>
@@ -57,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}>
@@ -68,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>
@@ -76,7 +109,7 @@ export function AddMemberPanel({
</div>
<div style={{ marginTop: 14, fontSize: 12.5, color: "#9a8d72" }}>
Un compte de connexion est créé automatiquement. Le mot de passe temporaire s&apos;affiche
sur la fiche de l&apos;adhérent jusqu&apos;à sa première connexion.
une seule fois, juste après l&apos;enregistrement : notez-le avant de quitter la page.
</div>
<button
type="submit"
@@ -0,0 +1,50 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { OneTimeCredentials, type IssuedCredentialsItem } from "@/components/OneTimeCredentials";
import { createMissingMemberAccounts } from "../actions";
export function BackfillAccountsForm({ missingAccounts }: { missingAccounts: number }) {
const [issued, setIssued] = useState<IssuedCredentialsItem[] | null>(null);
const [pending, setPending] = useState(false);
const router = useRouter();
if (issued) {
return (
<OneTimeCredentials
items={issued}
title={`${issued.length} compte(s) créé(s) — identifiants à transmettre`}
/>
);
}
return (
<form
action={async () => {
setPending(true);
try {
const created = await createMissingMemberAccounts();
// On propage tout : l'état d'envoi accompagne chaque ligne.
setIssued(created.map((c) => ({ ...c, label: c.name })));
router.refresh();
} finally {
setPending(false);
}
}}
style={{ background: "#fbeede", border: "1px solid #ecd8b8", borderRadius: 12, padding: "14px 18px", display: "flex", alignItems: "center", justifyContent: "space-between", gap: 14, flexWrap: "wrap" }}
>
<div style={{ fontSize: 13.5, color: "#9a6638" }}>
<strong>{missingAccounts}</strong> adhérent(s) avec un e-mail n&apos;ont pas encore de compte de connexion.
</div>
<button
type="submit"
disabled={pending}
className="font-display"
style={{ border: "none", background: "#9a6638", color: "#fff", fontWeight: 700, fontSize: 13.5, padding: "10px 18px", borderRadius: 10, cursor: "pointer", whiteSpace: "nowrap" }}
>
{pending ? "Création…" : "Créer les comptes manquants"}
</button>
</form>
);
}
@@ -0,0 +1,36 @@
"use client";
import { useState } from "react";
import { OneTimeCredentials } from "@/components/OneTimeCredentials";
import { resetMemberPassword, type IssuedCredentials } from "../actions";
export function ResetPasswordButton({ memberId }: { memberId: number }) {
const [issued, setIssued] = useState<IssuedCredentials | null>(null);
const [pending, setPending] = useState(false);
return (
<div style={{ marginTop: 14, display: "flex", flexDirection: "column", gap: 12 }}>
{issued && <OneTimeCredentials items={[issued]} title="Nouveau mot de passe temporaire" />}
<form
action={async (fd) => {
setPending(true);
try {
const result = await resetMemberPassword(fd);
setIssued(result ?? null);
} finally {
setPending(false);
}
}}
>
<input type="hidden" name="memberId" value={memberId} />
<button
type="submit"
disabled={pending}
style={{ border: "1px solid #d8cdb4", background: "#fff", color: "#6f6450", fontWeight: 600, fontSize: 13, padding: "9px 15px", borderRadius: 9, cursor: "pointer" }}
>
{pending ? "Réinitialisation…" : "Réinitialiser le mot de passe"}
</button>
</form>
</div>
);
}
+31 -29
View File
@@ -1,13 +1,17 @@
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { asc, eq } from "drizzle-orm";
import { auth } from "@/auth";
import { getSession } from "@/lib/session";
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";
import { deleteMember, resetMemberPassword, updateMember } from "../../actions";
import { deleteMember, updateMember } from "../../actions";
import { ResetPasswordButton } from "../ResetPasswordButton";
export const dynamic = "force-dynamic";
@@ -16,7 +20,7 @@ export default async function EditMemberPage({
}: {
params: Promise<{ id: string }>;
}) {
const session = await auth();
const session = await getSession();
if (!can(session?.user.role, "manageMembers")) {
redirect("/backend");
}
@@ -29,12 +33,12 @@ export default async function EditMemberPage({
if (!member) notFound();
const cats = await db
.select({ id: categories.id, label: categories.label })
.select({ id: categories.id, slug: categories.slug, label: categories.label })
.from(categories)
.orderBy(asc(categories.sort));
const [account] = await db
.select({ email: users.email, tempPassword: users.tempPassword, mustChange: users.mustChangePassword })
.select({ email: users.email, mustChange: users.mustChangePassword })
.from(users)
.where(eq(users.memberId, memberId));
@@ -52,14 +56,6 @@ export default async function EditMemberPage({
redirect("/backend/adherents");
}
async function handleReset() {
"use server";
const fd = new FormData();
fd.set("memberId", String(memberId));
await resetMemberPassword(fd);
redirect(`/backend/adherents/${memberId}`);
}
return (
<div style={{ maxWidth: 720 }}>
<Link href="/backend/adherents" style={{ textDecoration: "none", color: "#6f6450", fontSize: 13.5, fontWeight: 600 }}>
@@ -78,9 +74,14 @@ export default async function EditMemberPage({
<input name="name" required defaultValue={member.name} className="field" />
</div>
<div>
<label className="field-label">E-mail</label>
<label className="field-label">E-mail du compte (identifiant, échanges avec l&apos;association)</label>
<input name="email" type="email" defaultValue={member.email} className="field" />
</div>
<div>
<label className="field-label">E-mail de contact (affiché sur la fiche)</label>
<input name="contactEmail" type="email" defaultValue={member.contactEmail ?? ""} className="field" placeholder={member.email} />
<p className="field-hint">Laissé vide, la fiche publique affiche l&apos;e-mail du compte.</p>
</div>
<div>
<label className="field-label">Catégorie</label>
<select name="categoryId" className="field" defaultValue={member.categoryId ?? ""}>
@@ -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" />
@@ -140,16 +147,19 @@ export default async function EditMemberPage({
<div style={{ marginTop: 16 }}>
<label className="field-label">Description (une ligne vide sépare les paragraphes)</label>
<textarea name="description" rows={4} defaultValue={member.description ?? ""} className="field" style={{ resize: "vertical" }} />
<p className="field-hint">
Ce texte est lu par Google : indiquez votre métier, votre commune et vos spécialités en trois à cinq
phrases (ex. « Boulangerie artisanale à Frouard : pain au levain, viennoiseries, pâtisseries maison »).
</p>
</div>
<div style={{ marginTop: 16 }}>
<label className="field-label">Tags / spécialités (séparés par des virgules)</label>
<input name="tags" defaultValue={member.tags ?? ""} className="field" placeholder="Levain naturel, Produits locaux, Fait maison" />
<TagsField defaultValue={member.tags} categories={cats} />
</div>
<div style={{ marginTop: 16 }}>
<label className="field-label">Horaires (une ligne par jour : « Jour|plage », ex : Mardi – Vendredi|7h – 19h30)</label>
<textarea name="hours" rows={4} defaultValue={member.hours ?? ""} className="field" style={{ resize: "vertical" }} placeholder={"Mardi – Vendredi|7h – 19h30\nSamedi|7h – 19h\nDimanche|7h – 13h\nLundi|Fermé"} />
<HoursEditor defaultValue={member.hours} />
</div>
<div style={{ display: "flex", gap: 10, marginTop: 22 }}>
@@ -172,12 +182,11 @@ export default async function EditMemberPage({
<div style={{ fontSize: 13.5, color: "#5a5040", marginBottom: 10 }}>
Identifiant : <strong>{account.email}</strong>
</div>
{account.tempPassword ? (
{account.mustChange ? (
<div style={{ background: "#fbeede", border: "1px solid #ecd8b8", borderRadius: 10, padding: "11px 14px", fontSize: 13.5, color: "#9a6638" }}>
Mot de passe temporaire :{" "}
<strong style={{ fontFamily: "monospace", fontSize: 15 }}>{account.tempPassword}</strong>
En attente de première connexion : l&apos;adhérent devra changer son mot de passe temporaire.
<div style={{ fontSize: 12, marginTop: 4 }}>
Communiquez-le à l&apos;adhérent. Il disparaît dès sa première connexion (changement obligatoire).
Mot de passe égaré ? Réinitialisez-le : un nouveau vous sera montré une seule fois.
</div>
</div>
) : (
@@ -185,14 +194,7 @@ export default async function EditMemberPage({
✓ L&apos;adhérent a défini son propre mot de passe.
</div>
)}
<form action={handleReset} style={{ marginTop: 14 }}>
<button
type="submit"
style={{ border: "1px solid #d8cdb4", background: "#fff", color: "#6f6450", fontWeight: 600, fontSize: 13, padding: "9px 15px", borderRadius: 9, cursor: "pointer" }}
>
Réinitialiser le mot de passe
</button>
</form>
<ResetPasswordButton memberId={memberId} />
</>
) : (
<div style={{ fontSize: 13.5, color: "#a99c82" }}>
+16 -39
View File
@@ -1,12 +1,12 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { asc, eq, ilike } from "drizzle-orm";
import { auth } from "@/auth";
import { getSession } from "@/lib/session";
import { db } from "@/db";
import { categories, members, users } from "@/db/schema";
import { can } from "@/lib/rbac";
import { createMissingMemberAccounts } from "../actions";
import { AddMemberPanel } from "./AddMemberPanel";
import { BackfillAccountsForm } from "./BackfillAccountsForm";
export const dynamic = "force-dynamic";
@@ -19,7 +19,7 @@ export default async function AdherentsPage({
}: {
searchParams: Promise<{ q?: string }>;
}) {
const session = await auth();
const session = await getSession();
if (!can(session?.user.role, "manageMembers")) {
redirect("/backend");
}
@@ -59,38 +59,13 @@ export default async function AdherentsPage({
return !!e && !takenEmails.has(e);
}).length;
async function handleBackfill() {
"use server";
await createMissingMemberAccounts();
redirect("/backend/adherents");
}
const COLS = "1.6fr 1fr 1.2fr 0.9fr 0.8fr";
return (
<div style={{ display: "flex", flexDirection: "column", gap: 18 }}>
{missingAccounts > 0 && (
<form
action={handleBackfill}
style={{ background: "#fbeede", border: "1px solid #ecd8b8", borderRadius: 12, padding: "14px 18px", display: "flex", alignItems: "center", justifyContent: "space-between", gap: 14, flexWrap: "wrap" }}
>
<div style={{ fontSize: 13.5, color: "#9a6638" }}>
<strong>{missingAccounts}</strong> adhérent(s) avec un e-mail n&apos;ont pas encore de compte de connexion.
</div>
<button
type="submit"
className="font-display"
style={{ border: "none", background: "#9a6638", color: "#fff", fontWeight: 700, fontSize: 13.5, padding: "10px 18px", borderRadius: 10, cursor: "pointer", whiteSpace: "nowrap" }}
>
Créer les comptes manquants
</button>
</form>
)}
{missingAccounts > 0 && <BackfillAccountsForm missingAccounts={missingAccounts} />}
<AddMemberPanel categories={cats} />
<div className="table-scroll">
<div style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 16, overflow: "hidden", minWidth: 560 }}>
<div style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 16, overflow: "hidden" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "18px 22px", borderBottom: "1px solid #f0e8d6", gap: 12, flexWrap: "wrap" }}>
<form style={{ display: "flex", alignItems: "center", gap: 10, background: "#faf7ef", border: "1px solid #e6dcc6", borderRadius: 10, padding: "8px 14px" }}>
<span style={{ width: 9, height: 9, border: "2px solid #9a8d72", borderRadius: "50%" }} />
@@ -104,9 +79,8 @@ export default async function AdherentsPage({
</div>
<div
className="member-row member-head"
style={{
display: "grid",
gridTemplateColumns: COLS,
padding: "13px 22px",
background: "#faf7ef",
borderBottom: "1px solid #f0e8d6",
@@ -131,24 +105,28 @@ export default async function AdherentsPage({
{rows.map((m) => {
const active = m.status === "active";
return (
<div key={m.id} style={{ display: "grid", gridTemplateColumns: COLS, alignItems: "center", padding: "15px 22px", borderBottom: "1px solid #f4eede" }}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<div key={m.id} className="member-row" style={{ padding: "15px 22px", borderBottom: "1px solid #f4eede" }}>
<div className="member-row__identity">
<span className="font-display" style={{ width: 38, height: 38, borderRadius: 10, background: "#f0e8d6", display: "flex", alignItems: "center", justifyContent: "center", fontWeight: 800, color: "#9a8d72", fontSize: 14, flexShrink: 0 }}>
{initialsOf(m.name)}
</span>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 14.5, fontWeight: 700, color: "#26201a" }}>{m.name}</div>
<div style={{ fontSize: 12, color: "#a99c82" }}>{m.email}</div>
<div className="member-row__mail" style={{ fontSize: 12, color: "#a99c82" }}>{m.email}</div>
</div>
</div>
<div style={{ fontSize: 13.5, color: "#6c6150" }}>{m.categoryLabel ?? "—"}</div>
<div style={{ fontSize: 13.5, color: "#6c6150" }}>{m.city ?? "—"}</div>
<div className="member-row__meta" style={{ fontSize: 13.5, color: "#6c6150" }}>
{m.categoryLabel ?? "—"}
</div>
<div className="member-row__meta" style={{ fontSize: 13.5, color: "#6c6150" }}>
{m.city ?? "—"}
</div>
<div>
<span style={{ display: "inline-block", fontSize: 11.5, fontWeight: 700, padding: "4px 11px", borderRadius: 999, background: active ? "#e6f4ec" : "#fbeede", color: active ? "#1f8a5b" : "#9a6638" }}>
{active ? "Actif" : "En attente"}
</span>
</div>
<div style={{ textAlign: "right" }}>
<div className="member-row__action">
<Link href={`/backend/adherents/${m.id}`} style={{ textDecoration: "none", color: "#2C6FB3", fontWeight: 700, fontSize: 13 }}>
Modifier
</Link>
@@ -157,7 +135,6 @@ export default async function AdherentsPage({
);
})}
</div>
</div>
</div>
);
}
@@ -0,0 +1,71 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { OneTimeCredentials } from "@/components/OneTimeCredentials";
import { inviteAdmin, type IssuedCredentials } from "../actions";
export function InviteAdminForm() {
const [issued, setIssued] = useState<IssuedCredentials | null>(null);
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState(false);
const router = useRouter();
return (
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
{issued && <OneTimeCredentials items={[issued]} title="Compte créé — identifiants à transmettre" />}
{error && (
<div role="alert" style={{ background: "#fdecea", border: "1px solid #f1c4bd", borderRadius: 10, padding: "10px 14px", fontSize: 13, color: "#a3372e" }}>
{error}
</div>
)}
<form
action={async (fd) => {
setPending(true);
setError(null);
try {
const result = await inviteAdmin(fd);
if (result && "error" in result) {
setError(result.error);
return;
}
setIssued(result ?? null);
router.refresh();
} catch {
// 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);
}
}}
>
<label className="field-label">Nom complet</label>
<input name="name" required placeholder="ex : Julie Bernard" className="field" style={{ marginBottom: 14 }} />
<label className="field-label">E-mail</label>
<input name="email" type="email" required placeholder="prenom@plein-r.fr" className="field" style={{ marginBottom: 14 }} />
<label className="field-label">Rôle</label>
<select name="role" defaultValue="Administrateur" className="field" style={{ marginBottom: 18 }}>
<option>Administrateur</option>
<option>Modérateur</option>
<option>Éditeur</option>
</select>
<button
type="submit"
disabled={pending}
className="font-display"
style={{ width: "100%", border: "none", background: "#13324F", color: "#fff", fontWeight: 700, fontSize: 14.5, padding: 13, borderRadius: 11, cursor: "pointer" }}
>
{pending ? "Création…" : "Créer le compte"}
</button>
</form>
<p style={{ fontSize: 11.5, color: "#a99c82", margin: 0, lineHeight: 1.5 }}>
Un mot de passe temporaire est généré et affiché une seule fois ici. L&apos;invité devra le
changer à sa première connexion.
</p>
</div>
);
}
+5 -29
View File
@@ -1,10 +1,11 @@
import { redirect } from "next/navigation";
import { inArray } from "drizzle-orm";
import { auth } from "@/auth";
import { getSession } from "@/lib/session";
import { db } from "@/db";
import { users } from "@/db/schema";
import { can, ROLE_LABELS, STAFF_ROLES } from "@/lib/rbac";
import { inviteAdmin, removeAdmin } from "../actions";
import { removeAdmin } from "../actions";
import { InviteAdminForm } from "./InviteAdminForm";
export const dynamic = "force-dynamic";
@@ -13,7 +14,7 @@ function initialsOf(name: string) {
}
export default async function AdminsPage() {
const session = await auth();
const session = await getSession();
if (!can(session?.user.role, "manageAdmins")) {
redirect("/backend");
}
@@ -70,32 +71,7 @@ export default async function AdminsPage() {
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 16, margin: "0 0 16px", color: "#26201a" }}>
Inviter un administrateur
</h3>
<form action={inviteAdmin}>
<label className="field-label">Nom complet</label>
<input name="name" required placeholder="ex : Julie Bernard" className="field" style={{ marginBottom: 14 }} />
<label className="field-label">E-mail</label>
<input name="email" type="email" required placeholder="prenom@plein-r.fr" className="field" style={{ marginBottom: 14 }} />
<label className="field-label">Rôle</label>
<select name="role" defaultValue="Administrateur" className="field" style={{ marginBottom: 18 }}>
<option>Administrateur</option>
<option>Modérateur</option>
<option>Éditeur</option>
</select>
<button
type="submit"
className="font-display"
style={{ width: "100%", border: "none", background: "#13324F", color: "#fff", fontWeight: 700, fontSize: 14.5, padding: 13, borderRadius: 11, cursor: "pointer" }}
>
Envoyer l&apos;invitation
</button>
</form>
<p style={{ fontSize: 11.5, color: "#a99c82", marginTop: 12, lineHeight: 1.5 }}>
Un mot de passe temporaire est généré. L&apos;invité pourra le réinitialiser à la première
connexion.
</p>
<InviteAdminForm />
</div>
</div>
);
+363
View File
@@ -0,0 +1,363 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import type { CSSProperties, ReactNode } from "react";
import { getSession } from "@/lib/session";
import { can } from "@/lib/rbac";
import {
MAIL_LABELS,
MAIL_PROVIDERS,
getMailAccounts,
mailHealthCached,
type MailHealth,
} from "@/lib/mail-accounts";
import { recentMails } from "@/lib/mail-outbox";
import { SmtpAccountForm } from "@/components/SmtpAccountForm";
import { publicBaseUrl } from "@/lib/social-accounts";
import { getSiteSettings } from "@/lib/site-settings";
import type { MailAccount, MailProvider } from "@/db/schema";
import {
cancelMail,
checkMailProvider,
disconnectMail,
retryMail,
saveMailApp,
saveMailSmtp,
sendMailTest,
setMailProvider,
} from "../actions";
export const dynamic = "force-dynamic";
const panel: CSSProperties = { background: "#fff", border: "1px solid #e6dcc6", borderRadius: 14, padding: "18px 20px", marginBottom: 16 };
const eyebrow: CSSProperties = { fontSize: 11, letterSpacing: "0.1em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 800, marginBottom: 12 };
const ghost: CSSProperties = { border: "1px solid #d8cdb4", background: "#fff", color: "#6c6150", fontWeight: 700, fontSize: 12.5, padding: "8px 13px", borderRadius: 9, cursor: "pointer", textDecoration: "none", display: "inline-block" };
const primary: CSSProperties = { ...ghost, border: "none", background: "#13324F", color: "#fff", fontWeight: 800 };
const danger: CSSProperties = { ...ghost, color: "#d8472b", borderColor: "#e0c3bb" };
const pill: CSSProperties = { borderRadius: 999, padding: "5px 11px", fontSize: 11, fontWeight: 800, whiteSpace: "nowrap" };
const BRAND: Record<MailProvider, { color: string; mark: string }> = {
google: { color: "#1a73e8", mark: "G" },
microsoft: { color: "#0f6cbd", mark: "M" },
smtp: { color: "#6c6150", mark: "@" },
};
const HELP: Record<"google" | "microsoft", { portal: string; steps: string[]; caution: string }> = {
google: {
portal: "https://console.cloud.google.com/apis/credentials",
steps: [
"Créez un projet, puis un identifiant OAuth de type « Application Web ».",
"Activez l'API Gmail dans la bibliothèque d'API du projet.",
"Sur l'écran de consentement, ajoutez la portée .../auth/gmail.send.",
"Déclarez l'adresse de retour ci-dessous comme URI de redirection autorisé.",
"Collez l'ID client et le code secret du client ci-dessus, puis connectez-vous avec la boîte de l'association.",
],
caution:
"Tant que l'application reste en mode « Test », Google fait expirer l'autorisation au bout de 7 jours et l'envoi s'arrête sans prévenir. Passez-la « En production » (un écran d'avertissement subsiste, sans conséquence), ou déclarez-la « Interne » si l'association a un compte Google Workspace.",
},
microsoft: {
portal: "https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade",
steps: [
"Inscrivez une application, en autorisant « comptes dans un annuaire quelconque et comptes Microsoft personnels ».",
"Ajoutez l'adresse de retour ci-dessous comme URI de redirection de type « Web ».",
"Dans les autorisations d'API, ajoutez les permissions déléguées Mail.Send, User.Read et offline_access.",
"Créez un secret client et notez sa valeur : elle n'est affichée qu'une fois.",
"Collez l'ID d'application et le secret ci-dessus, puis connectez-vous avec la boîte de l'association.",
],
caution:
"Microsoft a désactivé l'authentification par simple mot de passe (SMTP AUTH) sur les boîtes outlook.com et hotmail.com : cette connexion par bouton est le seul chemin fiable pour ces adresses.",
},
};
function Banner({ tone, children }: { tone: "error" | "ok" | "warn"; children: ReactNode }) {
const palette = {
error: { background: "#fbe9e6", border: "#f2d5cf", color: "#a8503c" },
ok: { background: "#e6f4ec", border: "#c4e2d1", color: "#1f8a5b" },
warn: { background: "#fbeede", border: "#ecd8b8", color: "#9a6638" },
}[tone];
return (
<div role="status" style={{ ...palette, border: `1px solid ${palette.border}`, borderRadius: 10, padding: "11px 15px", fontSize: 13.5, marginBottom: 14 }}>
{children}
</div>
);
}
function StatusChip({ account, health }: { account: MailAccount | undefined; health: MailHealth | null }) {
if (!account) return <span style={{ ...pill, background: "#f1efe7", color: "#a99c82" }}>Non configurée</span>;
if (health && !health.ok) return <span style={{ ...pill, background: "#fbe9e6", color: "#d8472b" }}>Ne répond plus</span>;
if (account.provider !== "smtp" && !account.fromAddress) {
return <span style={{ ...pill, background: "#f1efe7", color: "#a99c82" }}>Non connectée</span>;
}
if (account.provider === "smtp" && !account.smtpHost) {
return <span style={{ ...pill, background: "#f1efe7", color: "#a99c82" }}>Non configurée</span>;
}
return <span style={{ ...pill, background: "#e6f4ec", color: "#1f8a5b" }}>Connectée</span>;
}
function formatMoment(value: Date) {
return new Date(value).toLocaleString("fr-FR", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
}
const MAIL_STATUS: Record<string, { label: string; background: string; color: string }> = {
queued: { label: "En file", background: "#eaf0f6", color: "#2C6FB3" },
sending: { label: "En cours", background: "#eaf0f6", color: "#2C6FB3" },
sent: { label: "Remis", background: "#e6f4ec", color: "#1f8a5b" },
failed: { label: "Échec", background: "#fbe9e6", color: "#d8472b" },
cancelled: { label: "Annulé", background: "#f1efe7", color: "#a99c82" },
};
/**
* Boîte mail de l'association.
*
* Même structure que Backend › Réseaux sociaux, et mêmes règles : aucun secret
* ne descend vers le navigateur — le champ mot de passe part vide et un champ
* vide conserve la valeur enregistrée.
*/
export default async function BoiteMailPage({
searchParams,
}: {
searchParams: Promise<{ error?: string; ok?: string; connected?: string }>;
}) {
const session = await getSession();
if (!can(session?.user.role, "manageSettings")) redirect("/backend");
const { error, ok, connected } = await searchParams;
const [accounts, settings, base, log] = await Promise.all([
getMailAccounts(),
getSiteSettings(),
publicBaseUrl(),
recentMails(20),
]);
const byProvider = new Map(accounts.map((account) => [account.provider, account]));
const health = Object.fromEntries(
await Promise.all(MAIL_PROVIDERS.map(async (provider) => [provider, await mailHealthCached(provider)] as const)),
) as Record<MailProvider, MailHealth | null>;
const active = accounts.find((account) => account.isActive) ?? null;
return (
<div>
{error && <Banner tone="error">{error}</Banner>}
{ok && <Banner tone="ok">{ok}</Banner>}
{connected && <Banner tone="ok">Boîte {MAIL_LABELS[connected as MailProvider] ?? connected} connectée.</Banner>}
{!active && (
<Banner tone="warn">
Aucune boîte n&apos;expédie pour l&apos;instant : les mots de passe temporaires restent affichés à
l&apos;écran, et aucune information ne part par e-mail.
</Banner>
)}
{MAIL_PROVIDERS.map((provider) => {
const account = byProvider.get(provider);
const verdict = health[provider];
const brand = BRAND[provider];
const isActive = account?.isActive ?? false;
const connectable = provider !== "smtp";
const usable = provider === "smtp" ? Boolean(account?.smtpHost) : Boolean(account?.fromAddress);
return (
<section
key={provider}
style={{ ...panel, ...(isActive ? { borderColor: "#b9d3c6", boxShadow: "inset 4px 0 0 #1f8a5b" } : {}) }}
>
<div style={{ display: "flex", justifyContent: "space-between", gap: 12, alignItems: "center", flexWrap: "wrap" }}>
<div style={{ display: "flex", gap: 13, alignItems: "center" }}>
<span
aria-hidden="true"
className="font-display"
style={{ width: 40, height: 40, borderRadius: 10, background: brand.color, color: "#fff", display: "flex", alignItems: "center", justifyContent: "center", fontWeight: 800, fontSize: 16, flexShrink: 0 }}
>
{brand.mark}
</span>
<div>
<div className="font-display" style={{ fontWeight: 700, fontSize: 16, color: "#26201a" }}>{MAIL_LABELS[provider]}</div>
<div style={{ color: "#9a8d72", fontSize: 12.5 }}>
{provider === "smtp"
? account?.smtpHost
? `${account.smtpHost} : ${account.smtpPort ?? 465} · ${account.smtpUser}`
: "Aucun serveur enregistré"
: account?.fromAddress
? `${account.fromAddress}${account.connectedAt ? ` · connectée le ${formatMoment(account.connectedAt)}` : ""}`
: "Aucun compte relié"}
</div>
</div>
</div>
<div style={{ display: "flex", gap: 9, alignItems: "center" }}>
<StatusChip account={account} health={verdict} />
{usable && (
<form action={setMailProvider}>
<input type="hidden" name="provider" value={provider} />
<button type="submit" disabled={isActive} style={{ ...(isActive ? { ...ghost, borderColor: "#b9d3c6", color: "#1f8a5b", cursor: "default" } : ghost) }}>
{isActive ? "✓ Expédie les messages" : "Expédier par ici"}
</button>
</form>
)}
</div>
</div>
{verdict && !verdict.ok && (
<div style={{ marginTop: 13 }}>
<Banner tone="error">{verdict.reason.slice(0, 400)}</Banner>
</div>
)}
<div style={{ borderTop: "1px solid #f0e8d6", margin: "14px 0" }} />
{provider === "smtp" ? (
<SmtpAccountForm
saveAction={saveMailSmtp}
host={account?.smtpHost ?? ""}
port={account?.smtpPort ?? 465}
secure={account?.smtpSecure ?? true}
user={account?.smtpUser ?? ""}
hasPassword={Boolean(account?.smtpPassword)}
fromAddress={account?.fromAddress ?? ""}
fromName={account?.fromName ?? settings.association_name}
submitStyle={primary}
/>
) : (
<>
<form action={saveMailApp}>
<input type="hidden" name="provider" value={provider} />
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))", gap: 12 }}>
<label className="field-label">
Identifiant de l&apos;application
<input className="field" name="appId" required defaultValue={account?.appId ?? ""} style={{ marginTop: 6 }} />
</label>
<label className="field-label">
Secret de l&apos;application
<input
className="field"
name="appSecret"
type="password"
autoComplete="new-password"
placeholder={account?.appSecret ? "Enregistré — laissez vide pour le conserver" : "Collez la clé secrète"}
style={{ marginTop: 6 }}
/>
</label>
</div>
<div style={{ display: "flex", gap: 9, marginTop: 14, flexWrap: "wrap", alignItems: "center" }}>
<button type="submit" style={primary}>Enregistrer</button>
<Link
href={account?.appId ? `/api/mail/${provider}/connect` : "#"}
aria-disabled={!account?.appId}
style={{ ...ghost, ...(account?.appId ? {} : { opacity: 0.5, pointerEvents: "none" }) }}
>
{account?.fromAddress ? "Reconnecter la boîte" : "Connecter la boîte"}
</Link>
</div>
</form>
<details style={{ marginTop: 14 }}>
<summary style={{ cursor: "pointer", fontSize: 13, fontWeight: 700, color: "#9a6638" }}>
Comment obtenir ces identifiants ?
</summary>
<ol style={{ margin: "11px 0 0", paddingLeft: 20, color: "#6c6150", fontSize: 13, lineHeight: 1.7 }}>
{HELP[provider].steps.map((step) => (
<li key={step}>{step}</li>
))}
</ol>
<p style={{ margin: "11px 0 0", color: "#6c6150", fontSize: 13 }}>
Adresse de retour à déclarer :{" "}
<code style={{ background: "#faf7ef", border: "1px solid #e6dcc6", borderRadius: 5, padding: "1px 6px", fontSize: 12.5 }}>
{base ? `${base}/api/mail/${provider}/callback` : "définissez d'abord l'URL publique du site"}
</code>
</p>
<p style={{ margin: "11px 0 0" }}>
<Link href={HELP[provider].portal} target="_blank" style={{ color: "#2C6FB3", fontSize: 13, fontWeight: 700 }}>
Ouvrir la console {provider === "google" ? "Google Cloud" : "Microsoft Entra"} ↗
</Link>
</p>
<div style={{ marginTop: 12 }}>
<Banner tone="warn">{HELP[provider].caution}</Banner>
</div>
</details>
</>
)}
{usable && (
<div style={{ display: "flex", gap: 9, marginTop: 14, flexWrap: "wrap", borderTop: "1px solid #f0e8d6", paddingTop: 13 }}>
<form action={checkMailProvider}>
<input type="hidden" name="provider" value={provider} />
<button type="submit" style={ghost}>Vérifier la connexion</button>
</form>
{connectable && account?.fromAddress && (
<form action={disconnectMail}>
<input type="hidden" name="provider" value={provider} />
<button type="submit" style={danger}>Déconnecter</button>
</form>
)}
{verdict?.ok && <span style={{ alignSelf: "center", color: "#1f8a5b", fontSize: 12.5 }}>✓ {verdict.detail}</span>}
</div>
)}
</section>
);
})}
<section style={panel}>
<div style={eyebrow}>Envoi de contrôle</div>
<form action={sendMailTest} style={{ display: "flex", gap: 12, alignItems: "center", flexWrap: "wrap" }}>
<button type="submit" disabled={!active} style={{ ...primary, ...(active ? {} : { opacity: 0.5 }) }}>
M&apos;envoyer un message de test
</button>
<span className="field-hint">
Part immédiatement vers votre propre adresse et affiche le verdict, sans passer par la file.
</span>
</form>
</section>
<section style={panel}>
<div style={eyebrow}>Journal d&apos;envoi</div>
{log.length === 0 ? (
<div style={{ color: "#a99c82", fontSize: 13 }}>Aucun message expédié pour l&apos;instant.</div>
) : (
<div className="table-scroll">
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 12.8 }}>
<thead>
<tr>
{["Quand", "Destinataire", "Objet", "État", ""].map((header) => (
<th key={header} style={{ textAlign: "left", fontSize: 10.5, letterSpacing: "0.1em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 800, padding: "0 10px 8px 0", whiteSpace: "nowrap" }}>
{header}
</th>
))}
</tr>
</thead>
<tbody>
{log.map((message) => {
const status = MAIL_STATUS[message.status] ?? MAIL_STATUS.queued;
return (
<tr key={message.id}>
<td style={{ padding: "9px 10px 9px 0", borderTop: "1px solid #f0e8d6", color: "#8c8068", whiteSpace: "nowrap", fontVariantNumeric: "tabular-nums" }}>
{formatMoment(message.createdAt)}
</td>
<td style={{ padding: "9px 10px 9px 0", borderTop: "1px solid #f0e8d6", color: "#26201a", fontWeight: 600 }}>{message.toAddress}</td>
<td style={{ padding: "9px 10px 9px 0", borderTop: "1px solid #f0e8d6", color: "#8c8068" }}>
{message.subject}
{message.error && <div style={{ color: "#d8472b", fontSize: 11.5, marginTop: 3 }}>{message.error.slice(0, 160)}</div>}
</td>
<td style={{ padding: "9px 10px 9px 0", borderTop: "1px solid #f0e8d6" }}>
<span style={{ ...pill, background: status.background, color: status.color }}>{status.label}</span>
</td>
<td style={{ padding: "9px 0", borderTop: "1px solid #f0e8d6", whiteSpace: "nowrap" }}>
{message.status === "failed" && (
<form action={retryMail}>
<input type="hidden" name="id" value={message.id} />
<button type="submit" style={{ ...ghost, padding: "5px 10px", fontSize: 11.5 }}>Réessayer</button>
</form>
)}
{message.status === "queued" && (
<form action={cancelMail}>
<input type="hidden" name="id" value={message.id} />
<button type="submit" style={{ ...ghost, padding: "5px 10px", fontSize: 11.5 }}>Annuler</button>
</form>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</section>
</div>
);
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { redirect } from "next/navigation";
import { asc, count, eq } from "drizzle-orm";
import { auth } from "@/auth";
import { getSession } from "@/lib/session";
import { db } from "@/db";
import { categories, members } from "@/db/schema";
import { can } from "@/lib/rbac";
@@ -9,7 +9,7 @@ import { addCategory, deleteCategory, renameCategory } from "../actions";
export const dynamic = "force-dynamic";
export default async function CategoriesPage() {
const session = await auth();
const session = await getSession();
if (!can(session?.user.role, "manageCategories")) {
redirect("/backend");
}
+26 -3
View File
@@ -1,13 +1,18 @@
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { getSession } from "@/lib/session";
import { changeOwnPassword } from "../actions";
export const dynamic = "force-dynamic";
export default async function ChangePasswordPage() {
const session = await auth();
export default async function ChangePasswordPage({
searchParams,
}: {
searchParams: Promise<{ error?: string }>;
}) {
const session = await getSession();
if (!session?.user) redirect("/login");
const { error } = await searchParams;
const forced = session.user.mustChangePassword;
return (
@@ -23,7 +28,25 @@ export default async function ChangePasswordPage() {
</div>
)}
{error && (
<div style={{ background: "#fbe9e6", border: "1px solid #f2d5cf", color: "#a8503c", borderRadius: 10, padding: "11px 14px", fontSize: 13.5, marginBottom: 16 }}>
{error}
</div>
)}
<form action={changeOwnPassword}>
<label className="field-label">
{forced ? "Mot de passe temporaire reçu" : "Mot de passe actuel"}
</label>
<input
name="currentPassword"
type="password"
required
className="field"
style={{ marginBottom: 14 }}
autoComplete="current-password"
/>
<label className="field-label">Nouveau mot de passe (8 caractères min.)</label>
<input name="password" type="password" required minLength={8} className="field" style={{ marginBottom: 14 }} autoComplete="new-password" />
@@ -0,0 +1,79 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { OneTimeCredentials } from "@/components/OneTimeCredentials";
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,
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) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 8, minWidth: 260 }}>
<OneTimeCredentials items={[created]} title="Adhérent créé — identifiants à transmettre" />
<Link href={`/backend/adherents/${created.memberId}`} style={{ color: "#2C6FB3", fontWeight: 700, fontSize: 13, textDecoration: "none" }}>
Ouvrir la fiche adhérent →
</Link>
</div>
);
}
if (status !== "new") return null;
return (
<form
action={async (fd) => {
setPending(true);
try {
const result = await approveMembershipRequest(fd);
// É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();
}
} finally {
setPending(false);
}
}}
>
<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}
style={{ border: "1px solid #1f8a5b", color: "#1f8a5b", background: "#fff", fontWeight: 700, fontSize: 12.5, padding: "7px 12px", borderRadius: 8, cursor: "pointer", whiteSpace: "nowrap" }}
>
{pending ? "Création…" : "Approuver & créer"}
</button>
</form>
);
}
+45 -30
View File
@@ -1,10 +1,11 @@
import { redirect } from "next/navigation";
import { desc } from "drizzle-orm";
import { auth } from "@/auth";
import { getSession } from "@/lib/session";
import { db } from "@/db";
import { contactMessages, membershipRequests } from "@/db/schema";
import { can } from "@/lib/rbac";
import { approveMembershipRequest, setContactStatus, setRequestStatus } from "../actions";
import { setContactStatus, setRequestStatus } from "../actions";
import { ApproveRequestForm } from "./ApproveRequestForm";
export const dynamic = "force-dynamic";
@@ -41,7 +42,7 @@ function ActionBtn({ children, color }: { children: React.ReactNode; color: stri
}
export default async function DemandesPage() {
const session = await auth();
const session = await getSession();
if (!can(session?.user.role, "manageMembers")) {
redirect("/backend");
}
@@ -51,14 +52,6 @@ export default async function DemandesPage() {
db.select().from(contactMessages).orderBy(desc(contactMessages.createdAt)),
]);
// Approuver = créer l'adhérent + son compte, puis aller sur sa fiche
// (login + mot de passe temporaire y sont affichés).
async function handleApprove(fd: FormData) {
"use server";
const id = await approveMembershipRequest(fd);
redirect(id ? `/backend/adherents/${id}` : "/backend/demandes");
}
return (
<div style={{ display: "flex", flexDirection: "column", gap: 26 }}>
{/* ---- Demandes d'adhésion ---- */}
@@ -79,16 +72,25 @@ export default async function DemandesPage() {
</div>
</div>
<StatusPill map={REQ_STATUS} status={r.status} />
{/* 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 }}>
<form action={handleApprove}>
<input type="hidden" name="id" value={r.id} />
<ActionBtn color="#1f8a5b">Approuver &amp; créer</ActionBtn>
</form>
<form action={setRequestStatus}>
<input type="hidden" name="id" value={r.id} />
<input type="hidden" name="status" value="rejected" />
<ActionBtn color="#d8472b">Rejeter</ActionBtn>
</form>
<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>
{r.message && (
@@ -119,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>
+31
View File
@@ -0,0 +1,31 @@
import { redirect } from "next/navigation";
import { getSession } from "@/lib/session";
import { EmailCreator } from "@/components/EmailCreator";
import { can } from "@/lib/rbac";
import { emailBrand, getSiteSettings } from "@/lib/site-settings";
import { audienceOptions } from "@/lib/mail-recipients";
import { isMailConfigured } from "@/lib/mail-accounts";
import { siteUrl } from "@/lib/social-accounts";
export const dynamic = "force-dynamic";
export default async function EmailsPage() {
const session = await getSession();
if (!can(session?.user.role, "manageEmails")) redirect("/backend");
const [settings, choices, mailReady, base] = await Promise.all([
getSiteSettings(),
audienceOptions(),
isMailConfigured(),
siteUrl(),
]);
return (
<EmailCreator
brand={emailBrand(settings)}
choices={choices}
mailReady={mailReady}
siteUrl={base}
/>
);
}
+70
View File
@@ -0,0 +1,70 @@
import Link from "next/link";
function initialsOf(name: string) {
return name.split(/\s+/).map((w) => w[0]).join("").slice(0, 2).toUpperCase();
}
const TABS = [
{ key: "informations", href: "/backend/espace/informations", label: "Informations" },
{ key: "profil", href: "/backend/espace", label: "Mon profil" },
{ key: "promotions", href: "/backend/espace/promotions", label: "Mes promotions" },
] as const;
export type EspaceTab = (typeof TABS)[number]["key"];
/**
* Bandeau de l'espace adhérent et ses onglets. Trois pages distinctes pour que
* les annonces de l'association, la fiche publique et la vie des promotions ne
* se mélangent pas dans une seule longue page.
*
* Les compteurs sont passés par chaque page : sans cela ils disparaîtraient
* en changeant d'onglet.
*/
export function EspaceHeader({
memberName,
subtitle,
active,
publicPath,
promoBadge,
infoBadge,
}: {
memberName: string;
subtitle: string;
active: EspaceTab;
publicPath?: string | null;
/** Nombre de promotions en ligne, affiché sur l'onglet. */
promoBadge?: number;
/** Nombre d'informations non lues. */
infoBadge?: number;
}) {
return (
<div style={{ marginBottom: 22 }}>
<div style={{ background: "#13324F", borderRadius: "16px 16px 0 0", padding: "20px 24px", display: "flex", alignItems: "center", gap: 15, flexWrap: "wrap" }}>
<span className="font-display" style={{ width: 46, height: 46, borderRadius: 12, background: "#E0A63C", color: "#33291D", display: "flex", alignItems: "center", justifyContent: "center", fontWeight: 800, fontSize: 16, flexShrink: 0 }}>
{initialsOf(memberName)}
</span>
<div style={{ flex: 1, minWidth: 200 }}>
<div className="font-display" style={{ fontWeight: 700, fontSize: 18, color: "#fff" }}>{memberName}</div>
<div style={{ fontSize: 13, color: "#9bb6cd" }}>{subtitle}</div>
</div>
{publicPath && (
<Link href={publicPath} target="_blank" style={{ color: "#fff", border: "1px solid rgba(255,255,255,0.35)", borderRadius: 10, padding: "8px 14px", fontSize: 13, fontWeight: 700, textDecoration: "none", whiteSpace: "nowrap" }}>
Voir ma fiche publique ↗
</Link>
)}
</div>
<nav className="espace-tabs" aria-label="Sections de l'espace adhérent">
{TABS.map((tab) => {
const isActive = tab.key === active;
const badge = tab.key === "promotions" ? promoBadge : tab.key === "informations" ? infoBadge : 0;
return (
<Link key={tab.key} href={tab.href} className={`espace-tab${isActive ? " active" : ""}`} aria-current={isActive ? "page" : undefined}>
{tab.label}
{badge ? <span className="espace-tab__badge">{badge}</span> : null}
</Link>
);
})}
</nav>
</div>
);
}
+273 -11
View File
@@ -4,35 +4,147 @@ 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)";
const NETWORK_LABELS: Record<SocialNetwork, string> = {
facebook: "Facebook",
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,
categories,
memberLogoUrl,
categoryGroups,
defaultCategory,
networks = [],
}: {
memberName: string;
categories: 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. */
defaultCategory?: string;
networks?: SocialNetwork[];
}) {
const CATEGORIES = categories.length > 0 ? categories : ["Autre"];
const allCategories = categoryGroups.flatMap((g) => g.items);
const initialCategory = defaultCategory && allCategories.includes(defaultCategory) ? defaultCategory : allCategories[0] ?? "Autre";
const router = useRouter();
const [title, setTitle] = useState("");
const [text, setText] = useState("");
const [cat, setCat] = useState(CATEGORIES[0]);
const [cat, setCat] = useState(initialCategory);
const [badge, setBadge] = useState("");
const [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);
const fileRef = useRef<HTMLInputElement>(null);
function toggleShare(network: SocialNetwork) {
setShares((s) => (s.includes(network) ? s.filter((n) => n !== network) : [...s, network]));
setSubmitted(false);
}
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);
}
@@ -41,7 +153,12 @@ export function MemberSpaceForm({
setTitle("");
setText("");
setBadge("");
setStartsOn("");
setEndsOn("");
setPublishAt("");
setImgData("");
setImgInfo(null);
setShares([]);
setSubmitted(false);
if (fileRef.current) fileRef.current.value = "";
}
@@ -54,7 +171,12 @@ 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");
await publishPromo(fd);
setPending(false);
setSubmitted(true);
@@ -65,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" }}>
@@ -112,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}
@@ -126,10 +261,16 @@ export function MemberSpaceForm({
<div style={{ display: "flex", gap: 12, marginBottom: 16 }}>
<div style={{ flex: 1 }}>
<label className="field-label">Catégorie</label>
<label className="field-label">Type de produit ou de service</label>
<select value={cat} onChange={(e) => setCat(e.target.value)} className="field">
{CATEGORIES.map((c) => (
<option key={c}>{c}</option>
{categoryGroups.map((group) => (
<optgroup key={group.label} label={group.label}>
{group.items.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</optgroup>
))}
</select>
</div>
@@ -139,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}
@@ -153,10 +344,54 @@ export function MemberSpaceForm({
/>
<div style={{ fontSize: 12, color: "#a99c82", marginBottom: 18 }}>{text.length} / 240 caractères</div>
{networks.length > 0 && (
<div style={{ border: "1px solid #f0e8d6", background: "#faf7ef", borderRadius: 12, padding: "14px 15px", marginBottom: 18 }}>
<div className="field-label" style={{ marginBottom: 4 }}>
Diffusion sur les réseaux Plein R
</div>
<div style={{ fontSize: 12.5, color: "#8c8068", lineHeight: 1.5, marginBottom: 11 }}>
Choisissez où l&apos;association relaiera votre offre. La publication n&apos;a lieu
qu&apos;une fois la promotion validée — et le choix n&apos;est plus modifiable ensuite.
</div>
<div style={{ display: "flex", gap: 9, flexWrap: "wrap" }}>
{networks.map((network) => {
const active = shares.includes(network);
return (
<label
key={network}
style={{
display: "inline-flex",
alignItems: "center",
gap: 9,
cursor: "pointer",
padding: "9px 14px",
borderRadius: 11,
border: `1px solid ${active ? SOCIAL_BRAND[network] : "#e6dcc6"}`,
background: active ? "#fff" : "transparent",
color: active ? SOCIAL_BRAND[network] : "#6c6150",
fontSize: 13.5,
fontWeight: 700,
}}
>
<input
type="checkbox"
checked={active}
onChange={() => toggleShare(network)}
style={{ accentColor: SOCIAL_BRAND[network], margin: 0 }}
/>
<SocialIcon network={network} size={16} />
{NETWORK_LABELS[network]}
</label>
);
})}
</div>
</div>
)}
<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 }}
>
@@ -181,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",
@@ -219,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&apos;offre →</span>
</div>
</div>
@@ -0,0 +1,102 @@
import { and, eq, sql } from "drizzle-orm";
import { redirect } from "next/navigation";
import { getSession } from "@/lib/session";
import { db } from "@/db";
import { categories, members, promotions } from "@/db/schema";
import { memberPath } from "@/lib/seo";
import { getMemberFeed } from "@/lib/informations";
import { InformationCard } from "@/components/InformationCard";
import { MarkInformationsRead } from "@/components/MarkInformationsRead";
import { EspaceHeader } from "../EspaceHeader";
export const dynamic = "force-dynamic";
/**
* Espace adhérent, onglet Informations : ce que publie l'association.
*
* Les non-lues sont calculées **avant** le marquage, qui a lieu après le rendu
* (`MarkInformationsRead`) : les pastilles « Nouveau » sont donc visibles sur
* la visite où elles disparaissent.
*/
export default async function EspaceInformationsPage() {
const session = await getSession();
if (!session?.user) redirect("/login");
const userId = Number(session.user.id);
const memberId = session.user.memberId ?? null;
let memberName = session.user.name ?? "Adhérent";
let subtitle = "Espace adhérent";
let publicPath: string | null = null;
let livePromoCount = 0;
if (memberId) {
const [profile] = await db.select().from(members).where(eq(members.id, memberId));
if (profile) {
memberName = profile.name;
const [category] = profile.categoryId
? await db.select({ label: categories.label }).from(categories).where(eq(categories.id, profile.categoryId))
: [];
subtitle = ["Espace adhérent", category?.label, profile.city].filter(Boolean).join(" · ");
if (profile.status === "active") publicPath = memberPath(profile);
}
const [live] = await db
.select({ total: sql<number>`count(*)::int` })
.from(promotions)
.where(and(eq(promotions.memberId, memberId), eq(promotions.status, "live")));
livePromoCount = live?.total ?? 0;
}
const feed = await getMemberFeed(userId);
const unreadIds = feed.filter((item) => item.unread).map((item) => item.id);
return (
<div>
<EspaceHeader
memberName={memberName}
subtitle={subtitle}
active="informations"
publicPath={publicPath}
promoBadge={livePromoCount}
infoBadge={unreadIds.length}
/>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "end", gap: 12, flexWrap: "wrap", marginBottom: 16 }}>
<div>
<h2 className="font-display" style={{ margin: 0, fontWeight: 700, fontSize: 19, color: "#26201a" }}>
Informations de l&apos;association
</h2>
<div style={{ color: "#8c8068", fontSize: 13, marginTop: 4 }}>
Annonces, convocations et rappels publiés par le bureau.
</div>
</div>
{unreadIds.length > 0 && (
<span style={{ background: "#e6f4ec", color: "#1f8a5b", borderRadius: 999, padding: "6px 13px", fontSize: 12.5, fontWeight: 800 }}>
{unreadIds.length} non lue{unreadIds.length > 1 ? "s" : ""}
</span>
)}
</div>
{feed.length === 0 ? (
<div style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 14, padding: 28, color: "#a99c82", fontSize: 13.5 }}>
L&apos;association n&apos;a encore rien publié. Les annonces du bureau apparaîtront ici.
</div>
) : (
feed.map((item) => (
<InformationCard
key={item.id}
title={item.title}
body={item.body}
imageUrl={item.imageUrl}
publishedAt={item.publishedAt}
authorName={item.authorName}
pinned={item.pinned}
unread={item.unread}
/>
))
)}
<MarkInformationsRead ids={unreadIds} />
</div>
);
}
+131 -85
View File
@@ -1,34 +1,37 @@
import { asc, desc, eq } from "drizzle-orm";
import { auth } from "@/auth";
import Link from "next/link";
import { and, asc, desc, eq, gte, sql } from "drizzle-orm";
import { getSession } from "@/lib/session";
import { db } from "@/db";
import { categories, members, promotions, type Member } from "@/db/schema";
import { categories, imageConsents, meetingRegistrations, meetings, members, promotions } from "@/db/schema";
import type { Member } from "@/db/schema";
import { memberPath } from "@/lib/seo";
import { ImageField } from "@/components/ImageField";
import { ImageConsentForm } from "@/components/ImageConsentForm";
import { HoursEditor } from "@/components/HoursEditor";
import { TagsField } from "@/components/TagsField";
import { MemberContactFields } from "@/components/MemberContactFields";
import { communeOptions } from "@/lib/communes";
import { updateOwnProfile } from "../actions";
import { MemberSpaceForm } from "./MemberSpaceForm";
import { unreadInformationCount } from "@/lib/informations";
import { saveImageConsent, updateOwnProfile } from "../actions";
import { EspaceHeader } from "./EspaceHeader";
export const dynamic = "force-dynamic";
const PROMO_STATUS: Record<string, { label: string; bg: string; color: string }> = {
live: { label: "En ligne", bg: "#e6f4ec", color: "#1f8a5b" },
pending: { label: "En attente", bg: "#fbeede", color: "#9a6638" },
rejected: { label: "Refusée", bg: "#fbe9e6", color: "#d8472b" },
expired: { label: "Expirée", bg: "#f1efe7", color: "#a99c82" },
};
function initialsOf(name: string) {
return name.split(/\s+/).map((w) => w[0]).join("").slice(0, 2).toUpperCase();
}
/** Espace adhérent, onglet Profil : fiche publique, inscriptions, droit à l'image. */
export default async function EspacePage() {
const session = await auth();
const session = await getSession();
const memberId = session?.user.memberId ?? null;
const fallbackName = session?.user.name ?? "Adhérent";
const userId = Number(session?.user.id);
const infoBadge = Number.isFinite(userId) ? await unreadInformationCount(userId) : 0;
let memberName = fallbackName;
let subtitle = "Espace adhérent";
let profile: Member | null = null;
let myPromos: { id: number; title: string; status: string; createdAt: Date; imageUrl: string | null }[] = [];
let livePromoCount = 0;
let myRegistrations: { meetingId: number; title: string; startsAt: Date; location: string | null; participants: number; confirmed: boolean }[] = [];
let latestConsent: { decision: string; createdAt: Date } | null = null;
let profileCategory: { slug: string; label: string } | null = null;
if (memberId) {
const [m] = await db.select().from(members).where(eq(members.id, memberId));
@@ -36,29 +39,39 @@ export default async function EspacePage() {
profile = m;
memberName = m.name;
const [cat] = m.categoryId
? await db.select({ label: categories.label }).from(categories).where(eq(categories.id, m.categoryId))
? await db.select({ slug: categories.slug, label: categories.label }).from(categories).where(eq(categories.id, m.categoryId))
: [];
profileCategory = cat ?? null;
subtitle = ["Espace adhérent", cat?.label, m.city].filter(Boolean).join(" · ");
}
myPromos = await db
.select({
id: promotions.id,
title: promotions.title,
status: promotions.status,
createdAt: promotions.createdAt,
imageUrl: promotions.imageUrl,
})
const [live] = await db
.select({ total: sql<number>`count(*)` })
.from(promotions)
.where(eq(promotions.memberId, memberId))
.orderBy(desc(promotions.createdAt));
.where(and(eq(promotions.memberId, memberId), eq(promotions.status, "live")));
livePromoCount = Number(live?.total ?? 0);
myRegistrations = await db
.select({
meetingId: meetings.id,
title: meetings.title,
startsAt: meetings.startsAt,
location: meetings.location,
participants: sql<number>`count(${meetingRegistrations.id})`,
confirmed: sql<boolean>`bool_and(${meetingRegistrations.status} = 'confirmed')`,
})
.from(meetingRegistrations)
.innerJoin(meetings, eq(meetings.id, meetingRegistrations.meetingId))
.where(and(eq(meetingRegistrations.memberId, memberId), gte(meetings.startsAt, new Date())))
.groupBy(meetings.id)
.orderBy(asc(meetings.startsAt));
const [consent] = await db
.select({ decision: imageConsents.decision, createdAt: imageConsents.createdAt })
.from(imageConsents)
.where(eq(imageConsents.memberId, memberId))
.orderBy(desc(imageConsents.createdAt))
.limit(1);
latestConsent = consent ?? null;
}
const catRows = await db
.select({ label: categories.label })
.from(categories)
.orderBy(asc(categories.sort));
const categoryLabels = catRows.map((c) => c.label);
function fmtDate(d: Date) {
return new Date(d).toLocaleDateString("fr-FR", { day: "numeric", month: "long" });
}
@@ -72,17 +85,45 @@ export default async function EspacePage() {
</div>
)}
<div style={{ background: "#13324F", borderRadius: 16, padding: "20px 24px", marginBottom: 22, display: "flex", alignItems: "center", gap: 15 }}>
<span className="font-display" style={{ width: 46, height: 46, borderRadius: 12, background: "#E0A63C", color: "#33291D", display: "flex", alignItems: "center", justifyContent: "center", fontWeight: 800, fontSize: 16, flexShrink: 0 }}>
{initialsOf(memberName)}
</span>
<div>
<div className="font-display" style={{ fontWeight: 700, fontSize: 18, color: "#fff" }}>
{memberName}
<EspaceHeader
memberName={memberName}
subtitle={subtitle}
active="profil"
publicPath={profile && profile.status === "active" ? memberPath(profile) : null}
promoBadge={livePromoCount}
infoBadge={infoBadge}
/>
{memberId && (
<section style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 24, marginBottom: 28 }}>
<div style={{ display: "flex", justifyContent: "space-between", gap: 12, alignItems: "end", marginBottom: 15, flexWrap: "wrap" }}>
<div>
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 18, margin: 0, color: "#26201a" }}>Mes inscriptions</h3>
<div style={{ color: "#8c8068", fontSize: 13, marginTop: 4 }}>Participants inscrits aux prochaines rencontres.</div>
</div>
<Link href="/association" style={{ color: "#9a6638", fontSize: 13, fontWeight: 800, textDecoration: "none" }}>Voir les rencontres</Link>
</div>
<div style={{ fontSize: 13, color: "#9bb6cd" }}>{subtitle}</div>
</div>
</div>
{myRegistrations.length === 0 && <div style={{ color: "#a99c82", fontSize: 13.5 }}>Aucune inscription à venir.</div>}
<div style={{ display: "grid", gap: 10 }}>
{myRegistrations.map((registration) => (
<div key={registration.meetingId} style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 14, borderTop: "1px solid #f0e8d6", paddingTop: 12, flexWrap: "wrap" }}>
<div>
<div style={{ color: "#26201a", fontSize: 14, fontWeight: 800 }}>{registration.title}</div>
<div style={{ color: "#8c8068", fontSize: 12.5, marginTop: 3 }}>
{fmtDate(registration.startsAt)} · {registration.participants} participant{registration.participants > 1 ? "s" : ""}{registration.location ? ` · ${registration.location}` : ""}
</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<span style={{ background: registration.confirmed ? "#e6f4ec" : "#fbeede", color: registration.confirmed ? "#1f8a5b" : "#9a6638", borderRadius: 999, padding: "5px 10px", fontSize: 11.5, fontWeight: 800 }}>
{registration.confirmed ? "Confirmée" : "En attente"}
</span>
<Link href={`/inscription/${registration.meetingId}`} style={{ color: "#9a6638", fontSize: 12.5, fontWeight: 800, textDecoration: "none" }}>Modifier</Link>
</div>
</div>
))}
</div>
</section>
)}
{profile && (
<form action={updateOwnProfile} style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 16, padding: 24, marginBottom: 28 }}>
@@ -95,6 +136,13 @@ export default async function EspacePage() {
<label className="field-label">Nom</label>
<input name="name" required defaultValue={profile.name} className="field" />
</div>
<div>
<label className="field-label">E-mail de contact (affiché sur la fiche)</label>
<input name="contactEmail" type="email" defaultValue={profile.contactEmail ?? ""} className="field" placeholder={profile.email} />
<p className="field-hint">
Indépendant de votre identifiant de connexion. Laissé vide, la fiche affiche l&apos;e-mail de votre compte.
</p>
</div>
<div>
<label className="field-label">Commune</label>
<select name="city" className="field" defaultValue={profile.city ?? ""}>
@@ -124,6 +172,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" />
@@ -132,16 +186,19 @@ export default async function EspacePage() {
<div style={{ marginTop: 16 }}>
<label className="field-label">Description (une ligne vide sépare les paragraphes)</label>
<textarea name="description" rows={4} defaultValue={profile.description ?? ""} className="field" style={{ resize: "vertical" }} />
<p className="field-hint">
Ce texte est lu par Google : indiquez votre métier, votre commune et vos spécialités en trois à cinq
phrases (ex. « Boulangerie artisanale à Frouard : pain au levain, viennoiseries, pâtisseries maison »).
</p>
</div>
<div style={{ marginTop: 16 }}>
<label className="field-label">Tags / spécialités (séparés par des virgules)</label>
<input name="tags" defaultValue={profile.tags ?? ""} className="field" />
<TagsField defaultValue={profile.tags} fixedCategory={profileCategory} />
</div>
<div style={{ marginTop: 16 }}>
<label className="field-label">Horaires (une ligne par jour : « Jour|plage »)</label>
<textarea name="hours" rows={4} defaultValue={profile.hours ?? ""} className="field" style={{ resize: "vertical" }} placeholder={"Mardi – Vendredi|7h – 19h30\nSamedi|7h – 19h"} />
<HoursEditor defaultValue={profile.hours} />
</div>
<button
@@ -154,45 +211,34 @@ export default async function EspacePage() {
</form>
)}
<MemberSpaceForm memberName={memberName} categories={categoryLabels} />
<div style={{ marginTop: 28 }}>
<div style={{ fontSize: 12.5, letterSpacing: "0.08em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 700, marginBottom: 12 }}>
Mes promotions
</div>
{myPromos.length === 0 && (
<div style={{ fontSize: 13.5, color: "#a99c82" }}>Vous n&apos;avez pas encore publié de promotion.</div>
)}
{myPromos.map((mp) => {
const st = PROMO_STATUS[mp.status] ?? PROMO_STATUS.expired;
return (
<div key={mp.id} style={{ display: "flex", alignItems: "center", gap: 13, background: "#fff", border: "1px solid #e6dcc6", borderRadius: 12, padding: "12px 14px", marginBottom: 10 }}>
<span
style={{
width: 42,
height: 42,
borderRadius: 9,
background: mp.imageUrl
? `url(${mp.imageUrl})`
: "repeating-linear-gradient(45deg,#efe9da,#efe9da 7px,#e6ddc9 7px,#e6ddc9 14px)",
backgroundSize: "cover",
backgroundPosition: "center",
flexShrink: 0,
}}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 700, color: "#26201a", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{mp.title}
</div>
<div style={{ fontSize: 12, color: "#a99c82" }}>Publié le {fmtDate(mp.createdAt)}</div>
{memberId && (
<section style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 16, padding: 24, marginBottom: 28 }}>
<div style={{ display: "flex", justifyContent: "space-between", gap: 12, alignItems: "start", flexWrap: "wrap", marginBottom: 14 }}>
<div>
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 18, margin: 0, color: "#26201a" }}>
Droit à l'image
</h3>
<div style={{ color: "#8c8068", fontSize: 13, marginTop: 4 }}>
{latestConsent?.decision === "accepted"
? "Autorisation signée et enregistrée. Vous pouvez la retirer à tout moment."
: "Vous pouvez autoriser l'utilisation de votre image à tout moment."}
</div>
<span style={{ display: "inline-block", fontSize: 11.5, fontWeight: 700, padding: "4px 11px", borderRadius: 999, background: st.bg, color: st.color }}>
{st.label}
</span>
</div>
);
})}
</div>
{latestConsent && (
<span style={{ background: latestConsent.decision === "accepted" ? "#e6f4ec" : "#fbe9e6", color: latestConsent.decision === "accepted" ? "#1f8a5b" : "#d8472b", borderRadius: 999, padding: "6px 12px", fontSize: 12, fontWeight: 800 }}>
{latestConsent.decision === "accepted" ? "Autorisé" : "Refusé"} · {fmtDate(latestConsent.createdAt)}
</span>
)}
</div>
<ImageConsentForm
saveAction={saveImageConsent}
defaultName={memberName}
compact
currentDecision={latestConsent?.decision === "accepted" ? "accepted" : latestConsent?.decision === "refused" ? "refused" : null}
/>
</section>
)}
</div>
);
}
+409
View File
@@ -0,0 +1,409 @@
import Link from "next/link";
import { and, desc, eq, inArray } from "drizzle-orm";
import { getSession } from "@/lib/session";
import { db } from "@/db";
import { categories, members, promotions, socialPosts } from "@/db/schema";
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
import { configuredNetworks, SOCIAL_LABELS, type SocialNetwork } from "@/lib/social";
import { PROMO_CATEGORY_GROUPS, defaultPromoCategory } from "@/lib/promo-categories";
import { memberPath } from "@/lib/seo";
import { formatValidityShort, visibilityNote } from "@/lib/promo-validity";
import { scheduleLabel } from "@/lib/promo-schedule";
import { setOwnPromoShareTargets, setOwnPromoSuspension } from "../../actions";
import { unreadInformationCount } from "@/lib/informations";
import { EspaceHeader } from "../EspaceHeader";
import { MemberSpaceForm } from "../MemberSpaceForm";
export const dynamic = "force-dynamic";
const PROMO_STATUS: Record<string, { label: string; bg: string; color: string }> = {
live: { label: "En ligne", bg: "#e6f4ec", color: "#1f8a5b" },
pending: { label: "En attente de validation", bg: "#fbeede", color: "#9a6638" },
rejected: { label: "Refusée", bg: "#fbe9e6", color: "#d8472b" },
suspended: { label: "Suspendue", bg: "#fbe9e6", color: "#d8472b" },
expired: { label: "Expirée", bg: "#f1efe7", color: "#a99c82" },
scheduled: { label: "Programmée", bg: "#eaf0f6", color: "#2C6FB3" },
};
type MyPromo = {
id: number;
title: string;
status: string;
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;
shareFacebook: boolean;
shareLinkedin: boolean;
};
type ShareState = Map<string, { status: string; url: string | null; error: string | null }>;
function fmtDate(d: Date) {
return new Date(d).toLocaleDateString("fr-FR", { day: "numeric", month: "long", year: "numeric" });
}
/** Espace adhérent, onglet Promotions : proposer une offre et suivre les siennes. */
export default async function EspacePromotionsPage() {
const session = await getSession();
const memberId = session?.user.memberId ?? null;
// Le compteur suit l'adhérent d'onglet en onglet : sans cela il
// disparaîtrait en quittant le fil.
const userId = Number(session?.user.id);
const infoBadge = Number.isFinite(userId) ? await unreadInformationCount(userId) : 0;
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;
let myPromos: MyPromo[] = [];
let lastShare: ShareState = new Map();
if (memberId) {
const [m] = await db
.select({
id: members.id,
name: members.name,
city: members.city,
status: members.status,
logoUrl: members.logoUrl,
categorySlug: categories.slug,
categoryLabel: categories.label,
})
.from(members)
.leftJoin(categories, eq(members.categoryId, categories.id))
.where(eq(members.id, memberId));
if (m) {
memberName = m.name;
memberLogoUrl = m.logoUrl;
categorySlug = m.categorySlug;
subtitle = ["Espace adhérent", m.categoryLabel, m.city].filter(Boolean).join(" · ");
publicPath = m.status === "active" ? memberPath(m) : null;
}
myPromos = await db
.select({
id: promotions.id,
title: promotions.title,
status: promotions.status,
category: promotions.category,
badge: promotions.badge,
validUntil: promotions.validUntil,
publishAt: promotions.publishAt,
startsOn: promotions.startsOn,
endsOn: promotions.endsOn,
createdAt: promotions.createdAt,
imageUrl: promotions.imageUrl,
suspendedBy: promotions.suspendedBy,
shareFacebook: promotions.shareFacebook,
shareLinkedin: promotions.shareLinkedin,
})
.from(promotions)
.where(eq(promotions.memberId, memberId))
.orderBy(desc(promotions.createdAt));
if (myPromos.length > 0) {
const rows = await db
.select({
promotionId: socialPosts.promotionId,
network: socialPosts.network,
status: socialPosts.status,
url: socialPosts.url,
error: socialPosts.error,
})
.from(socialPosts)
.where(inArray(socialPosts.promotionId, myPromos.map((p) => p.id)))
.orderBy(desc(socialPosts.createdAt));
// Trié du plus récent au plus ancien : la première occurrence gagne.
for (const row of rows) {
const key = `${row.promotionId}:${row.network}`;
if (!lastShare.has(key)) lastShare.set(key, row);
}
}
}
const networks = await configuredNetworks();
const live = myPromos.filter((p) => p.status === "live");
const pending = myPromos.filter((p) => p.status === "pending");
// 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>
{!memberId && (
<div style={{ background: "#fbeede", border: "1px solid #ecd8b8", color: "#9a6638", borderRadius: 12, padding: "12px 16px", marginBottom: 18, fontSize: 13.5 }}>
Votre compte n&apos;est pas encore relié à une fiche adhérent. Les promotions publiées ne
seront pas rattachées à un commerce.
</div>
)}
<EspaceHeader memberName={memberName} subtitle={subtitle} active="promotions" publicPath={publicPath} promoBadge={live.length} infoBadge={infoBadge} />
{/* Résumé : ce qui est visible par le public aujourd'hui */}
<div className="grid grid-3" style={{ gap: 12, marginBottom: 24 }}>
<StatCard label="En ligne sur le site" value={live.length} color="#1f8a5b" bg="#e6f4ec" />
<StatCard label="En attente de validation" value={pending.length} color="#9a6638" bg="#fbeede" />
<StatCard label="Terminées ou suspendues" value={others.length} color="#8c8068" bg="#f1efe7" />
</div>
<PromoSection
title="Mes promotions en cours"
intro={
live.length > 0
? "Ces offres sont visibles sur le site et sur votre fiche. Vous pouvez en suspendre une à tout moment : elle disparaît aussitôt, et vous la remettez en ligne quand vous voulez."
: "Aucune promotion en ligne pour le moment. Proposez une offre ci-dessous : elle apparaîtra ici dès sa validation par l'association."
}
promos={live}
networks={networks}
lastShare={lastShare}
highlight
/>
<MemberSpaceForm
memberName={memberName}
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"
intro="L'association relit chaque offre avant sa mise en ligne. Vous pouvez encore ajuster la diffusion sur les réseaux sociaux."
promos={pending}
networks={networks}
lastShare={lastShare}
/>
)}
{others.length > 0 && (
<PromoSection
title="Suspendues, refusées ou expirées"
intro="Historique de vos offres. Une promotion suspendue par vous peut être remise en ligne."
promos={others}
networks={networks}
lastShare={lastShare}
/>
)}
{myPromos.length > 0 && (
<p style={{ fontSize: 13, color: "#8c8068", marginTop: 18 }}>
Les offres en ligne apparaissent sur <Link href="/promotions" style={{ color: "#9a6638", fontWeight: 700 }}>la page des promotions</Link>
{publicPath ? (
<>
{" "}et sur <Link href={publicPath} style={{ color: "#9a6638", fontWeight: 700 }}>votre fiche</Link>
</>
) : null}
.
</p>
)}
</div>
);
}
function StatCard({ label, value, color, bg }: { label: string; value: number; color: string; bg: string }) {
return (
<div style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 14, padding: "14px 16px", display: "flex", alignItems: "center", gap: 14 }}>
<span className="font-display" style={{ width: 44, height: 44, borderRadius: 12, background: bg, color, display: "flex", alignItems: "center", justifyContent: "center", fontWeight: 800, fontSize: 20, flexShrink: 0 }}>
{value}
</span>
<span style={{ fontSize: 13, color: "#6c6150", fontWeight: 600, lineHeight: 1.35 }}>{label}</span>
</div>
);
}
function PromoSection({
title,
intro,
promos,
networks,
lastShare,
highlight = false,
}: {
title: string;
intro: string;
promos: MyPromo[];
networks: SocialNetwork[];
lastShare: ShareState;
highlight?: boolean;
}) {
return (
<section
style={{
background: highlight ? "#fff" : "transparent",
border: highlight ? "1px solid #e6dcc6" : "none",
borderRadius: 16,
padding: highlight ? 22 : "0",
marginBottom: 28,
}}
>
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 18, margin: 0, color: "#26201a" }}>
{title}
<span style={{ marginLeft: 8, fontSize: 13, color: "#a99c82", fontWeight: 600 }}>{promos.length}</span>
</h3>
<p style={{ fontSize: 13, color: "#8c8068", margin: "4px 0 14px", lineHeight: 1.5 }}>{intro}</p>
{promos.map((mp) => (
<PromoRow key={mp.id} promo={mp} networks={networks} lastShare={lastShare} />
))}
</section>
);
}
function PromoRow({ promo: mp, networks, lastShare }: { promo: MyPromo; networks: SocialNetwork[]; lastShare: ShareState }) {
const st = PROMO_STATUS[mp.status] ?? PROMO_STATUS.expired;
const suspendedByStaff = mp.status === "suspended" && mp.suspendedBy === "staff";
const meta = [mp.category, mp.badge, 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" }}>
<span
style={{
width: 54,
height: 54,
borderRadius: 10,
background: mp.imageUrl ? `url(${mp.imageUrl})` : "repeating-linear-gradient(45deg,#efe9da,#efe9da 7px,#e6ddc9 7px,#e6ddc9 14px)",
backgroundSize: "cover",
backgroundPosition: "center",
flexShrink: 0,
opacity: mp.status === "suspended" ? 0.5 : 1,
}}
/>
<div style={{ flex: 1, minWidth: 180 }}>
<div style={{ fontSize: 14.5, fontWeight: 700, color: "#26201a" }}>{mp.title}</div>
<div style={{ fontSize: 12, color: "#a99c82", marginTop: 2 }}>
{meta ? `${meta} · ` : ""}Déposée le {fmtDate(mp.createdAt)}
</div>
</div>
<span style={{ display: "inline-block", fontSize: 11.5, fontWeight: 700, padding: "4px 11px", borderRadius: 999, background: st.bg, color: st.color, flexShrink: 0 }}>
{st.label}
</span>
{mp.status === "live" && (
<form action={setOwnPromoSuspension}>
<input type="hidden" name="id" value={mp.id} />
<input type="hidden" name="action" value="suspend" />
<button type="submit" style={{ border: "1px solid #e0c3bb", background: "#fff", color: "#d8472b", fontWeight: 700, fontSize: 12.5, padding: "7px 13px", borderRadius: 9, cursor: "pointer", whiteSpace: "nowrap" }}>
Suspendre
</button>
</form>
)}
{mp.status === "suspended" && !suspendedByStaff && (
<form action={setOwnPromoSuspension}>
<input type="hidden" name="id" value={mp.id} />
<input type="hidden" name="action" value="restore" />
<button type="submit" style={{ border: "none", background: "#1f8a5b", color: "#fff", fontWeight: 700, fontSize: 12.5, padding: "8px 14px", borderRadius: 9, cursor: "pointer", whiteSpace: "nowrap" }}>
Remettre en ligne
</button>
</form>
)}
</div>
{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&apos;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&apos;association. Contactez-la pour la remettre en ligne.
</div>
)}
{networks.length > 0 && <PromoShareState promo={mp} networks={networks} lastShare={lastShare} editable={mp.status === "pending"} />}
</div>
);
}
/**
* État de diffusion d'une promo côté adhérent : modifiable tant qu'elle est en
* attente, purement informatif dès qu'elle est validée.
*/
function PromoShareState({
promo,
networks,
lastShare,
editable,
}: {
promo: { id: number; shareFacebook: boolean; shareLinkedin: boolean };
networks: SocialNetwork[];
lastShare: ShareState;
editable: boolean;
}) {
const wanted = (n: SocialNetwork) => (n === "facebook" ? promo.shareFacebook : promo.shareLinkedin);
const chosen = networks.filter(wanted);
if (editable) {
return (
<form action={setOwnPromoShareTargets} style={{ marginTop: 11, borderTop: "1px solid #f0e8d6", paddingTop: 11 }}>
<input type="hidden" name="id" value={promo.id} />
<div style={{ fontSize: 11.5, color: "#8c8068", marginBottom: 8, lineHeight: 1.5 }}>
Diffusion prévue après validation — modifiable tant que la promotion est en attente.
</div>
<div style={{ display: "flex", gap: 10, flexWrap: "wrap", alignItems: "center" }}>
{networks.map((network) => (
<label key={network} style={{ display: "inline-flex", alignItems: "center", gap: 7, fontSize: 12.5, fontWeight: 700, color: SOCIAL_BRAND[network], cursor: "pointer" }}>
<input
type="checkbox"
name={network === "facebook" ? "shareFacebook" : "shareLinkedin"}
defaultChecked={wanted(network)}
style={{ accentColor: SOCIAL_BRAND[network], margin: 0 }}
/>
<SocialIcon network={network} size={15} />
{SOCIAL_LABELS[network]}
</label>
))}
<button type="submit" style={{ border: "1px solid #d8cdb4", background: "#fff", color: "#6c6150", fontWeight: 700, fontSize: 12, padding: "6px 12px", borderRadius: 8, cursor: "pointer" }}>
Mettre à jour
</button>
</div>
</form>
);
}
if (chosen.length === 0) return null;
return (
<div style={{ marginTop: 11, borderTop: "1px solid #f0e8d6", paddingTop: 11, display: "flex", gap: 8, flexWrap: "wrap" }}>
{chosen.map((network) => {
const last = lastShare.get(`${promo.id}:${network}`);
const posted = last?.status === "posted";
const failed = last?.status === "failed";
const detail = posted ? "publié" : failed ? "échec de publication" : "en attente";
const color = posted ? "#1f8a5b" : failed ? "#d8472b" : "#9a6638";
const chip = (
<span style={{ display: "inline-flex", alignItems: "center", gap: 7, background: "#faf7ef", border: "1px solid #f0e8d6", borderRadius: 999, padding: "5px 12px", fontSize: 11.5, fontWeight: 700, color }}>
<SocialIcon network={network} size={13} />
{SOCIAL_LABELS[network]} · {detail}
</span>
);
return posted && last?.url ? (
<a key={network} href={last.url} target="_blank" rel="noopener noreferrer" style={{ textDecoration: "none" }}>
{chip}
</a>
) : (
<span key={network}>{chip}</span>
);
})}
</div>
);
}
@@ -0,0 +1,136 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ImageField } from "@/components/ImageField";
import { RichTextEditor } from "@/components/RichTextEditor";
import { saveInformation } from "../actions";
/**
* Rédaction d'une information.
*
* L'édition est **visuelle** : on sélectionne, on clique sur « G », le texte
* devient gras à l'écran. Aucune syntaxe n'est montrée — un rédacteur
* d'association n'a pas à connaître Markdown.
*
* Ce qui est enregistré reste malgré tout le format balisé restreint : c'est
* `RichTextEditor` qui resérialise à chaque frappe. Aucun HTML tiers n'atteint
* la base, et l'éditeur est son propre aperçu — il n'y a plus deux rendus à
* comparer.
*/
type Draft = {
id: number | null;
title: string;
body: string;
imageUrl: string | null;
pinned: boolean;
};
export function InformationForm({ draft }: { draft: Draft }) {
const router = useRouter();
const [title, setTitle] = useState(draft.title);
const [body, setBody] = useState(draft.body);
const [pinned, setPinned] = useState(draft.pinned);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
// Remonte le formulaire après une création : `ImageField` n'est pas contrôlé,
// seul un changement de clé le remet à vide.
const [round, setRound] = useState(0);
async function submit(formData: FormData) {
setSaving(true);
setError(null);
const result = await saveInformation(formData);
setSaving(false);
if (result && "error" in result) {
// Refus de saisie : le formulaire reste ouvert avec ce qui a été écrit.
setError(result.error);
return;
}
if (draft.id) {
router.push("/backend/informations");
} else {
setTitle("");
setBody("");
setPinned(false);
setRound((value) => value + 1);
}
router.refresh();
}
return (
<form key={round} action={submit} style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 14, padding: "20px 22px" }}>
{draft.id && <input type="hidden" name="id" value={draft.id} />}
<div style={{ fontSize: 11, letterSpacing: "0.1em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 800, marginBottom: 14 }}>
{draft.id ? "Modifier l'information" : "Nouvelle information"}
</div>
<label className="field-label" htmlFor="information-title">Titre</label>
<input
id="information-title"
className="field"
name="title"
value={title}
onChange={(event) => setTitle(event.target.value)}
maxLength={200}
placeholder="Assemblée générale — mardi 14 octobre, 19 h"
style={{ marginBottom: 15, fontWeight: 700 }}
/>
<label className="field-label" id="information-body-label">Message</label>
<RichTextEditor
value={draft.body}
onChange={setBody}
ariaLabel="Message de l'information"
placeholder="Rédigez votre message. Sélectionnez du texte puis cliquez sur G pour le mettre en gras."
/>
{/* Le format balisé ne se montre pas : il voyage dans un champ caché. */}
<input type="hidden" name="body" value={body} />
<p className="field-hint" style={{ marginTop: 6 }}>
Gras, italique, sous-titres, listes et liens. Le collage depuis un traitement de texte
conserve la mise en forme reconnue et laisse tomber le reste.
</p>
<div style={{ marginTop: 16 }}>
<ImageField name="imageUrl" label="Image (facultative)" defaultValue={draft.imageUrl ?? ""} height={150} />
</div>
<label style={{ display: "flex", gap: 10, alignItems: "flex-start", marginTop: 16, cursor: "pointer" }}>
<input type="checkbox" name="pinned" checked={pinned} onChange={(event) => setPinned(event.target.checked)} style={{ marginTop: 3 }} />
<span>
<strong style={{ fontSize: 13.5, color: "#26201a" }}>Épingler en tête de l&apos;espace adhérent</strong>
<span className="field-hint" style={{ display: "block" }}>
Une seule information à la fois : celle déjà épinglée redescend dans le fil.
</span>
</span>
</label>
{error && (
<div role="alert" style={{ marginTop: 14, color: "#d8472b", fontSize: 13, fontWeight: 700 }}>
{error}
</div>
)}
<div style={{ display: "flex", gap: 9, marginTop: 18, flexWrap: "wrap" }}>
<button
type="submit"
disabled={saving}
style={{ border: "none", background: "#13324F", color: "#fff", fontWeight: 800, fontSize: 13.5, padding: "12px 20px", borderRadius: 10, cursor: saving ? "progress" : "pointer", opacity: saving ? 0.7 : 1 }}
>
{saving ? "Enregistrement…" : draft.id ? "Enregistrer les modifications" : "Enregistrer le brouillon"}
</button>
{draft.id && (
<Link
href="/backend/informations"
style={{ border: "1px solid #d8cdb4", background: "#fff", color: "#6c6150", fontWeight: 700, fontSize: 13.5, padding: "12px 18px", borderRadius: 10, textDecoration: "none" }}
>
Annuler
</Link>
)}
</div>
</form>
);
}
+191
View File
@@ -0,0 +1,191 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import type { CSSProperties } from "react";
import { getSession } from "@/lib/session";
import { can } from "@/lib/rbac";
import { countMemberAccounts, getAdminInformations, getInformation } from "@/lib/informations";
import { richTextExcerpt } from "@/lib/rich-text";
import { isMailConfigured } from "@/lib/mail-accounts";
import { mailCountsFor } from "@/lib/mail-outbox";
import { InformationForm } from "./InformationForm";
import {
deleteInformation,
publishInformation,
toggleInformationPin,
unpublishInformation,
} from "../actions";
export const dynamic = "force-dynamic";
const panel: CSSProperties = { background: "#fff", border: "1px solid #e6dcc6", borderRadius: 14, padding: "17px 18px" };
const eyebrow: CSSProperties = { fontSize: 11, letterSpacing: "0.1em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 800, marginBottom: 12 };
const rowButton: CSSProperties = { border: "1px solid #d8cdb4", background: "#fff", color: "#6c6150", fontWeight: 700, fontSize: 12, padding: "6px 11px", borderRadius: 8, cursor: "pointer" };
const pill: CSSProperties = { borderRadius: 999, padding: "4px 10px", fontSize: 11, fontWeight: 800, whiteSpace: "nowrap" };
function formatDay(value: Date) {
return new Date(value).toLocaleDateString("fr-FR", { day: "numeric", month: "short", year: "numeric" });
}
/**
* Rédaction et suivi des informations diffusées dans l'espace adhérent.
*
* L'information en cours d'édition est désignée par `?modifier=<id>` plutôt
* que par un état client : la page reste un composant serveur, et un lien
* suffit à passer d'une information à l'autre.
*/
export default async function InformationsPage({
searchParams,
}: {
searchParams: Promise<{ modifier?: string; error?: string; ok?: string }>;
}) {
const session = await getSession();
if (!can(session?.user.role, "manageInformations")) redirect("/backend");
// Un modérateur publie ; écrire à tous les adhérents reste réservé à qui
// tient déjà le studio d'e-mails.
const canBroadcast = can(session?.user.role, "manageEmails");
const mailReady = canBroadcast && (await isMailConfigured());
const { modifier, error, ok } = await searchParams;
const editId = Number(modifier ?? 0) || null;
const editing = editId ? await getInformation(editId) : null;
const [items, memberCount] = await Promise.all([getAdminInformations(), countMemberAccounts()]);
const published = items.filter((item) => item.status === "published");
const drafts = items.filter((item) => item.status === "draft");
const mailCounts = Object.fromEntries(
await Promise.all(
published
.filter((item) => item.emailSentAt)
.map(async (item) => [item.id, await mailCountsFor(item.id)] as const)
)
);
return (
<div>
{error && (
<div role="status" style={{ background: "#fbe9e6", border: "1px solid #f2d5cf", color: "#a8503c", borderRadius: 10, padding: "11px 15px", fontSize: 13.5, marginBottom: 14 }}>
{error}
</div>
)}
{ok && (
<div role="status" style={{ background: "#e6f4ec", border: "1px solid #c4e2d1", color: "#1f8a5b", borderRadius: 10, padding: "11px 15px", fontSize: 13.5, marginBottom: 14 }}>
{ok}
</div>
)}
<div style={{ display: "grid", gridTemplateColumns: "minmax(0, 1.5fr) minmax(0, 1fr)", gap: 18, alignItems: "start" }} className="informations-split">
<InformationForm
key={editing?.id ?? "nouvelle"}
draft={{
id: editing?.id ?? null,
title: editing?.title ?? "",
body: editing?.body ?? "",
imageUrl: editing?.imageUrl ?? null,
pinned: editing?.pinned ?? false,
}}
/>
<div style={{ display: "grid", gap: 16 }}>
<section style={panel}>
<div style={eyebrow}>Publiées · {published.length}</div>
{published.length === 0 && (
<div style={{ color: "#a99c82", fontSize: 13 }}>
Rien n&apos;est encore visible dans l&apos;espace adhérent.
</div>
)}
<div style={{ display: "grid", gap: 12 }}>
{published.map((item, index) => (
<div key={item.id} style={index > 0 ? { borderTop: "1px solid #f0e8d6", paddingTop: 12 } : undefined}>
<div style={{ display: "flex", justifyContent: "space-between", gap: 8, alignItems: "start" }}>
<span className="font-display" style={{ fontWeight: 700, fontSize: 15, color: "#26201a" }}>{item.title}</span>
{item.pinned && <span style={{ ...pill, background: "#fbeede", color: "#9a6638" }}>Épinglée</span>}
</div>
<div style={{ color: "#9a8d72", fontSize: 12.5, marginTop: 3 }}>
{item.publishedAt ? formatDay(item.publishedAt) : "—"}
{" · "}
lue par {item.readCount} / {memberCount}
{item.authorName ? ` · ${item.authorName}` : ""}
</div>
<div style={{ color: "#8c8068", fontSize: 12.5, marginTop: 5 }}>{richTextExcerpt(item.body, 110)}</div>
{item.emailSentAt && (
<div style={{ color: "#1f8a5b", fontSize: 12, marginTop: 5, fontWeight: 700 }}>
Diffusée par e-mail · {mailCounts[item.id]?.sent ?? 0} remis
{mailCounts[item.id]?.queued ? ` · ${mailCounts[item.id].queued} en file` : ""}
{mailCounts[item.id]?.failed ? ` · ${mailCounts[item.id].failed} en échec` : ""}
</div>
)}
<div style={{ display: "flex", gap: 7, marginTop: 9, flexWrap: "wrap" }}>
<Link href={`/backend/informations?modifier=${item.id}`} style={{ ...rowButton, textDecoration: "none" }}>
Modifier
</Link>
<form action={toggleInformationPin}>
<input type="hidden" name="id" value={item.id} />
<button type="submit" style={rowButton}>{item.pinned ? "Décrocher" : "Épingler"}</button>
</form>
<form action={unpublishInformation}>
<input type="hidden" name="id" value={item.id} />
<button type="submit" style={rowButton}>Retirer</button>
</form>
</div>
</div>
))}
</div>
</section>
<section style={panel}>
<div style={eyebrow}>Brouillons · {drafts.length}</div>
{drafts.length === 0 && <div style={{ color: "#a99c82", fontSize: 13 }}>Aucun brouillon en attente.</div>}
<div style={{ display: "grid", gap: 12 }}>
{drafts.map((item, index) => (
<div key={item.id} style={index > 0 ? { borderTop: "1px solid #f0e8d6", paddingTop: 12 } : undefined}>
<div style={{ display: "flex", justifyContent: "space-between", gap: 8, alignItems: "start" }}>
<span className="font-display" style={{ fontWeight: 700, fontSize: 15, color: "#26201a" }}>{item.title}</span>
<span style={{ ...pill, background: "#f1efe7", color: "#a99c82" }}>Brouillon</span>
</div>
<div style={{ color: "#9a8d72", fontSize: 12.5, marginTop: 3 }}>
Modifié le {formatDay(item.updatedAt)}
{item.authorName ? ` · ${item.authorName}` : ""}
</div>
<div style={{ color: "#8c8068", fontSize: 12.5, marginTop: 5 }}>{richTextExcerpt(item.body, 110)}</div>
<div style={{ display: "flex", gap: 7, marginTop: 9, flexWrap: "wrap" }}>
<form action={publishInformation} style={{ display: "grid", gap: 7 }}>
<input type="hidden" name="id" value={item.id} />
{canBroadcast && (
<label style={{ display: "flex", gap: 8, alignItems: "flex-start", fontSize: 12.5, color: "#6c6150", cursor: mailReady ? "pointer" : "not-allowed" }}>
<input type="checkbox" name="sendEmail" disabled={!mailReady} style={{ marginTop: 2 }} />
<span>
Envoyer aussi par e-mail <strong>aux {memberCount} adhérent(s)</strong>
{!mailReady && (
<span style={{ display: "block", color: "#a99c82", fontSize: 11.5 }}>
Aucune boîte mail configurée — voir Configuration › Boîte mail.
</span>
)}
</span>
</label>
)}
<button
type="submit"
style={{ ...rowButton, border: "none", background: "#13324F", color: "#fff", fontWeight: 800, justifySelf: "start" }}
>
Publier
</button>
</form>
<Link href={`/backend/informations?modifier=${item.id}`} style={{ ...rowButton, textDecoration: "none" }}>
Modifier
</Link>
<form action={deleteInformation}>
<input type="hidden" name="id" value={item.id} />
<button type="submit" style={{ ...rowButton, color: "#d8472b", borderColor: "#e0c3bb" }}>
Supprimer
</button>
</form>
</div>
</div>
))}
</div>
</section>
</div>
</div>
</div>
);
}
+143
View File
@@ -0,0 +1,143 @@
import { redirect } from "next/navigation";
import type { CSSProperties } from "react";
import { and, desc, eq } from "drizzle-orm";
import { getSession } from "@/lib/session";
import { db } from "@/db";
import { meetingRegistrations, meetings } from "@/db/schema";
import { can } from "@/lib/rbac";
import {
confirmMeetingRegistration,
deleteMeetingRegistration,
updateMeetingRegistration,
} from "../actions";
export const dynamic = "force-dynamic";
function formatDate(date: Date) {
return new Intl.DateTimeFormat("fr-FR", { day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" }).format(date);
}
export default async function InscriptionsPage({ searchParams }: { searchParams: Promise<{ meeting?: string }> }) {
const session = await getSession();
if (!can(session?.user.role, "manageMeetings")) redirect("/backend");
const { meeting: meetingParam } = await searchParams;
const selectedMeetingId = Number(meetingParam) || null;
const [meetingRows, registrationRows] = await Promise.all([
db.select({ id: meetings.id, title: meetings.title, startsAt: meetings.startsAt }).from(meetings).orderBy(desc(meetings.startsAt)),
db
.select({
id: meetingRegistrations.id,
meetingId: meetingRegistrations.meetingId,
meetingTitle: meetings.title,
meetingStartsAt: meetings.startsAt,
attendeeName: meetingRegistrations.attendeeName,
attendeeCompany: meetingRegistrations.attendeeCompany,
attendeeEmail: meetingRegistrations.attendeeEmail,
attendeePhone: meetingRegistrations.attendeePhone,
status: meetingRegistrations.status,
imageConsent: meetingRegistrations.imageConsent,
createdAt: meetingRegistrations.createdAt,
})
.from(meetingRegistrations)
.innerJoin(meetings, eq(meetings.id, meetingRegistrations.meetingId))
.where(selectedMeetingId ? and(eq(meetingRegistrations.meetingId, selectedMeetingId)) : undefined)
.orderBy(desc(meetings.startsAt), desc(meetingRegistrations.createdAt)),
]);
return (
<div style={{ display: "grid", gap: 22 }}>
<section style={{ display: "flex", justifyContent: "space-between", alignItems: "end", gap: 16, flexWrap: "wrap" }}>
<div>
<div style={sectionLabel}>Participants</div>
<div style={{ color: "#6c6150", fontSize: 13.5, marginTop: 5 }}>{registrationRows.length} inscription{registrationRows.length > 1 ? "s" : ""} affichée{registrationRows.length > 1 ? "s" : ""}</div>
</div>
<form method="get" style={{ display: "flex", gap: 9, alignItems: "end", flexWrap: "wrap" }}>
<div>
<label className="field-label" htmlFor="meeting-filter">Rencontre</label>
<select id="meeting-filter" name="meeting" className="field" defaultValue={selectedMeetingId ?? ""} style={{ minWidth: 260 }}>
<option value="">Toutes les rencontres</option>
{meetingRows.map((meeting) => <option key={meeting.id} value={meeting.id}>{meeting.title}</option>)}
</select>
</div>
<button type="submit" style={secondaryButton}>Filtrer</button>
</form>
</section>
{registrationRows.map((registration) => (
<article key={registration.id} style={cardStyle}>
<div style={{ display: "flex", justifyContent: "space-between", gap: 14, alignItems: "start", flexWrap: "wrap", marginBottom: 16 }}>
<div>
<div className="font-display" style={{ color: "#26201a", fontSize: 19, fontWeight: 800 }}>{registration.attendeeName}</div>
<div style={{ color: "#8c8068", fontSize: 12.5, marginTop: 3 }}>{registration.meetingTitle} · {formatDate(registration.meetingStartsAt)}</div>
</div>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<span style={registration.status === "confirmed" ? confirmedBadge : pendingBadge}>{registration.status === "confirmed" ? "Confirmée" : "En attente"}</span>
<span style={registration.imageConsent ? consentBadge : refusalBadge}>{registration.imageConsent ? "Image autorisée" : "Image refusée"}</span>
</div>
</div>
<form action={updateMeetingRegistration} className="grid grid-2" style={{ gap: 12 }}>
<input type="hidden" name="id" value={registration.id} />
<div>
<label className="field-label">Participant</label>
<input name="attendeeName" className="field" defaultValue={registration.attendeeName} required />
</div>
<div>
<label className="field-label">Entreprise</label>
<input name="attendeeCompany" className="field" defaultValue={registration.attendeeCompany} />
</div>
<div>
<label className="field-label">E-mail</label>
<input name="attendeeEmail" type="email" className="field" defaultValue={registration.attendeeEmail ?? ""} />
</div>
<div>
<label className="field-label">Téléphone</label>
<input name="attendeePhone" className="field" defaultValue={registration.attendeePhone ?? ""} />
</div>
<div>
<label className="field-label">Statut</label>
<select name="status" className="field" defaultValue={registration.status}>
<option value="pending">En attente</option>
<option value="confirmed">Confirmée</option>
</select>
</div>
<label style={{ display: "flex", gap: 9, alignItems: "center", color: "#6c6150", fontSize: 13, paddingTop: 22 }}>
<input type="checkbox" name="imageConsent" defaultChecked={registration.imageConsent} />
Publication de l'image autorisée
</label>
<button type="submit" style={primaryButton}>Enregistrer</button>
</form>
<div style={{ display: "flex", gap: 10, marginTop: 12, paddingTop: 12, borderTop: "1px solid #f0e8d6", flexWrap: "wrap" }}>
{registration.status !== "confirmed" && (
<form action={confirmMeetingRegistration}>
<input type="hidden" name="id" value={registration.id} />
<button type="submit" style={confirmButton}>Confirmer</button>
</form>
)}
<form action={deleteMeetingRegistration}>
<input type="hidden" name="id" value={registration.id} />
<button type="submit" style={dangerButton}>Annuler l'inscription</button>
</form>
</div>
</article>
))}
{registrationRows.length === 0 && <div style={emptyStyle}>Aucune inscription pour cette sélection.</div>}
</div>
);
}
const sectionLabel: CSSProperties = { fontSize: 12.5, letterSpacing: "0.08em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 800 };
const cardStyle: CSSProperties = { background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 20 };
const badgeBase: CSSProperties = { display: "inline-block", borderRadius: 999, padding: "5px 10px", fontSize: 11.5, fontWeight: 800 };
const confirmedBadge: CSSProperties = { ...badgeBase, background: "#e6f4ec", color: "#1f8a5b" };
const pendingBadge: CSSProperties = { ...badgeBase, background: "#fbeede", color: "#9a6638" };
const consentBadge: CSSProperties = { ...badgeBase, background: "#eaf0f6", color: "#2C6FB3" };
const refusalBadge: CSSProperties = { ...badgeBase, background: "#fbe9e6", color: "#d8472b" };
const primaryButton: CSSProperties = { border: "none", background: "#13324F", color: "#fff", borderRadius: 8, padding: "11px 16px", fontWeight: 800, cursor: "pointer" };
const secondaryButton: CSSProperties = { border: "1px solid #d8cdb4", background: "#fff", color: "#6f6450", borderRadius: 8, padding: "11px 16px", fontWeight: 800, cursor: "pointer" };
const confirmButton: CSSProperties = { border: "none", background: "#1f8a5b", color: "#fff", borderRadius: 8, padding: "9px 14px", fontWeight: 800, cursor: "pointer" };
const dangerButton: CSSProperties = { border: "1px solid #e0c3bb", background: "#fff", color: "#d8472b", borderRadius: 8, padding: "8px 13px", fontWeight: 800, cursor: "pointer" };
const emptyStyle: CSSProperties = { background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 28, color: "#8c8068", textAlign: "center" };
+63 -4
View File
@@ -1,23 +1,81 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { eq } from "drizzle-orm";
import { auth } from "@/auth";
import { desc, eq } from "drizzle-orm";
import { getSession } from "@/lib/session";
import { db } from "@/db";
import { contactMessages, membershipRequests, promotions } from "@/db/schema";
import { contactMessages, imageConsents, membershipRequests, promotions } from "@/db/schema";
import { ImageConsentForm } from "@/components/ImageConsentForm";
import { isStaff } from "@/lib/rbac";
import { unreadInformationCount } from "@/lib/informations";
import { saveImageConsent } from "./actions";
import { BackendShell } from "./BackendShell";
import { NOINDEX } from "@/lib/seo";
export const dynamic = "force-dynamic";
// Espace privé : jamais indexé, même si une page fuyait hors du middleware.
export const metadata: Metadata = {
title: "Espace adhérent",
robots: NOINDEX,
};
export default async function BackendLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await auth();
const session = await getSession();
if (!session?.user) {
redirect("/login");
}
if (
session.user.role === "member" &&
session.user.memberId &&
!session.user.mustChangePassword
) {
const latestConsent = await db
.select({ id: imageConsents.id })
.from(imageConsents)
.where(eq(imageConsents.memberId, session.user.memberId))
.orderBy(desc(imageConsents.createdAt))
.limit(1);
if (latestConsent.length === 0) {
return (
<div style={{ minHeight: "100vh", background: "#F6F2E8", color: "#33291D", fontFamily: "'Public Sans',sans-serif", padding: "clamp(22px, 5vw, 54px)" }}>
<main style={{ maxWidth: 860, margin: "0 auto" }}>
<div style={{ display: "flex", alignItems: "center", gap: 14, marginBottom: 24 }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/assets/logo.png" alt="Plein R" style={{ height: 50, width: "auto" }} />
<div>
<div className="font-display" style={{ fontSize: 24, fontWeight: 800, color: "#26201a" }}>
Consentement RGPD
</div>
<div style={{ color: "#8c8068", fontSize: 14 }}>Droit à l'image - première connexion adhérent</div>
</div>
</div>
<section style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 20, padding: "clamp(22px, 5vw, 36px)", boxShadow: "0 24px 55px -42px rgba(40,30,15,0.55)" }}>
<h1 className="font-display" style={{ margin: "0 0 10px", color: "#26201a", fontSize: "clamp(28px, 5vw, 40px)" }}>
Votre choix sur le droit à l'image
</h1>
<p style={{ margin: "0 0 20px", color: "#6c6150", lineHeight: 1.7 }}>
Avant d'accéder à votre espace, indiquez si Plein R peut utiliser votre image dans
les supports liés aux rencontres et à la vie de l'association.
</p>
<ImageConsentForm saveAction={saveImageConsent} defaultName={session.user.name ?? "Adhérent"} />
</section>
</main>
</div>
);
}
}
// Le compteur d'informations non lues suit le compte, pas le rôle : un
// membre du bureau a lui aussi un espace adhérent.
const userId = Number(session.user.id);
const infoCount = Number.isFinite(userId) ? await unreadInformationCount(userId) : 0;
let pendingCount = 0;
let inboxCount = 0;
if (isStaff(session.user.role)) {
@@ -35,6 +93,7 @@ export default async function BackendLayout({
user={{ name: session.user.name ?? "Adhérent", role: session.user.role }}
pendingCount={pendingCount}
inboxCount={inboxCount}
infoCount={infoCount}
>
{children}
</BackendShell>
+94 -14
View File
@@ -1,10 +1,19 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { desc, eq } from "drizzle-orm";
import { auth } from "@/auth";
import { getSession } from "@/lib/session";
import { db } from "@/db";
import { activityLog, contactMessages, members, membershipRequests, promotions } from "@/db/schema";
import { isStaff } from "@/lib/rbac";
import { activityLog, contactMessages, members, membershipRequests, promotions, socialPosts } from "@/db/schema";
import { activityNodes } from "@/lib/activity";
import { can, isStaff } from "@/lib/rbac";
import { countVisiblePromotions } from "@/lib/queries";
import {
expiryStatus,
getSocialAccounts,
SOCIAL_LABELS,
tokenHealthCached,
type SocialNetwork,
} from "@/lib/social-accounts";
export const dynamic = "force-dynamic";
@@ -22,25 +31,25 @@ function StatCard({
hintColor?: string;
}) {
return (
<div style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 16, padding: 20 }}>
<div style={{ fontSize: 13, color: "#9a8d72", marginBottom: 8 }}>{label}</div>
<div className="font-display" style={{ fontWeight: 800, fontSize: 32, color: valueColor }}>
<div className="stat-card" style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 16 }}>
<div className="stat-card__label">{label}</div>
<div className="font-display stat-card__value" style={{ color: valueColor }}>
{value}
</div>
<div style={{ fontSize: 12, color: hintColor, marginTop: 6, fontWeight: 600 }}>{hint}</div>
<div className="stat-card__hint" style={{ color: hintColor }}>{hint}</div>
</div>
);
}
export default async function DashboardPage() {
const session = await auth();
const session = await getSession();
if (!isStaff(session?.user.role)) {
redirect("/backend/espace");
}
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")),
@@ -49,6 +58,48 @@ export default async function DashboardPage() {
const pendingCount = pendingPromos.length;
// 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();
const h = Math.floor(diff / 3_600_000);
@@ -60,9 +111,39 @@ export default async function DashboardPage() {
return (
<div>
{expiringNetworks.length > 0 && (
<Link
href="/backend/reseaux"
style={{
display: "block",
textDecoration: "none",
background: "#fbeede",
border: "1px solid #ecd8b8",
color: "#9a6638",
borderRadius: 12,
padding: "13px 16px",
marginBottom: 20,
fontSize: 13.5,
lineHeight: 1.6,
}}
>
{expiringNetworks.map((n) => (
<div key={n.network}>
<strong>{SOCIAL_LABELS[n.network]}</strong>{" "}
{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>
))}
</Link>
)}
<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
@@ -87,10 +168,9 @@ export default async function DashboardPage() {
{activity.map((a) => (
<div key={a.id} style={{ display: "flex", alignItems: "center", gap: 13 }}>
<span style={{ width: 9, height: 9, borderRadius: "50%", background: a.dot, flexShrink: 0 }} />
<div
style={{ flex: 1, fontSize: 13.5, color: "#3c3322" }}
dangerouslySetInnerHTML={{ __html: a.message }}
/>
<div style={{ flex: 1, fontSize: 13.5, color: "#3c3322" }}>
{activityNodes(a.message)}
</div>
<span style={{ fontSize: 12, color: "#a99c82", whiteSpace: "nowrap" }}>
{timeAgo(a.createdAt)}
</span>
+183
View File
@@ -0,0 +1,183 @@
import { redirect } from "next/navigation";
import type { CSSProperties } from "react";
import { getSession } from "@/lib/session";
import { ImageField } from "@/components/ImageField";
import { can } from "@/lib/rbac";
import { getSiteSettings } from "@/lib/site-settings";
import { saveSiteSettings } from "../actions";
export const dynamic = "force-dynamic";
export default async function ParametresPage() {
const session = await getSession();
if (!can(session?.user.role, "manageSettings")) redirect("/backend");
const settings = await getSiteSettings();
return (
<form action={saveSiteSettings} style={{ display: "grid", gap: 22, maxWidth: 1080 }}>
<section style={panel}>
<h2 className="font-display" style={title}>Identité du site</h2>
<div className="grid grid-2" style={{ gap: 16 }}>
<Field label="Nom association" name="association_name" value={settings.association_name} />
<Field label="Slogan" name="association_tagline" value={settings.association_tagline} />
<Field label="E-mail" name="association_email" value={settings.association_email} />
<Field label="Téléphone" name="association_phone" value={settings.association_phone} />
<Field label="Site web" name="association_website" value={settings.association_website} />
<Field label="Contact principal" name="association_contact" value={settings.association_contact} />
<Field
label="Page Facebook"
name="association_facebook"
value={settings.association_facebook}
placeholder="https://www.facebook.com/..."
/>
<Field
label="Page LinkedIn"
name="association_linkedin"
value={settings.association_linkedin}
placeholder="https://www.linkedin.com/company/..."
/>
<div style={{ gridColumn: "1 / -1" }}>
<Field label="Adresse" name="association_address" value={settings.association_address} />
</div>
<Area label="Texte d'introduction Association" name="association_intro" value={settings.association_intro} />
<Area
label="Mission (une ligne vide sépare les paragraphes)"
name="association_mission"
value={settings.association_mission}
/>
<Area
label="Ce que l'association apporte (Titre|Description, une ligne par point)"
name="association_pillars"
value={settings.association_pillars}
/>
</div>
<div style={{ marginTop: 14, color: "#9a6638", fontSize: 13, lineHeight: 1.6 }}>
Les pages Facebook et LinkedIn s&apos;affichent sur la page d&apos;accueil et dans le pied
de page. Laissez un champ vide pour masquer le réseau correspondant. La publication
automatique des promotions, elle, se configure côté serveur (jetons d&apos;accès dans les
variables d&apos;environnement).
</div>
</section>
<section style={panel}>
<h2 className="font-display" style={title}>Directoire</h2>
<div className="grid grid-2" style={{ gap: 16 }}>
<Field label="Président(e)" name="association_president" value={settings.association_president} />
<Field label="Intitulé du rôle" name="association_president_role" value={settings.association_president_role} />
<Field label="Vice-président(e)" name="association_vice_president" value={settings.association_vice_president} />
<Field label="Trésorier(ère)" name="association_treasurer" value={settings.association_treasurer} />
<Field label="Secrétaire" name="association_secretary" value={settings.association_secretary} />
<ImageField name="association_president_photo" label="Photo président(e)" defaultValue={settings.association_president_photo} height={130} />
<Area label="Message président(e)" name="association_president_message" value={settings.association_president_message} />
<Area
label="Autres membres du directoire (Nom|Fonction, une ligne par personne)"
name="association_board_members"
value={settings.association_board_members}
/>
</div>
</section>
<section style={panel}>
<h2 className="font-display" style={title}>Mentions légales & confidentialité</h2>
<div className="grid grid-2" style={{ gap: 16 }}>
<Field label="SIRET" name="association_siret" value={settings.association_siret} />
<Field label="RNA" name="association_rna" value={settings.association_rna} />
<Field label="Directeur de publication" name="association_publication_director" value={settings.association_publication_director} />
<Field label="Contact confidentialité/RGPD" name="association_privacy_contact" value={settings.association_privacy_contact} />
<Field label="Hébergeur" name="association_host_name" value={settings.association_host_name} />
<Field label="Téléphone hébergeur" name="association_host_phone" value={settings.association_host_phone} />
<div style={{ gridColumn: "1 / -1" }}>
<Field label="Adresse hébergeur" name="association_host_address" value={settings.association_host_address} />
</div>
<Field label="Dernière mise à jour légale" name="legal_updated" value={settings.legal_updated} placeholder="17 juillet 2026" />
</div>
<div style={{ marginTop: 14, color: "#9a6638", fontSize: 13, lineHeight: 1.6 }}>
Remplissez ces champs avant mise en production: ils alimentent les pages publiques
Mentions légales et Confidentialité.
</div>
</section>
<section style={panel}>
<h2 className="font-display" style={title}>Référencement & mesure d&apos;audience</h2>
<div className="grid grid-2" style={{ gap: 16 }}>
<Field
label="Identifiant Google Analytics (GA4)"
name="google_analytics_id"
value={settings.google_analytics_id}
placeholder="G-XXXXXXXXXX"
/>
<Field
label="Jeton Search Console (balise HTML)"
name="google_site_verification"
value={settings.google_site_verification}
placeholder="Valeur de l'attribut content"
/>
</div>
<div style={{ marginTop: 14, color: "#9a6638", fontSize: 13, lineHeight: 1.6 }}>
Laissez vide pour ne rien charger : sans identifiant, aucune balise Google n&apos;est
posée sur le site. La mesure d&apos;audience ne couvre que les pages publiques — ni le
backoffice, ni l&apos;espace adhérent. Le plan du site est déjà publié sur{" "}
<code>/sitemap.xml</code> et annoncé dans <code>/robots.txt</code> : il suffit de le
déclarer dans Search Console. Pensez à mentionner Google Analytics dans la page
Confidentialité avant de l&apos;activer.
</div>
</section>
<button type="submit" style={submitButton}>Enregistrer les paramètres</button>
</form>
);
}
function Field({
label,
name,
value,
placeholder,
}: {
label: string;
name: string;
value: string;
placeholder?: string;
}) {
return (
<div>
<label className="field-label">{label}</label>
<input name={name} defaultValue={value} placeholder={placeholder} className="field" />
</div>
);
}
function Area({ label, name, value }: { label: string; name: string; value: string }) {
return (
<div style={{ gridColumn: "1 / -1" }}>
<label className="field-label">{label}</label>
<textarea name={name} rows={4} defaultValue={value} className="field" />
</div>
);
}
const panel: CSSProperties = {
background: "#fff",
border: "1px solid #e6dcc6",
borderRadius: 16,
padding: 22,
};
const title: CSSProperties = {
margin: "0 0 16px",
fontSize: 20,
color: "#26201a",
};
const submitButton: CSSProperties = {
justifySelf: "start",
border: "none",
background: "#13324F",
color: "#fff",
fontWeight: 800,
fontSize: 14.5,
padding: "13px 22px",
borderRadius: 10,
cursor: "pointer",
};
+263 -26
View File
@@ -1,11 +1,16 @@
import { redirect } from "next/navigation";
import { desc, eq, inArray } from "drizzle-orm";
import { auth } from "@/auth";
import { getSession } from "@/lib/session";
import { db } from "@/db";
import { members, promotions } from "@/db/schema";
import { members, promotions, socialPosts, users } from "@/db/schema";
import { can } from "@/lib/rbac";
import { moderatePromo } from "../actions";
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";
@@ -14,11 +19,24 @@ const STRIPE_WARM =
const STRIPE_COOL =
"repeating-linear-gradient(45deg,#eef0ec,#eef0ec 11px,#e2e8e6 11px,#e2e8e6 22px)";
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) {
return new Date(d).toLocaleDateString("fr-FR", { day: "numeric", month: "long", year: "numeric" });
}
export default async function PromotionsPage() {
const session = await auth();
const session = await getSession();
if (!can(session?.user.role, "moderatePromos")) {
redirect("/backend");
}
const canShare = can(session?.user.role, "publishSocial");
const networks = canShare ? await configuredNetworks() : [];
const rows = await db
.select({
@@ -27,18 +45,55 @@ 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"]))
.orderBy(desc(promotions.status), desc(promotions.createdAt));
.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");
// pending first, then live
const ordered = [...pending, ...live];
const suspended = rows.filter((r) => r.status === "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
.select({
promotionId: socialPosts.promotionId,
network: socialPosts.network,
status: socialPosts.status,
url: socialPosts.url,
error: socialPosts.error,
createdAt: socialPosts.createdAt,
authorName: users.name,
})
.from(socialPosts)
.leftJoin(users, eq(socialPosts.postedById, users.id))
.where(inArray(socialPosts.promotionId, ordered.map((r) => r.id)))
.orderBy(desc(socialPosts.createdAt))
: [];
// La liste est triée du plus récent au plus ancien : la première occurrence
// rencontrée pour un couple (promo, réseau) est donc la plus récente.
const lastShare = new Map<string, (typeof shares)[number]>();
for (const s of shares) {
const key = `${s.promotionId}:${s.network}`;
if (!lastShare.has(key)) lastShare.set(key, s);
}
return (
<div>
@@ -46,20 +101,39 @@ 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>
<span style={{ background: "#fff", border: "1px solid #e6dcc6", color: "#6c6150", fontSize: 13, fontWeight: 600, padding: "8px 16px", borderRadius: 999 }}>
Suspendues · {suspended.length}
</span>
</div>
{canShare && networks.length === 0 && (
<div style={{ background: "#fbeede", border: "1px solid #ecd8b8", color: "#9a6638", borderRadius: 12, padding: "12px 16px", marginBottom: 18, fontSize: 13.5, lineHeight: 1.55 }}>
Publication sur les réseaux indisponible : aucun jeton d&apos;accès Facebook ou LinkedIn
n&apos;est configuré sur le serveur (voir <code>.env.example</code>).
</div>
)}
{ordered.length === 0 && (
<div style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 16, padding: 28, color: "#a99c82", fontSize: 14 }}>
Aucune promotion pour le moment.
</div>
)}
<div className="grid grid-3" style={{ gap: 18 }}>
<div className="grid grid-3" style={{ gap: 16, width: "100%", maxWidth: 960 }}>
{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
);
return (
<article
key={p.id}
@@ -75,12 +149,13 @@ export default async function PromotionsPage() {
<div
style={{
position: "relative",
aspectRatio: "1 / 1",
aspectRatio: "4 / 3",
background: isPending ? STRIPE_COOL : STRIPE_WARM,
overflow: "hidden",
display: "flex",
alignItems: "center",
justifyContent: "center",
opacity: isSuspended ? 0.55 : 1,
}}
>
{p.imageUrl && <PromoImage src={p.imageUrl} alt={p.title ?? ""} />}
@@ -98,34 +173,110 @@ export default async function PromotionsPage() {
padding: "4px 11px",
fontSize: 11,
fontWeight: 800,
background: isPending ? "#fbeede" : "#e6f4ec",
color: isPending ? "#9a6638" : "#1f8a5b",
background: chip.bg,
color: chip.color,
}}
>
{isPending ? "En attente" : "En ligne"}
{chip.label}
</span>
</div>
<div style={{ padding: "15px 16px 16px", display: "flex", flexDirection: "column", flex: 1 }}>
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 16.5, margin: "0 0 4px", color: "#26201a" }}>
<div style={{ padding: "13px 14px 14px", display: "flex", flexDirection: "column", flex: 1 }}>
<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 14px", fontSize: 13, color: "#8c8068", lineHeight: 1.5, flex: 1 }}>
<p style={{ margin: "0 0 12px", fontSize: 12.5, color: "#8c8068", lineHeight: 1.45, flex: 1 }}>
{p.text}
</p>
{isPending ? (
<div style={{ display: "flex", gap: 8 }}>
{/* 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&apos;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"}
{p.suspendedAt ? ` le ${fmtDate(p.suspendedAt)}` : ""}.
</div>
)}
{isPending && (
<div style={{ display: "flex", gap: 8, alignItems: "stretch" }}>
{/* Les cases vivent dans le formulaire « Valider » : c'est le
seul et dernier moment où la diffusion est ajustable. */}
<form action={moderatePromo} style={{ flex: 1 }}>
<input type="hidden" name="id" value={p.id} />
<input type="hidden" name="action" value="approve" />
{networks.length > 0 && (
<div style={{ background: "#faf7ef", border: "1px solid #f0e8d6", borderRadius: 10, padding: "10px 11px", marginBottom: 8 }}>
<div style={{ fontSize: 10.5, letterSpacing: "0.08em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 800, marginBottom: 7 }}>
Diffusion demandée
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{networks.map((network) => (
<label key={network} style={{ display: "flex", alignItems: "center", gap: 7, fontSize: 12.5, fontWeight: 700, color: SOCIAL_BRAND[network], cursor: "pointer" }}>
<input
type="checkbox"
name={network === "facebook" ? "shareFacebook" : "shareLinkedin"}
defaultChecked={network === "facebook" ? p.shareFacebook : p.shareLinkedin}
style={{ accentColor: SOCIAL_BRAND[network], margin: 0 }}
/>
<SocialIcon network={network} size={14} />
{SOCIAL_LABELS[network]}
</label>
))}
</div>
<div style={{ fontSize: 11, color: "#a99c82", marginTop: 7, lineHeight: 1.45 }}>
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&apos;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>
</form>
<form action={moderatePromo} style={{ flex: 1 }}>
{/* Aligné en bas pour rester au niveau du bouton Valider,
que le bloc diffusion rend plus haut. */}
<form action={moderatePromo} style={{ flex: 1, display: "flex", flexDirection: "column", justifyContent: "flex-end" }}>
<input type="hidden" name="id" value={p.id} />
<input type="hidden" name="action" value="reject" />
<button type="submit" style={{ width: "100%", border: "1px solid #e0c3bb", background: "#fff", color: "#d8472b", fontWeight: 700, fontSize: 13, padding: 10, borderRadius: 9, cursor: "pointer" }}>
@@ -133,18 +284,103 @@ export default async function PromotionsPage() {
</button>
</form>
</div>
) : (
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", borderTop: "1px solid #f0e8d6", paddingTop: 11 }}>
<span style={{ fontSize: 12.5, color: "#1f8a5b", fontWeight: 700 }}>● En ligne</span>
)}
{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 }}>
<input type="hidden" name="id" value={p.id} />
<input type="hidden" name="action" value={isSuspended ? "restore" : "suspend"} />
<button
type="submit"
style={{
width: "100%",
border: isSuspended ? "none" : "1px solid #e0c3bb",
background: isSuspended ? "#1f8a5b" : "#fff",
color: isSuspended ? "#fff" : "#d8472b",
fontWeight: 700,
fontSize: 13,
padding: 10,
borderRadius: 9,
cursor: "pointer",
}}
>
{isSuspended ? "Remettre en ligne" : "Suspendre"}
</button>
</form>
<form action={moderatePromo}>
<input type="hidden" name="id" value={p.id} />
<input type="hidden" name="action" value="remove" />
<button type="submit" style={{ border: "none", background: "transparent", color: "#a99c82", fontWeight: 700, fontSize: 12.5, cursor: "pointer" }}>
Retirer
<button type="submit" style={{ border: "1px solid #e6dcc6", background: "#fff", color: "#a99c82", fontWeight: 700, fontSize: 13, padding: "10px 12px", borderRadius: 9, cursor: "pointer" }}>
Supprimer
</button>
</form>
</div>
)}
{!isPending && chosenNetworks.length > 0 && (
<div style={{ borderTop: "1px solid #f0e8d6", marginTop: 12, paddingTop: 11 }}>
<div style={{ fontSize: 10.5, letterSpacing: "0.1em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 800, marginBottom: 8 }}>
Diffusion réseaux
</div>
{chosenNetworks.map((network) => {
const last = lastShare.get(`${p.id}:${network}`);
const posted = last?.status === "posted";
return (
<div key={network} style={{ marginBottom: 8 }}>
<div style={{ display: "flex", alignItems: "center", gap: 7, fontSize: 12, fontWeight: 700, color: posted ? "#1f8a5b" : "#d8472b" }}>
<span style={{ color: SOCIAL_BRAND[network], display: "inline-flex" }}>
<SocialIcon network={network} size={14} />
</span>
{SOCIAL_LABELS[network]} ·{" "}
{posted ? `publié le ${fmtDate(last!.createdAt)}` : "non publié"}
{posted && last?.url && (
<a href={last.url} target="_blank" rel="noopener noreferrer" style={{ color: "#2C6FB3", fontWeight: 700 }}>
voir
</a>
)}
</div>
{!posted && last?.error && (
<div style={{ fontSize: 11, color: "#a8503c", lineHeight: 1.45, marginTop: 3 }}>
{last.error.slice(0, 200)}
</div>
)}
{!posted && p.status === "live" && (
<form action={retryPromoShare} style={{ marginTop: 6 }}>
<input type="hidden" name="id" value={p.id} />
<input type="hidden" name="network" value={network} />
<button
type="submit"
style={{
border: `1px solid ${SOCIAL_BRAND[network]}`,
background: "#fff",
color: SOCIAL_BRAND[network],
fontWeight: 700,
fontSize: 12,
padding: "7px 12px",
borderRadius: 8,
cursor: "pointer",
}}
>
Réessayer sur {SOCIAL_LABELS[network]}
</button>
</form>
)}
</div>
);
})}
</div>
)}
</div>
</article>
);
@@ -153,3 +389,4 @@ export default async function PromotionsPage() {
</div>
);
}
+172
View File
@@ -0,0 +1,172 @@
import { redirect } from "next/navigation";
import type { CSSProperties } from "react";
import { asc, desc, eq, sql } from "drizzle-orm";
import { getSession } from "@/lib/session";
import { db } from "@/db";
import { meetingRegistrations, meetings, pastMeetingPhotos, pastMeetings } from "@/db/schema";
import { ImageField } from "@/components/ImageField";
import { can } from "@/lib/rbac";
import {
addPastMeetingPhoto,
createPastMeeting,
deletePastMeeting,
deletePastMeetingPhoto,
updatePastMeeting,
} from "../actions";
export const dynamic = "force-dynamic";
function dateValue(date: Date) {
const local = new Date(date);
local.setMinutes(local.getMinutes() - local.getTimezoneOffset());
return local.toISOString().slice(0, 10);
}
export default async function PastMeetingsAdminPage() {
const session = await getSession();
if (!can(session?.user.role, "manageMeetings")) redirect("/backend");
const [meetingRows, archives, photos, refusals] = await Promise.all([
db
.select({
id: meetings.id,
title: meetings.title,
startsAt: meetings.startsAt,
registered: sql<number>`count(${meetingRegistrations.id})`,
})
.from(meetings)
.leftJoin(meetingRegistrations, eq(meetingRegistrations.meetingId, meetings.id))
.groupBy(meetings.id)
.orderBy(desc(meetings.startsAt)),
db.select().from(pastMeetings).orderBy(desc(pastMeetings.eventDate)),
db.select().from(pastMeetingPhotos).orderBy(asc(pastMeetingPhotos.position), asc(pastMeetingPhotos.id)),
db
.select({ meetingId: meetingRegistrations.meetingId, attendeeName: meetingRegistrations.attendeeName })
.from(meetingRegistrations)
.where(eq(meetingRegistrations.imageConsent, false))
.orderBy(asc(meetingRegistrations.attendeeName)),
]);
const photosByPast = new Map<number, typeof photos>();
for (const photo of photos) photosByPast.set(photo.pastMeetingId, [...(photosByPast.get(photo.pastMeetingId) ?? []), photo]);
const refusalsByMeeting = new Map<number, string[]>();
for (const refusal of refusals) refusalsByMeeting.set(refusal.meetingId, [...(refusalsByMeeting.get(refusal.meetingId) ?? []), refusal.attendeeName]);
return (
<div style={{ display: "grid", gap: 28 }}>
<section style={panelStyle}>
<h2 className="font-display" style={{ margin: "0 0 6px", fontSize: 20, color: "#26201a" }}>Nouvelle rencontre passée</h2>
<p style={{ color: "#8c8068", fontSize: 13, margin: "0 0 18px" }}>Les trois archives les plus récentes apparaissent sur « L'association » et la page complète rassemble toutes les rencontres passées.</p>
<form action={createPastMeeting} className="grid grid-2" style={{ gap: 16 }}>
<div>
<label className="field-label">Titre</label>
<input name="title" className="field" required />
</div>
<div>
<label className="field-label">Date</label>
<input name="eventDate" type="date" className="field" required />
</div>
<div>
<label className="field-label">Lieu</label>
<input name="location" className="field" />
</div>
<div>
<label className="field-label">Rencontre liée</label>
<select name="meetingId" className="field" defaultValue="">
<option value="">Aucune</option>
{meetingRows.map((meeting) => <option key={meeting.id} value={meeting.id}>{meeting.title} ({Number(meeting.registered)} participants)</option>)}
</select>
</div>
<div style={{ gridColumn: "1 / -1" }}>
<label className="field-label">Description</label>
<textarea name="description" rows={3} className="field" />
</div>
<div style={{ gridColumn: "1 / -1" }}>
<label className="field-label">Participants manuels (un par ligne, uniquement sans rencontre liée)</label>
<textarea name="participants" rows={3} className="field" />
</div>
<button type="submit" style={primaryButton}>Publier l'archive</button>
</form>
</section>
<section>
<div style={sectionTitle}>Archives publiées</div>
<div className="grid grid-3" style={{ gap: 18 }}>
{archives.map((archive) => {
const refused = archive.meetingId ? refusalsByMeeting.get(archive.meetingId) ?? [] : [];
return (
<article key={archive.id} style={cardStyle}>
{refused.length > 0 && (
<div style={{ background: "#fbe9e6", border: "1px solid #f0c4bb", color: "#b53a25", borderRadius: 8, padding: "11px 13px", fontSize: 12.5, lineHeight: 1.5, marginBottom: 13 }}>
<strong>Droit à l'image refusé :</strong> {refused.join(", ")}. Vérifiez que ces personnes ne figurent pas sur les photos publiées.
</div>
)}
<form action={updatePastMeeting} style={{ display: "grid", gap: 12 }}>
<input type="hidden" name="id" value={archive.id} />
<div>
<label className="field-label">Titre</label>
<input name="title" defaultValue={archive.title} className="field" required />
</div>
<div>
<label className="field-label">Date</label>
<input name="eventDate" type="date" defaultValue={dateValue(archive.eventDate)} className="field" required />
</div>
<div>
<label className="field-label">Lieu</label>
<input name="location" defaultValue={archive.location ?? ""} className="field" />
</div>
<div>
<label className="field-label">Rencontre liée</label>
<select name="meetingId" className="field" defaultValue={archive.meetingId ?? ""}>
<option value="">Aucune</option>
{meetingRows.map((meeting) => <option key={meeting.id} value={meeting.id}>{meeting.title} ({Number(meeting.registered)})</option>)}
</select>
</div>
<div>
<label className="field-label">Description</label>
<textarea name="description" rows={3} defaultValue={archive.description ?? ""} className="field" />
</div>
<div>
<label className="field-label">Participants manuels</label>
<textarea name="participants" rows={3} defaultValue={archive.participants ?? ""} className="field" />
</div>
<button type="submit" style={primaryButton}>Enregistrer</button>
</form>
<div style={{ marginTop: 14, borderTop: "1px solid #f0e8d6", paddingTop: 14 }}>
<form action={addPastMeetingPhoto} style={{ display: "grid", gap: 10 }}>
<input type="hidden" name="pastMeetingId" value={archive.id} />
<ImageField name="imageUrl" label="Ajouter une photo" height={100} />
<input name="caption" className="field" placeholder="Légende" />
<button type="submit" style={primaryButton}>Ajouter la photo</button>
</form>
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 8, marginTop: 12 }}>
{(photosByPast.get(archive.id) ?? []).map((photo) => (
<form key={photo.id} action={deletePastMeetingPhoto}>
<input type="hidden" name="id" value={photo.id} />
<button type="submit" title="Supprimer" aria-label={`Supprimer la photo ${photo.caption ?? ""}`} style={{ width: "100%", aspectRatio: "1 / 1", border: "1px solid #e6dcc6", borderRadius: 8, cursor: "pointer", background: `center/cover no-repeat url(${photo.imageUrl})` }} />
</form>
))}
</div>
</div>
<form action={deletePastMeeting} style={{ marginTop: 12 }}>
<input type="hidden" name="id" value={archive.id} />
<button type="submit" style={dangerButton}>Supprimer l'archive</button>
</form>
</article>
);
})}
</div>
{archives.length === 0 && <div style={emptyStyle}>Aucune rencontre passée publiée.</div>}
</section>
</div>
);
}
const panelStyle: CSSProperties = { background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 22 };
const cardStyle: CSSProperties = { background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 16 };
const sectionTitle: CSSProperties = { fontSize: 12.5, letterSpacing: "0.08em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 800, marginBottom: 12 };
const primaryButton: CSSProperties = { border: "none", background: "#13324F", color: "#fff", fontWeight: 800, fontSize: 13.5, padding: "11px 16px", borderRadius: 8, cursor: "pointer" };
const dangerButton: CSSProperties = { width: "100%", border: "1px solid #e0c3bb", background: "#fff", color: "#d8472b", fontWeight: 800, fontSize: 13, padding: "10px 14px", borderRadius: 8, cursor: "pointer" };
const emptyStyle: CSSProperties = { background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 24, color: "#8c8068" };
+171
View File
@@ -0,0 +1,171 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import type { CSSProperties } from "react";
import { desc, eq, sql } from "drizzle-orm";
import { getSession } from "@/lib/session";
import { db } from "@/db";
import { meetingRegistrations, meetings } from "@/db/schema";
import { ImageField } from "@/components/ImageField";
import { MeetingEmailComposer } from "@/components/MeetingEmailComposer";
import { can } from "@/lib/rbac";
import { emailBrand, getSiteSettings } from "@/lib/site-settings";
import { audienceOptions } from "@/lib/mail-recipients";
import { isMailConfigured } from "@/lib/mail-accounts";
import { siteUrl } from "@/lib/social-accounts";
import { createMeeting, deleteMeeting, updateMeeting } from "../actions";
export const dynamic = "force-dynamic";
function dateTimeValue(date: Date) {
const local = new Date(date);
local.setMinutes(local.getMinutes() - local.getTimezoneOffset());
return local.toISOString().slice(0, 16);
}
export default async function RencontresAdminPage() {
const session = await getSession();
if (!can(session?.user.role, "manageMeetings")) redirect("/backend");
const canSend = can(session?.user.role, "manageEmails");
const [rows, settings, choices, mailReady, base] = await Promise.all([
db
.select({
id: meetings.id,
title: meetings.title,
startsAt: meetings.startsAt,
location: meetings.location,
description: meetings.description,
capacity: meetings.capacity,
participantsPerAccount: meetings.participantsPerAccount,
imageUrl: meetings.imageUrl,
registered: sql<number>`count(${meetingRegistrations.id})`,
})
.from(meetings)
.leftJoin(meetingRegistrations, eq(meetingRegistrations.meetingId, meetings.id))
.groupBy(meetings.id)
.orderBy(desc(meetings.startsAt)),
getSiteSettings(),
audienceOptions(),
isMailConfigured(),
siteUrl(),
]);
const brand = emailBrand(settings);
return (
<div style={{ display: "grid", gap: 28 }}>
<section style={panelStyle}>
<h2 className="font-display" style={{ margin: "0 0 16px", fontSize: 20, color: "#26201a" }}>Nouvelle rencontre</h2>
<form action={createMeeting} className="grid grid-2" style={{ gap: 16 }}>
<div>
<label className="field-label">Titre</label>
<input name="title" className="field" required />
</div>
<div>
<label className="field-label">Date et heure</label>
<input name="startsAt" type="datetime-local" className="field" required />
</div>
<div>
<label className="field-label">Lieu</label>
<input name="location" className="field" />
</div>
<div className="grid grid-2" style={{ gap: 12 }}>
<div>
<label className="field-label">Places</label>
<input name="capacity" type="number" min="1" defaultValue={30} className="field" />
</div>
<div>
<label className="field-label">Max. par compte</label>
<input name="participantsPerAccount" type="number" min="1" max="100" defaultValue={1} className="field" />
</div>
</div>
<div style={{ gridColumn: "1 / -1" }}>
<label className="field-label">Description</label>
<textarea name="description" rows={3} className="field" />
</div>
<div style={{ gridColumn: "1 / -1" }}>
<ImageField name="imageUrl" label="Image" height={160} />
</div>
<button type="submit" style={primaryButton}>Ajouter la rencontre</button>
</form>
</section>
<section>
<div style={sectionTitle}>Rencontres publiées</div>
<div className="grid grid-3" style={{ gap: 18 }}>
{rows.map((meeting) => (
<article key={meeting.id} style={cardStyle}>
<form action={updateMeeting} style={{ display: "grid", gap: 12 }}>
<input type="hidden" name="id" value={meeting.id} />
<ImageField name="imageUrl" label="Image" defaultValue={meeting.imageUrl ?? ""} height={120} />
<div>
<label className="field-label">Titre</label>
<input name="title" defaultValue={meeting.title} className="field" required />
</div>
<div>
<label className="field-label">Date et heure</label>
<input name="startsAt" type="datetime-local" defaultValue={dateTimeValue(meeting.startsAt)} className="field" required />
</div>
<div>
<label className="field-label">Lieu</label>
<input name="location" defaultValue={meeting.location ?? ""} className="field" />
</div>
<div className="grid grid-2" style={{ gap: 10 }}>
<div>
<label className="field-label">Places</label>
<input name="capacity" type="number" min="1" defaultValue={meeting.capacity} className="field" />
</div>
<div>
<label className="field-label">Max. / compte</label>
<input name="participantsPerAccount" type="number" min="1" max="100" defaultValue={meeting.participantsPerAccount} className="field" />
</div>
</div>
<div>
<label className="field-label">Description</label>
<textarea name="description" rows={3} defaultValue={meeting.description ?? ""} className="field" />
</div>
<button type="submit" style={primaryButton}>Enregistrer</button>
</form>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 12, marginTop: 14, paddingTop: 14, borderTop: "1px solid #f0e8d6" }}>
<div style={{ fontSize: 12.5, color: "#6c6150", fontWeight: 800 }}>{Number(meeting.registered)} participant(s)</div>
<Link href={`/backend/inscriptions?meeting=${meeting.id}`} style={{ color: "#9a6638", fontSize: 12.5, fontWeight: 800, textDecoration: "none" }}>Voir les inscrits</Link>
</div>
<MeetingEmailComposer
meeting={{
id: meeting.id,
title: meeting.title,
startsAt: meeting.startsAt.toISOString(),
location: meeting.location,
description: meeting.description,
capacity: meeting.capacity,
registered: Number(meeting.registered),
}}
brand={brand}
choices={{
...choices,
meeting: { id: meeting.id, title: meeting.title, count: Number(meeting.registered) },
}}
// Un modérateur gère les rencontres mais n'écrit pas à tous les
// adhérents : l'envoi reste réservé au studio d'e-mails.
mailReady={canSend && mailReady}
siteUrl={base}
/>
<form action={deleteMeeting} style={{ marginTop: 8 }}>
<input type="hidden" name="id" value={meeting.id} />
<button type="submit" style={dangerButton}>Supprimer</button>
</form>
</article>
))}
</div>
{rows.length === 0 && <div style={emptyStyle}>Aucune rencontre programmée.</div>}
</section>
</div>
);
}
const panelStyle: CSSProperties = { background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 22 };
const cardStyle: CSSProperties = { background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 16 };
const sectionTitle: CSSProperties = { fontSize: 12.5, letterSpacing: "0.08em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 800, marginBottom: 12 };
const primaryButton: CSSProperties = { border: "none", background: "#13324F", color: "#fff", fontWeight: 800, fontSize: 13.5, padding: "11px 16px", borderRadius: 8, cursor: "pointer" };
const dangerButton: CSSProperties = { width: "100%", border: "1px solid #e0c3bb", background: "#fff", color: "#d8472b", fontWeight: 800, fontSize: 13, padding: "10px 14px", borderRadius: 8, cursor: "pointer" };
const emptyStyle: CSSProperties = { background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 24, color: "#8c8068" };
+518
View File
@@ -0,0 +1,518 @@
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,
redirectUri,
siteUrl,
SOCIAL_LABELS,
SOCIAL_NETWORKS,
type SocialTarget,
type TokenHealth,
} from "@/lib/social-accounts";
import { disconnectSocial, saveSitePublicUrl, saveSocialApp, selectSocialTarget } from "../actions";
export const dynamic = "force-dynamic";
const HELP: Record<
(typeof SOCIAL_NETWORKS)[number],
{
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",
steps: [
"Créez une application de type « Business » et relevez l'identifiant et la clé secrète (Paramètres › Général).",
"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",
steps: [
"Créez une application rattachée à la page LinkedIn de l'association et relevez le Client ID et le Client Secret (onglet Auth).",
"Collez l'URL de redirection ci-dessous dans « Authorized redirect URLs ».",
"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 « 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" },
],
},
};
function fmtDate(d: Date) {
return new Date(d).toLocaleDateString("fr-FR", { day: "numeric", month: "long", year: "numeric" });
}
export default async function ReseauxPage({
searchParams,
}: {
searchParams: Promise<{ error?: string; connected?: string; choose?: string }>;
}) {
const session = await getSession();
if (!can(session?.user.role, "manageSettings")) redirect("/backend");
const { error, connected, choose } = await searchParams;
const accounts = await getSocialAccounts();
const byNetwork = new Map(accounts.map((a) => [a.network, a]));
const base = await siteUrl();
// Adresse par laquelle l'administrateur consulte cette page : proposée par
// défaut pour lui éviter de la recopier à la main.
const detected = originFromHeaders(await headers());
// Calculées ici : le rendu des cartes n'est pas asynchrone.
const redirectUris = new Map<string, string>();
for (const network of SOCIAL_NETWORKS) {
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;
for (const network of SOCIAL_NETWORKS) {
const account = byNetwork.get(network);
if (account?.accessToken && !account.targetId) {
try {
targets.set(network, await listStoredTargets(network));
} catch (caught) {
targetError = caught instanceof Error ? caught.message : "Pages illisibles.";
}
}
}
return (
<div style={{ display: "grid", gap: 22, maxWidth: 1080 }}>
{error && <Banner tone="error">{error}</Banner>}
{targetError && <Banner tone="error">{targetError}</Banner>}
{connected && (
<Banner tone="ok">
Compte {SOCIAL_LABELS[connected as "facebook"] ?? connected} connecté.
</Banner>
)}
{choose && (
<Banner tone="warn">
Connexion réussie : choisissez la page à utiliser pour les publications.
</Banner>
)}
<section style={panel}>
<h2 className="font-display" style={{ ...title, margin: "0 0 4px" }}>
URL publique du site
</h2>
<p style={{ margin: "0 0 14px", fontSize: 13.5, color: "#6c6150", lineHeight: 1.6 }}>
Adresse à laquelle vos visiteurs accèdent au site. Elle sert à construire
l&apos;adresse de retour des connexions Facebook et LinkedIn, et le lien inséré dans
les publications.
</p>
<form action={saveSitePublicUrl} style={{ display: "flex", gap: 10, flexWrap: "wrap", alignItems: "flex-end" }}>
<div style={{ flex: "1 1 320px", minWidth: 0 }}>
<label className="field-label">Adresse</label>
<input
name="sitePublicUrl"
className="field"
defaultValue={base || detected}
placeholder="https://pleinr.example.fr"
/>
</div>
<button type="submit" style={submitButton}>Enregistrer</button>
</form>
{!base && detected && (
<div style={{ marginTop: 10, fontSize: 12.5, color: "#9a6638" }}>
Aucune adresse enregistrée : celle de votre navigation, <strong>{detected}</strong>, est
utilisée par défaut. Facebook et LinkedIn n&apos;acceptent qu&apos;une adresse de retour
en <code>https</code> sur un nom de domaine : vérifiez-la puis enregistrez.
</div>
)}
{!base && !detected && (
<div style={{ marginTop: 10, fontSize: 12.5, color: "#a8503c" }}>
Renseignez cette adresse avant de connecter un réseau social.
</div>
)}
</section>
{SOCIAL_NETWORKS.map((network) => {
const account = byNetwork.get(network);
const help = HELP[network];
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}>
<div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 6 }}>
<span
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 40,
height: 40,
borderRadius: 12,
background: SOCIAL_BRAND[network],
color: "#fff",
flexShrink: 0,
}}
>
<SocialIcon network={network} size={20} />
</span>
<h2 className="font-display" style={{ ...title, margin: 0, flex: 1 }}>
{SOCIAL_LABELS[network]}
</h2>
<StatusChip connected={isConnected} status={broken ? "expired" : status} />
</div>
{isConnected && (
<div style={{ fontSize: 13.5, color: "#6c6150", marginBottom: 14, lineHeight: 1.6 }}>
Publie sur <strong style={{ color: "#26201a" }}>{account?.targetName}</strong>.
{status === "never" && " Ce jeton n'expire pas."}
{account?.expiresAt && status !== "never" && ` Jeton valable jusqu'au ${fmtDate(account.expiresAt)}.`}
{account?.connectedAt && ` Connecté le ${fmtDate(account.connectedAt)}.`}
<br />
{tokenHealth?.ok && (
<span style={{ color: "#1f8a5b", fontWeight: 600 }}>
✓ Jeton vérifié à l&apos;instant auprès de {SOCIAL_LABELS[network]} : il fonctionne.{" "}
</span>
)}
Pour changer de page ou renouveler l&apos;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&apos;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 ne l'aurez pas renouvelé.`
: `Le jeton ${SOCIAL_LABELS[network]} expire bientôt. Un clic sur « Renouveler le jeton » suffit.`}
</Banner>
)}
{candidates.length > 0 && (
<div style={{ background: "#faf7ef", border: "1px solid #f0e8d6", borderRadius: 12, padding: 15, marginBottom: 16 }}>
<div className="field-label" style={{ marginBottom: 9 }}>
Page à utiliser pour les publications
</div>
<div style={{ display: "grid", gap: 8 }}>
{candidates.map((target) => (
<form key={target.id} action={selectSocialTarget} style={{ display: "flex", alignItems: "center", gap: 10 }}>
<input type="hidden" name="network" value={network} />
<input type="hidden" name="targetId" value={target.id} />
<span style={{ flex: 1, fontSize: 13.5, color: "#3c3322", fontWeight: 600 }}>
{target.name}
</span>
<button type="submit" style={{ border: "none", background: "#13324F", color: "#fff", fontWeight: 700, fontSize: 12.5, padding: "8px 14px", borderRadius: 9, cursor: "pointer" }}>
Choisir
</button>
</form>
))}
</div>
</div>
)}
<form action={saveSocialApp} style={{ marginBottom: 14 }}>
<input type="hidden" name="network" value={network} />
<div className="grid grid-2" style={{ gap: 16 }}>
<div>
<label className="field-label">
{network === "facebook" ? "Identifiant de l'application" : "Client ID"}
</label>
<input name="appId" defaultValue={account?.appId ?? ""} className="field" required />
</div>
<div>
<label className="field-label">
{network === "facebook" ? "Clé secrète" : "Client Secret"}
</label>
<input
name="appSecret"
type="password"
className="field"
autoComplete="new-password"
placeholder={account ? "Enregistré — laissez vide pour le conserver" : "Collez la clé secrète"}
/>
</div>
</div>
<button type="submit" style={{ ...submitButton, marginTop: 14 }}>
Enregistrer les identifiants
</button>
</form>
{/* Renouvellement : l'action et ses deux liens de contrôle, visibles
sans avoir à déplier l'aide de configuration. */}
{isConnected && (
<div
style={{
background: needsRenewal ? "#fbeede" : "#faf7ef",
border: `1px solid ${needsRenewal ? "#ecd8b8" : "#f0e8d6"}`,
borderRadius: 12,
padding: 15,
marginBottom: 16,
}}
>
<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} />
<button type="submit" style={{ border: "1px solid #e0c3bb", background: "#fff", color: "#d8472b", fontWeight: 700, fontSize: 14, padding: "12px 18px", borderRadius: 10, cursor: "pointer" }}>
Déconnecter
</button>
</form>
)}
</div>
<details style={{ marginTop: 16 }}>
<summary style={{ cursor: "pointer", fontSize: 13.5, fontWeight: 700, color: "#9a6638" }}>
Comment obtenir ces identifiants ?
</summary>
<ol style={{ margin: "12px 0 0", paddingLeft: 20, fontSize: 13.5, color: "#6c6150", lineHeight: 1.7 }}>
{help.steps.map((step) => (
<li key={step}>{step}</li>
))}
</ol>
<div style={{ marginTop: 12, fontSize: 13, color: "#6c6150" }}>
Portail :{" "}
<a href={help.portal} target="_blank" rel="noopener noreferrer" style={{ color: "#2C6FB3", fontWeight: 700 }}>
{help.portal}
</a>
</div>
{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&apos;échange complet — jeton court,
puis jeton de longue durée, puis jeton de page — vous n&apos;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 —"}
</code>
</div>
{help.caution && (
<div style={{ marginTop: 12, background: "#fbeede", border: "1px solid #ecd8b8", color: "#9a6638", borderRadius: 10, padding: "11px 13px", fontSize: 13, lineHeight: 1.6 }}>
{help.caution}
</div>
)}
</details>
</section>
);
})}
</div>
);
}
function StatusChip({ connected, status }: { connected: boolean; status: string }) {
const [label, bg, color] = !connected
? ["Non connecté", "#f1efe7", "#a99c82"]
: status === "expired"
? ["Jeton expiré", "#fbe9e6", "#d8472b"]
: status === "soon"
? ["Expire bientôt", "#fbeede", "#9a6638"]
: ["Connecté", "#e6f4ec", "#1f8a5b"];
return (
<span style={{ background: bg, color, borderRadius: 999, padding: "6px 13px", fontSize: 12, fontWeight: 800 }}>
{label}
</span>
);
}
function Banner({ tone, children }: { tone: "ok" | "warn" | "error"; children: React.ReactNode }) {
const palette = {
ok: { bg: "#e6f4ec", border: "#c7e6d5", color: "#1f8a5b" },
warn: { bg: "#fbeede", border: "#ecd8b8", color: "#9a6638" },
error: { bg: "#fbe9e6", border: "#f2d5cf", color: "#a8503c" },
}[tone];
return (
<div style={{ background: palette.bg, border: `1px solid ${palette.border}`, color: palette.color, borderRadius: 12, padding: "12px 16px", fontSize: 13.5, lineHeight: 1.6 }}>
{children}
</div>
);
}
const panel: CSSProperties = {
background: "#fff",
border: "1px solid #e6dcc6",
borderRadius: 16,
padding: 22,
};
const title: CSSProperties = {
fontSize: 20,
color: "#26201a",
};
const submitButton: CSSProperties = {
border: "none",
background: "#13324F",
color: "#fff",
fontWeight: 800,
fontSize: 14,
padding: "11px 20px",
borderRadius: 10,
cursor: "pointer",
};
+67
View File
@@ -0,0 +1,67 @@
import type { Metadata } from "next";
import { LegalPage } from "@/components/LegalPage";
import { getSiteSettings } from "@/lib/site-settings";
import { pageMetadata } from "@/lib/seo";
export const metadata: Metadata = pageMetadata({
title: "Politique de confidentialité",
description: "Politique de confidentialité et traitement des données personnelles par l'association Plein R.",
path: "/confidentialite",
});
export const dynamic = "force-dynamic";
export default async function ConfidentialitePage() {
const settings = await getSiteSettings();
const contact = settings.association_privacy_contact || settings.association_email || "le formulaire « Nous contacter »";
return (
<LegalPage title="Confidentialité" intro={`Cette page explique quelles données sont recueillies et comment ${settings.association_name} les utilise.`}>
<h2>Données recueillies</h2>
<p>Selon votre utilisation du site, Plein R peut recevoir :</p>
<ul>
<li>vos nom, e-mail, téléphone et message lors d’une prise de contact ou demande d’adhésion ;</li>
<li>les informations professionnelles publiées dans l’annuaire et les promotions ;</li>
<li>les données nécessaires à la connexion et à la sécurisation des espaces privés.</li>
</ul>
<h2>Finalités</h2>
<p>
Ces données servent à répondre aux demandes, gérer les adhésions, administrer l’annuaire,
modérer les promotions et sécuriser l’accès aux comptes. Elles ne sont pas vendues à des tiers.
</p>
<h2>Responsable, bases légales et destinataires</h2>
<p>
{settings.association_name} est responsable des traitements réalisés par ce site. Les traitements reposent, selon le cas,
sur les démarches précontractuelles liées à l’adhésion, l’exécution de la relation avec les adhérents,
l’intérêt légitime de l’association à répondre aux demandes et administrer son réseau, ou une obligation légale.
</p>
<p>
Seules les personnes habilitées au sein de Plein R et les prestataires techniques nécessaires au fonctionnement
du site peuvent accéder aux données, dans la limite de leurs missions.
</p>
<h2>Durée et sécurité</h2>
<p>
Les données sont conservées pendant la durée nécessaire au traitement demandé, à la gestion de la relation
avec l’association et au respect de ses obligations. Des mesures adaptées limitent accès, perte ou divulgation non autorisée.
</p>
<h2>Cookies</h2>
<p>
Le site utilise uniquement les éléments techniques nécessaires à son fonctionnement, notamment pour maintenir
une session sécurisée dans l’espace adhérent ou l’administration.
</p>
<h2>Vos droits</h2>
<p>
Vous pouvez demander accès, rectification, effacement, limitation, opposition ou portabilité concernant vos données,
selon les conditions prévues par la réglementation.
Envoyez votre demande via {contact} en précisant votre identité et l’objet de votre demande.
</p>
<p>Vous pouvez également saisir la CNIL si vous estimez que vos droits ne sont pas respectés.</p>
{settings.legal_updated && <p>Dernière mise à jour : {settings.legal_updated}</p>}
</LegalPage>
);
}
+1429 -7
View File
File diff suppressed because it is too large. Load diff
+143
View File
@@ -0,0 +1,143 @@
import type { Metadata } from "next";
import Link from "next/link";
import { and, asc, eq, gte, sql } from "drizzle-orm";
import { getSession } from "@/lib/session";
import { MeetingRegistrationForm } from "@/components/MeetingRegistrationForm";
import { SiteFooter } from "@/components/SiteFooter";
import { SiteHeader } from "@/components/SiteHeader";
import { db } from "@/db";
import { meetingRegistrations, meetings, members } from "@/db/schema";
import { NOINDEX } from "@/lib/seo";
export const dynamic = "force-dynamic";
// Page transactionnelle : rien à indexer, la rencontre est décrite sur /association.
export const metadata: Metadata = {
title: "Inscription à une rencontre",
robots: NOINDEX,
};
function formatDate(date: Date) {
return new Intl.DateTimeFormat("fr-FR", {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
}).format(date);
}
function formatTime(date: Date) {
return new Intl.DateTimeFormat("fr-FR", { hour: "2-digit", minute: "2-digit" }).format(date);
}
export default async function MeetingRegistrationPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const meetingId = Number(id);
const session = await getSession();
const now = new Date();
const [meeting] = Number.isInteger(meetingId)
? await db
.select({
id: meetings.id,
title: meetings.title,
startsAt: meetings.startsAt,
location: meetings.location,
description: meetings.description,
capacity: meetings.capacity,
participantsPerAccount: meetings.participantsPerAccount,
imageUrl: meetings.imageUrl,
registered: sql<number>`count(${meetingRegistrations.id})`,
})
.from(meetings)
.leftJoin(meetingRegistrations, eq(meetingRegistrations.meetingId, meetings.id))
.where(and(eq(meetings.id, meetingId), gte(meetings.startsAt, now)))
.groupBy(meetings.id)
: [];
const memberId = session?.user.memberId ?? null;
const [member, existing] = memberId && meeting
? await Promise.all([
db
.select({ name: members.name, status: members.status })
.from(members)
.where(eq(members.id, memberId))
.then((rows) => rows[0] ?? null),
db
.select({
name: meetingRegistrations.attendeeName,
imageConsent: meetingRegistrations.imageConsent,
status: meetingRegistrations.status,
})
.from(meetingRegistrations)
.where(and(eq(meetingRegistrations.meetingId, meeting.id), eq(meetingRegistrations.memberId, memberId)))
.orderBy(asc(meetingRegistrations.createdAt), asc(meetingRegistrations.id)),
])
: [null, []];
const registered = meeting ? Number(meeting.registered) : 0;
const remaining = meeting ? Math.max(0, meeting.capacity - registered) : 0;
return (
<div style={{ background: "#F6F2E8", minHeight: "100vh", fontFamily: "'Public Sans',sans-serif", color: "#33291D" }}>
<SiteHeader active="association" logo />
<main>
{!meeting ? (
<section className="container" style={{ paddingTop: 72, paddingBottom: 96, maxWidth: 760 }}>
<div style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: "32px clamp(22px, 5vw, 38px)" }}>
<div style={{ color: "#9a6638", fontSize: 12, letterSpacing: "0.14em", textTransform: "uppercase", fontWeight: 800 }}>Rencontre</div>
<h1 className="font-display" style={{ color: "#26201a", fontSize: 34, margin: "8px 0 12px" }}>Inscription indisponible</h1>
<p style={{ color: "#6c6150", lineHeight: 1.7 }}>Cette rencontre n'existe pas ou les inscriptions sont terminées.</p>
<Link href="/association" style={{ display: "inline-block", marginTop: 12, color: "#9a6638", fontWeight: 800, textDecoration: "none" }}>Voir les prochaines rencontres</Link>
</div>
</section>
) : (
<section className="container" style={{ paddingTop: 40, paddingBottom: 80 }}>
<div style={{ gap: 28, alignItems: "start" }} className="meeting-registration-layout">
<article style={{ background: "#13324F", color: "#fff", borderRadius: 8, overflow: "hidden" }}>
<div style={{ aspectRatio: "16 / 9", background: meeting.imageUrl ? `center/cover no-repeat url(${meeting.imageUrl})` : "repeating-linear-gradient(45deg,#244965,#244965 12px,#2d5571 12px,#2d5571 24px)" }} />
<div style={{ padding: 24 }}>
<div style={{ color: "#E0A63C", fontSize: 12.5, fontWeight: 800 }}>{formatDate(meeting.startsAt)} · {formatTime(meeting.startsAt)}</div>
<h1 className="font-display" style={{ fontSize: 30, margin: "9px 0 8px" }}>{meeting.title}</h1>
{meeting.location && <div style={{ color: "#cfe0ee", fontSize: 14, fontWeight: 700 }}>{meeting.location}</div>}
{meeting.description && <p style={{ color: "#cfe0ee", lineHeight: 1.7, margin: "16px 0" }}>{meeting.description}</p>}
<div style={{ borderTop: "1px solid rgba(255,255,255,.16)", paddingTop: 15, color: "#fff", fontSize: 13.5, fontWeight: 800 }}>
{registered}/{meeting.capacity} participants · {remaining} place{remaining > 1 ? "s" : ""} restante{remaining > 1 ? "s" : ""}
</div>
</div>
</article>
<section style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: "clamp(22px, 4vw, 30px)" }}>
<div style={{ color: "#9a6638", fontSize: 12, letterSpacing: "0.14em", textTransform: "uppercase", fontWeight: 800 }}>Inscription membre</div>
<h2 className="font-display" style={{ color: "#26201a", fontSize: 28, margin: "8px 0 20px" }}>
{existing.length > 0 ? "Modifier les participants" : "Inscrire les participants"}
</h2>
{!session?.user || !memberId ? (
<div>
<p style={{ color: "#6c6150", lineHeight: 1.7 }}>Connectez-vous avec votre compte adhérent avant de choisir les participants.</p>
<Link href="/login" style={{ display: "inline-block", background: "#13324F", color: "#fff", borderRadius: 8, padding: "13px 20px", fontWeight: 800, textDecoration: "none" }}>Se connecter</Link>
</div>
) : member?.status !== "active" ? (
<div style={{ background: "#fbeede", border: "1px solid #ecd8b8", color: "#9a6638", borderRadius: 8, padding: "14px 16px", lineHeight: 1.6 }}>
Votre adhésion doit être active avant de pouvoir vous inscrire aux rencontres.
</div>
) : remaining === 0 && existing.length === 0 ? (
<div style={{ background: "#f1efe7", border: "1px solid #ded8c8", color: "#6c6150", borderRadius: 8, padding: "14px 16px" }}>Cette rencontre est complète.</div>
) : (
<MeetingRegistrationForm
meetingId={meeting.id}
defaultName={session.user.name ?? member.name}
existing={existing}
maxPerAccount={meeting.participantsPerAccount}
remaining={remaining}
/>
)}
</section>
</div>
</section>
)}
</main>
<SiteFooter />
</div>
);
}
+95 -8
View File
@@ -1,17 +1,101 @@
import type { Metadata } from "next";
import type { Metadata, Viewport } from "next";
import { Analytics } from "@/components/Analytics";
import { JsonLd } from "@/components/JsonLd";
import {
INDEX_FOLLOW,
SITE_DESCRIPTION,
SITE_KEYWORDS,
SITE_LOCALE,
SITE_NAME,
SITE_TITLE,
THEME_COLOR,
organizationJsonLd,
webSiteJsonLd,
} from "@/lib/seo";
import { normalizeSiteVerification } from "@/lib/analytics";
import { metadataBase, publicBaseUrl } from "@/lib/seo-server";
import { getSiteSettings, socialLinks } from "@/lib/site-settings";
import "./globals.css";
export const metadata: Metadata = {
title: "Plein R — Commerçants & entreprises du Bassin de Pompey",
description:
"Association des commerçants et entreprises du Bassin de Pompey. Réseau · Rencontre · Réussite.",
// Rendu dynamique pour toutes les pages : la CSP à nonce du middleware ne peut
// pas signer les scripts d'un HTML pré-généré au build. Toutes les pages
// interrogent de toute façon la base.
export const dynamic = "force-dynamic";
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
themeColor: THEME_COLOR,
colorScheme: "light",
};
export default function RootLayout({
/**
* Métadonnées par défaut de tout le site. Chaque page complète ou remplace
* titre, description et canonique ; `metadataBase` rend absolues les URLs
* relatives (canonique, Open Graph, image de partage).
*/
export async function generateMetadata(): Promise<Metadata> {
const [base, settings] = await Promise.all([
metadataBase(),
getSiteSettings().catch(() => null),
]);
// Jeton « balise HTML » de Search Console, collé dans Backend › Paramètres.
const google = normalizeSiteVerification(settings?.google_site_verification);
return {
metadataBase: base,
title: {
default: SITE_TITLE,
template: `%s · ${SITE_NAME}`,
},
description: SITE_DESCRIPTION,
applicationName: SITE_NAME,
keywords: SITE_KEYWORDS,
authors: [{ name: `Association ${SITE_NAME}` }],
creator: `Association ${SITE_NAME}`,
publisher: `Association ${SITE_NAME}`,
category: "business",
referrer: "strict-origin-when-cross-origin",
// Pas de canonique ici : chaque page publique pose la sienne via
// `pageMetadata()` ; les pages privées (noindex) n'en ont pas.
openGraph: {
type: "website",
locale: SITE_LOCALE,
siteName: SITE_NAME,
title: SITE_TITLE,
description: SITE_DESCRIPTION,
url: "/",
},
twitter: {
card: "summary_large_image",
title: SITE_TITLE,
description: SITE_DESCRIPTION,
},
robots: INDEX_FOLLOW,
verification: google ? { google } : undefined,
icons: {
icon: [{ url: "/assets/logo.png", type: "image/png" }],
apple: [{ url: "/assets/logo.png", type: "image/png" }],
shortcut: ["/assets/logo.png"],
},
manifest: "/manifest.webmanifest",
};
}
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
// Données structurées communes : l'association (Organization) et le site
// (WebSite + recherche dans l'annuaire). Ne bloque jamais le rendu.
const [baseUrl, settings] = await Promise.all([
publicBaseUrl(),
getSiteSettings().catch(() => null),
]);
const structuredData = settings
? [organizationJsonLd(baseUrl, settings, socialLinks(settings)), webSiteJsonLd(baseUrl)]
: [webSiteJsonLd(baseUrl)];
return (
<html lang="fr">
<head>
@@ -25,13 +109,16 @@ export default function RootLayout({
href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,400;12..96,600;12..96,700;12..96,800&family=Public+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap"
rel="stylesheet"
/>
<link rel="icon" href="/assets/logo.png" type="image/png" />
{/* Fallback: if JS is disabled, never keep revealed content hidden. */}
<noscript>
<style>{`.reveal,.reveal-stagger>*{opacity:1!important}`}</style>
</noscript>
</head>
<body>{children}</body>
<body>
{children}
<JsonLd data={structuredData} />
<Analytics measurementId={settings?.google_analytics_id ?? ""} />
</body>
</html>
);
}
+12
View File
@@ -0,0 +1,12 @@
import type { Metadata } from "next";
import { NOINDEX } from "@/lib/seo";
// La page de connexion est un composant client : ses métadonnées vivent ici.
export const metadata: Metadata = {
title: "Connexion à l'espace adhérent",
robots: NOINDEX,
};
export default function LoginLayout({ children }: { children: React.ReactNode }) {
return children;
}
+17
View File
@@ -0,0 +1,17 @@
import type { MetadataRoute } from "next";
import { SITE_DESCRIPTION, SITE_NAME, SITE_TITLE, THEME_COLOR } from "@/lib/seo";
/** Manifeste web : nom, icône et couleurs, pour l'ajout à l'écran d'accueil. */
export default function manifest(): MetadataRoute.Manifest {
return {
name: SITE_TITLE,
short_name: SITE_NAME,
description: SITE_DESCRIPTION,
lang: "fr",
start_url: "/",
display: "standalone",
background_color: "#F6F2E8",
theme_color: THEME_COLOR,
icons: [{ src: "/assets/logo.png", sizes: "any", type: "image/png" }],
};
}
+60
View File
@@ -0,0 +1,60 @@
import type { Metadata } from "next";
import { LegalPage } from "@/components/LegalPage";
import { getSiteSettings } from "@/lib/site-settings";
import { pageMetadata } from "@/lib/seo";
export const metadata: Metadata = pageMetadata({
title: "Mentions légales",
description: "Mentions légales du site de l'association Plein R : éditeur, hébergeur, propriété intellectuelle.",
path: "/mentions-legales",
});
export const dynamic = "force-dynamic";
function value(input: string, fallback = "à renseigner") {
return input || fallback;
}
export default async function MentionsLegalesPage() {
const settings = await getSiteSettings();
return (
<LegalPage title="Mentions légales" intro={`Informations relatives à l’édition et à l’utilisation du site ${settings.association_name}.`}>
<h2>Éditeur du site</h2>
<p>
Ce site est édité par {settings.association_name}, association des commerçants et entreprises du Bassin de Pompey.
</p>
<ul>
<li>Siège social : {value(settings.association_address)}</li>
<li>RNA : {value(settings.association_rna)}</li>
<li>SIRET : {value(settings.association_siret)}</li>
<li>Direction de la publication : {value(settings.association_publication_director || settings.association_president)}</li>
</ul>
<p>
Contact : {value(settings.association_email || settings.association_contact)}{" "}
{settings.association_phone ? `- ${settings.association_phone}` : ""}
</p>
<h2>Hébergement</h2>
<ul>
<li>Hébergeur : {value(settings.association_host_name)}</li>
<li>Adresse : {value(settings.association_host_address)}</li>
<li>Téléphone : {value(settings.association_host_phone)}</li>
</ul>
<h2>Propriété intellectuelle</h2>
<p>
Structure, textes, éléments graphiques et identité visuelle du site sont protégés. Toute reproduction,
adaptation ou diffusion sans autorisation préalable de Plein R est interdite, hors exceptions prévues par la loi.
</p>
<p>Logos, images et contenus publiés par les adhérents restent la propriété de leurs titulaires respectifs.</p>
<h2>Responsabilité</h2>
<p>
Plein R veille à la qualité des informations publiées, sans pouvoir garantir leur exhaustivité permanente.
Les offres, coordonnées et contenus des adhérents relèvent de la responsabilité de leurs auteurs.
</p>
{settings.legal_updated && <p>Dernière mise à jour : {settings.legal_updated}</p>}
</LegalPage>
);
}
+48
View File
@@ -0,0 +1,48 @@
import type { Metadata } from "next";
import Link from "next/link";
import { SiteFooter } from "@/components/SiteFooter";
import { SiteHeader } from "@/components/SiteHeader";
import { NOINDEX } from "@/lib/seo";
export const metadata: Metadata = {
title: "Page introuvable",
robots: NOINDEX,
};
/** 404 publique : renvoie vers l'annuaire plutôt que vers une page blanche. */
export default function NotFound() {
return (
<div style={{ background: "#F6F2E8", minHeight: "100vh", fontFamily: "'Public Sans',sans-serif", color: "#33291D" }}>
<SiteHeader logo />
<main className="container" style={{ paddingTop: 48, paddingBottom: 72, maxWidth: 720 }}>
<div style={{ color: "#9a6638", fontSize: 12, letterSpacing: "0.14em", textTransform: "uppercase", fontWeight: 800, marginBottom: 12 }}>
Erreur 404
</div>
<h1 className="font-display" style={{ margin: "0 0 14px", color: "#26201a", fontSize: "clamp(30px, 6vw, 48px)", lineHeight: 1.05, letterSpacing: "-0.02em" }}>
Cette page n&apos;existe pas ou plus
</h1>
<p style={{ fontSize: 17, lineHeight: 1.6, color: "#6c6150", margin: "0 0 26px" }}>
La fiche ou la page demandée a peut-être été retirée. Retrouvez tous les commerçants et
entreprises du Bassin de Pompey dans l&apos;annuaire.
</p>
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
<Link
href="/annuaire"
className="font-display"
style={{ textDecoration: "none", background: "#E0A63C", color: "#33291D", fontWeight: 700, fontSize: 15.5, padding: "13px 24px", borderRadius: 12 }}
>
Voir l&apos;annuaire
</Link>
<Link
href="/"
className="font-display"
style={{ textDecoration: "none", background: "#fff", border: "1px solid #e6dcc6", color: "#33291D", fontWeight: 700, fontSize: 15.5, padding: "13px 24px", borderRadius: 12 }}
>
Retour à l&apos;accueil
</Link>
</div>
</main>
<SiteFooter />
</div>
);
}
+88
View File
@@ -0,0 +1,88 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { ImageResponse } from "next/og";
import { SITE_NAME } from "@/lib/seo";
/**
* Image de partage (Open Graph / Twitter) générée à la volée, 1200×630.
* Sert de vignette par défaut à toutes les pages ; les fiches adhérents la
* reprennent, leurs logos étant stockés en data-URI (illisibles par les
* robots de prévisualisation).
*/
export const alt = "Plein R — Commerçants & entreprises du Bassin de Pompey";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
/** Logo servi depuis `public/` (copié dans l'image standalone), en data-URI pour Satori. */
async function logoDataUri(): Promise<string | null> {
try {
const file = await readFile(path.join(process.cwd(), "public", "assets", "logo.png"));
return `data:image/png;base64,${file.toString("base64")}`;
} catch {
return null;
}
}
export default async function OpenGraphImage() {
const logo = await logoDataUri();
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
padding: 72,
background: "linear-gradient(135deg, #F6F2E8 0%, #efe9da 100%)",
color: "#26201a",
fontFamily: "sans-serif",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 24 }}>
{logo ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={logo} alt="" width={120} height={120} style={{ objectFit: "contain" }} />
) : (
<div
style={{
width: 84,
height: 84,
borderRadius: 24,
background: "#E0A63C",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#26201a",
fontSize: 48,
fontWeight: 800,
}}
>
R
</div>
)}
<div style={{ fontSize: 40, fontWeight: 800, letterSpacing: -1 }}>{SITE_NAME}</div>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 18 }}>
<div style={{ fontSize: 26, letterSpacing: 6, textTransform: "uppercase", color: "#9a6638", fontWeight: 700 }}>
Réseau · Rencontre · Réussite
</div>
<div style={{ fontSize: 72, fontWeight: 800, lineHeight: 1.02, letterSpacing: -2, display: "flex", flexWrap: "wrap" }}>
Le cœur commerçant du&nbsp;
<span style={{ color: "#2C6FB3" }}>Bassin de Pompey</span>
</div>
<div style={{ fontSize: 30, color: "#6c6150", marginTop: 8 }}>
Annuaire des commerçants, artisans et entreprises · Bons plans · Rencontres
</div>
</div>
<div style={{ display: "flex", gap: 14 }}>
{["#E0A63C", "#2C6FB3", "#d8472b", "#1f8a5b"].map((c) => (
<div key={c} style={{ width: 64, height: 10, borderRadius: 999, background: c }} />
))}
</div>
</div>
),
{ ...size }
);
}
+127 -105
View File
@@ -1,3 +1,4 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Sparkle } from "@/components/Sparkle";
import { Reveal } from "@/components/Reveal";
@@ -6,24 +7,33 @@ 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 { getLivePromotions, getRotatingActiveMembers } from "@/lib/queries";
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, pageMetadata } from "@/lib/seo";
export const dynamic = "force-dynamic";
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 const metadata: Metadata = {
...pageMetadata({
title: SITE_TITLE,
ogTitle: SITE_TITLE,
description: SITE_DESCRIPTION,
path: "/",
}),
// Titre absolu : la page d'accueil ne prend pas le suffixe « · Plein R ».
title: { absolute: SITE_TITLE },
};
export default async function AccueilPage() {
const [promos, rotatingMembers] = await Promise.all([
const [promos, rotatingMembers, settings, memberCount] = await Promise.all([
getLivePromotions(6),
getRotatingActiveMembers(),
getSiteSettings(),
getActiveMemberCount(),
]);
const socials = socialLinks(settings);
return (
<div
@@ -36,7 +46,7 @@ export default async function AccueilPage() {
>
<SiteHeader active="accueil" />
<div className="container">
<main className="container">
{/* hero */}
<section
className="grid hero-grid"
@@ -48,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%",
@@ -97,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 &amp; entreprises du Bassin de Pompey
Association des commerçants &amp; 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
@@ -208,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 ? `/adherents/${p.memberId}` : "#"}
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&apos;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>
@@ -318,6 +243,102 @@ export default async function AccueilPage() {
<MembersCarousel members={rotatingMembers} />
</section>
{/* réseaux sociaux */}
{socials.length > 0 && (
<section
style={{
position: "relative",
margin: "0 0 46px",
background: "#fff",
border: "1px solid #e6dcc6",
borderRadius: 24,
padding: "34px 36px 36px",
overflow: "hidden",
boxShadow: "0 26px 60px -40px rgba(40,30,15,0.5)",
}}
>
<Sparkle color="#6FB0C6" size={18} style={{ top: 26, right: 42 }} duration={3.1} />
<div
style={{
display: "inline-flex",
alignItems: "center",
gap: 8,
background: "#eaf0f6",
color: "#2C6FB3",
fontSize: 12,
letterSpacing: "0.12em",
textTransform: "uppercase",
fontWeight: 800,
padding: "6px 13px",
borderRadius: 999,
marginBottom: 12,
}}
>
<span style={{ width: 8, height: 8, borderRadius: "50%", background: "#2C6FB3" }} />
Suivez-nous
</div>
<h2
className="font-display"
style={{ fontWeight: 800, fontSize: "clamp(22px, 5vw, 30px)", letterSpacing: "-0.02em", margin: 0, color: "#26201a" }}
>
Plein R sur les réseaux
</h2>
<p style={{ margin: "7px 0 0", color: "#8c8068", fontSize: 15, maxWidth: 560 }}>
Bons plans, rencontres et actualités des commerçants du Bassin de Pompey : retrouvez-nous
sur Facebook et LinkedIn.
</p>
<div className="grid grid-2" style={{ gap: 16, marginTop: 24 }}>
{socials.map((s) => (
<a
key={s.network}
href={s.href}
target="_blank"
rel="noopener noreferrer"
className="lift-card"
style={{
display: "flex",
alignItems: "center",
gap: 14,
textDecoration: "none",
color: "inherit",
background: "#faf7ef",
border: "1px solid #f0e8d6",
borderRadius: 16,
padding: "16px 18px",
}}
>
<span
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 44,
height: 44,
borderRadius: 13,
background: SOCIAL_BRAND[s.network],
color: "#fff",
flexShrink: 0,
}}
>
<SocialIcon network={s.network} size={22} />
</span>
<span style={{ minWidth: 0, flex: 1 }}>
<span className="font-display" style={{ display: "block", fontWeight: 700, fontSize: 16, color: "#26201a" }}>
{s.label}
</span>
<span style={{ display: "block", fontSize: 12.5, color: "#8c8068", marginTop: 2, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{s.handle}
</span>
</span>
<span style={{ color: "#2C6FB3", fontWeight: 700, fontSize: 14, flexShrink: 0 }}>↗</span>
</a>
))}
</div>
</section>
)}
{/* warm CTA */}
<section
style={{
@@ -337,8 +358,9 @@ export default async function AccueilPage() {
Faites partie de l&apos;aventure Plein R
</h2>
<p style={{ margin: "0 auto 22px", fontSize: 15.5, color: "#f4e6d3", maxWidth: 480 }}>
Rejoignez plus de 120 commerçants et entreprises qui font réseau, se rencontrent et réussissent
ensemble.
{memberCount > 0
? `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"
@@ -356,7 +378,7 @@ export default async function AccueilPage() {
/>
</Reveal>
</section>
</div>
</main>
<SiteFooter />
</div>
+143
View File
@@ -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&apos;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>
);
}
+101
View File
@@ -0,0 +1,101 @@
import type { Metadata } from "next";
import { asc, desc, inArray } from "drizzle-orm";
import { JsonLd } from "@/components/JsonLd";
import { PastMeetingGallery } from "@/components/PastMeetingGallery";
import { SiteFooter } from "@/components/SiteFooter";
import { SiteHeader } from "@/components/SiteHeader";
import { db } from "@/db";
import { meetingRegistrations, pastMeetingPhotos, pastMeetings } from "@/db/schema";
import { splitLines } from "@/lib/site-settings";
import { breadcrumbJsonLd, pageMetadata } from "@/lib/seo";
import { publicBaseUrl } from "@/lib/seo-server";
export const dynamic = "force-dynamic";
export const metadata: Metadata = pageMetadata({
title: "Rencontres passées de l'association",
description:
"Retour en images sur les rencontres Plein R : soirées réseau, visites d'entreprises et moments " +
"partagés entre entrepreneurs du Bassin de Pompey.",
path: "/rencontres-passees",
});
function formatDate(date: Date) {
return new Intl.DateTimeFormat("fr-FR", { day: "numeric", month: "long", year: "numeric" }).format(date);
}
export default async function PastMeetingsPage() {
const [archives, photos, baseUrl] = await Promise.all([
db.select().from(pastMeetings).orderBy(desc(pastMeetings.eventDate)),
db.select().from(pastMeetingPhotos).orderBy(asc(pastMeetingPhotos.position), asc(pastMeetingPhotos.id)),
publicBaseUrl(),
]);
const linkedIds = archives.map((archive) => archive.meetingId).filter((id): id is number => id !== null);
const linkedRegistrations = linkedIds.length > 0
? await db
.select({ meetingId: meetingRegistrations.meetingId, id: meetingRegistrations.id })
.from(meetingRegistrations)
.where(inArray(meetingRegistrations.meetingId, linkedIds))
: [];
const photosByPast = new Map<number, typeof photos>();
for (const photo of photos) {
photosByPast.set(photo.pastMeetingId, [...(photosByPast.get(photo.pastMeetingId) ?? []), photo]);
}
const registrationCounts = new Map<number, number>();
for (const registration of linkedRegistrations) {
registrationCounts.set(registration.meetingId, (registrationCounts.get(registration.meetingId) ?? 0) + 1);
}
return (
<div style={{ background: "#F6F2E8", minHeight: "100vh", fontFamily: "'Public Sans',sans-serif", color: "#33291D" }}>
<SiteHeader active="association" logo />
<JsonLd
data={breadcrumbJsonLd(baseUrl, [
{ name: "Accueil", path: "/" },
{ name: "L'association", path: "/association" },
{ name: "Rencontres passées", path: "/rencontres-passees" },
])}
/>
<main>
<section style={{ background: "#13324F", color: "#fff" }}>
<div className="container" style={{ paddingTop: 58, paddingBottom: 54 }}>
<div style={{ color: "#E0A63C", fontSize: 12, letterSpacing: "0.14em", textTransform: "uppercase", fontWeight: 800 }}>Ils y étaient</div>
<h1 className="font-display" style={{ margin: "10px 0 14px", fontSize: "clamp(38px, 7vw, 62px)", lineHeight: 1 }}>Toutes nos rencontres passées</h1>
<p style={{ maxWidth: 680, color: "#cfe0ee", fontSize: 17, lineHeight: 1.7, margin: 0 }}>
Revivez en images les temps forts de Plein R et les moments partagés par les acteurs économiques du Bassin de Pompey.
</p>
</div>
</section>
<section className="container" style={{ paddingTop: 54, paddingBottom: 80 }}>
{archives.length === 0 ? (
<div style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 34, color: "#8c8068", textAlign: "center" }}>Aucune rencontre passée publiée pour le moment.</div>
) : (
<div className="grid grid-3" style={{ gap: 20 }}>
{archives.map((archive) => {
const archivePhotos = photosByPast.get(archive.id) ?? [];
const participantCount = archive.meetingId
? registrationCounts.get(archive.meetingId) ?? 0
: splitLines(archive.participants ?? "").length;
return (
<article key={archive.id} style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 18 }}>
<div style={{ color: "#9a6638", fontSize: 12.5, fontWeight: 800 }}>{formatDate(archive.eventDate)}</div>
<h2 className="font-display" style={{ color: "#26201a", fontSize: 23, margin: "7px 0" }}>{archive.title}</h2>
{archive.location && <div style={{ color: "#6c6150", fontSize: 13, fontWeight: 700 }}>{archive.location}</div>}
{archive.description && <p style={{ color: "#8c8068", fontSize: 13.5, lineHeight: 1.65 }}>{archive.description}</p>}
<div style={{ color: "#6c6150", fontSize: 12.5, fontWeight: 800, marginBottom: 12 }}>
{participantCount} participant{participantCount > 1 ? "s" : ""} · {archivePhotos.length} photo{archivePhotos.length > 1 ? "s" : ""}
</div>
<PastMeetingGallery title={archive.title} photos={archivePhotos} />
</article>
);
})}
</div>
)}
</section>
</main>
<SiteFooter />
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
import type { MetadataRoute } from "next";
import { PRIVATE_PATHS, absoluteUrl } from "@/lib/seo";
import { publicBaseUrl } from "@/lib/seo-server";
export const dynamic = "force-dynamic";
/** /robots.txt : tout le site public est explorable, l'espace privé exclu. */
export default async function robots(): Promise<MetadataRoute.Robots> {
const base = await publicBaseUrl();
return {
rules: [
{
userAgent: "*",
allow: "/",
disallow: PRIVATE_PATHS,
},
],
sitemap: base ? absoluteUrl(base, "/sitemap.xml") : undefined,
};
}
+53
View File
@@ -0,0 +1,53 @@
import type { MetadataRoute } from "next";
import { asc, eq } from "drizzle-orm";
import { db } from "@/db";
import { members } from "@/db/schema";
import { getCategoriesInUse } from "@/lib/queries";
import { STATIC_ROUTES, absoluteUrl, categoryPath, memberPath } from "@/lib/seo";
import { publicBaseUrl } from "@/lib/seo-server";
export const dynamic = "force-dynamic";
/** /sitemap.xml : pages publiques + une entrée par adhérent actif. */
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const base = await publicBaseUrl();
// Pas de `lastModified` : sans date de mise à jour fiable en base, mieux vaut
// l'omettre qu'annoncer une valeur que les moteurs finiraient par ignorer.
const statics: MetadataRoute.Sitemap = STATIC_ROUTES.map((route) => ({
url: absoluteUrl(base, route.path),
changeFrequency: route.changeFrequency,
priority: route.priority,
}));
let rows: { id: number; name: string; city: string | null }[] = [];
let categoriesInUse: { slug: string }[] = [];
try {
[rows, categoriesInUse] = await Promise.all([
db
.select({ id: members.id, name: members.name, city: members.city })
.from(members)
.where(eq(members.status, "active"))
.orderBy(asc(members.id)),
getCategoriesInUse(),
]);
} catch {
// Base indisponible : on livre au moins les pages statiques.
}
// Pages métier : seulement celles qui ont au moins un adhérent (les autres
// sont en noindex, une page vide n'apporte rien aux moteurs).
const metiers: MetadataRoute.Sitemap = categoriesInUse.map((c) => ({
url: absoluteUrl(base, categoryPath(c.slug)),
changeFrequency: "weekly",
priority: 0.8,
}));
const fiches: MetadataRoute.Sitemap = rows.map((m) => ({
url: absoluteUrl(base, memberPath(m)),
changeFrequency: "weekly",
priority: 0.7,
}));
return [...statics, ...metiers, ...fiches];
}
+7
View File
@@ -13,6 +13,10 @@ export const authConfig = {
},
session: {
strategy: "jwt",
// 7 jours et non 30 : un jeton dérobé sur un poste partagé vaut d'autant
// moins longtemps. La révocation immédiate reste assurée par
// `users.session_version` (src/lib/session.ts).
maxAge: 7 * 24 * 60 * 60,
},
trustHost: true,
callbacks: {
@@ -23,6 +27,7 @@ export const authConfig = {
token.memberId = (user as { memberId?: number | null }).memberId ?? null;
token.mustChangePassword =
(user as { mustChangePassword?: boolean }).mustChangePassword ?? false;
token.sessionVersion = (user as { sessionVersion?: number }).sessionVersion ?? 0;
}
return token;
},
@@ -36,6 +41,8 @@ export const authConfig = {
| "member";
session.user.memberId = (token.memberId as number | null) ?? null;
session.user.mustChangePassword = Boolean(token.mustChangePassword);
// Exposé pour la revalidation en base (src/lib/session.ts).
session.sessionVersion = Number(token.sessionVersion ?? 0);
}
return session;
},
+19 -3
View File
@@ -5,6 +5,11 @@ import { eq } from "drizzle-orm";
import { db } from "@/db";
import { users } from "@/db/schema";
import { authConfig } from "@/auth.config";
import { clearLoginFailures, isLoginBlocked, recordLoginFailure } from "@/lib/login-throttle";
// Empreinte bcrypt d'une valeur quelconque : sert de comparaison factice quand
// l'adresse n'existe pas, pour que le temps de réponse reste identique.
const ABSENT_USER_HASH = "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy";
export const { handlers, auth, signIn, signOut } = NextAuth({
...authConfig,
@@ -21,12 +26,22 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
const password = String(credentials?.password ?? "");
if (!email || !password) return null;
if (isLoginBlocked(email)) return null;
const rows = await db.select().from(users).where(eq(users.email, email));
const user = rows[0];
if (!user) return null;
const valid = await bcrypt.compare(password, user.passwordHash);
if (!valid) return null;
// Comparaison menée même sans compte correspondant : sans cela, le
// temps de réponse révélerait quelles adresses existent.
const hash = user?.passwordHash ?? ABSENT_USER_HASH;
const valid = await bcrypt.compare(password, hash);
if (!user || !valid) {
recordLoginFailure(email);
return null;
}
clearLoginFailures(email);
return {
id: String(user.id),
@@ -35,6 +50,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
role: user.role,
memberId: user.memberId,
mustChangePassword: user.mustChangePassword,
sessionVersion: user.sessionVersion ?? 0,
};
},
}),
Loaded 100 of 192 files, more files were not shown because too many files have changed in this diff. Show more