mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-22 14:48:04 +02:00
Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e340dde58 | ||
|
|
2da125eca2 | ||
|
|
cc33cf5fbc | ||
|
|
90b121a3c1 | ||
|
|
b860eeaf29 | ||
|
|
7e7d307cdd | ||
|
|
39eb8cfc13 | ||
|
|
0c8542f894 | ||
|
|
158958a8f4 | ||
|
|
877b0ece78 | ||
|
|
a7ac27e530 | ||
|
|
8d4443a1a1 | ||
|
|
d57e544a18 | ||
|
|
1701fc2834 | ||
|
|
3df3e55974 | ||
|
|
67cd9d6684 | ||
|
|
fb09bf0e3a | ||
|
|
7d72016bfb | ||
|
|
e59ae3a908 | ||
|
|
146250f973 | ||
|
|
dd16e05b47 | ||
|
|
e9b6fdfd91 | ||
|
|
503adcb20e | ||
|
|
50b8732080 | ||
|
|
4b12dc5164 | ||
|
|
353d38bebf | ||
|
|
a52e13e623 | ||
|
|
cf8cf5c8e2 | ||
|
|
d99ee7ec07 | ||
|
|
e980ab6e04 | ||
|
|
02c5378a47 | ||
|
|
81b99ed5fb | ||
|
|
17511a73c3 | ||
|
|
23d1d14a49 | ||
|
|
8b46cd6316 | ||
|
|
bfc82b3b3b | ||
|
|
c8417d8560 | ||
|
|
f9d0d6c825 | ||
|
|
320e63b749 | ||
|
|
8414c7201e | ||
|
|
0562d07254 | ||
|
|
a8adb9ae6a | ||
|
|
dd350038f0 |
No files matched your search
+34
-3
@@ -39,9 +39,14 @@ SEED_DEMO=false
|
|||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
PORT=3000
|
PORT=3000
|
||||||
|
|
||||||
# URL publique du site. Se règle normalement dans Backend › Réseaux sociaux ;
|
# ---- VPS sans reverse proxy existant (voir README) ----
|
||||||
# cette variable ne sert que de repli (puis AUTH_URL en dernier recours).
|
# Caddy devant l'application : ports 80 et 443, certificat Let's Encrypt
|
||||||
NEXT_PUBLIC_SITE_URL=
|
# 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 ----
|
# ---- Publication des promotions sur les réseaux sociaux ----
|
||||||
# La configuration se fait désormais dans Backend › Réseaux sociaux : on y colle
|
# La configuration se fait désormais dans Backend › Réseaux sociaux : on y colle
|
||||||
@@ -67,3 +72,29 @@ FACEBOOK_PAGE_ACCESS_TOKEN=
|
|||||||
LINKEDIN_ORGANIZATION_URN=
|
LINKEDIN_ORGANIZATION_URN=
|
||||||
LINKEDIN_ORGANIZATION_ID=
|
LINKEDIN_ORGANIZATION_ID=
|
||||||
LINKEDIN_ACCESS_TOKEN=
|
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=
|
||||||
@@ -41,14 +41,77 @@ docker compose up --build # full stack
|
|||||||
- **Data reads** for the public site live in `src/lib/queries.ts`.
|
- **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
|
- After changing `src/db/schema.ts`, run `npm run db:generate` and commit the new
|
||||||
file under `drizzle/`.
|
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
|
## Promotions
|
||||||
|
|
||||||
Statuts : `pending` → `live` → `suspended` ⇄ `live` (+ `rejected` / `expired`).
|
Statuts : `pending` → (`scheduled` →) `live` → `suspended` ⇄ `live`
|
||||||
|
(+ `rejected` / `expired`).
|
||||||
`promotions.suspended_by` retient qui a suspendu (`member` ou `staff`) : une
|
`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
|
suspension par le staff ne peut être levée que par le staff. Les lectures
|
||||||
publiques filtrent sur `status = 'live'`, donc une promo suspendue disparaît du
|
publiques filtrent sur le statut `live`, donc une promo suspendue — ou encore
|
||||||
site sans traitement supplémentaire.
|
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
|
## Réseaux sociaux
|
||||||
|
|
||||||
@@ -68,24 +131,51 @@ site sans traitement supplémentaire.
|
|||||||
anti-CSRF passe par un cookie httpOnly ; aucun jeton ne transite par une URL.
|
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
|
- Facebook : le jeton de page n'expire pas. LinkedIn : 60 jours, rafraîchissement
|
||||||
programmatique réservé à certains partenaires, d'où le bandeau de reconnexion.
|
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
|
- 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
|
binaire (multipart pour Facebook, Images API en 3 étapes pour LinkedIn), pas
|
||||||
par URL.
|
par URL.
|
||||||
- La diffusion est déclenchée **par la validation**, pas par un bouton :
|
- L'image part **telle quelle** : aucun recadrage ni redimensionnement côté
|
||||||
|
application. Un visuel non carré est donc recadré — ou entouré de bandes de
|
||||||
|
couleur — par la plateforme. `src/lib/image-info.ts` (pur, verrouillé par
|
||||||
|
`tests/image-info.test.ts`) décrit le fichier déposé (dimensions, format,
|
||||||
|
poids) et `MemberSpaceForm` l'affiche sous l'aperçu : on informe, on ne
|
||||||
|
bloque pas.
|
||||||
|
- La diffusion est déclenchée **par la validation** (ou par l'échéance d'une
|
||||||
|
publication programmée), pas par un bouton :
|
||||||
`promotions.share_facebook` / `share_linkedin` sont choisis par l'adhérent,
|
`promotions.share_facebook` / `share_linkedin` sont choisis par l'adhérent,
|
||||||
ajustables par le modérateur dans le formulaire « Valider », puis figés
|
ajustables par le modérateur dans le formulaire « Valider », puis figés
|
||||||
(`status !== 'pending'`).
|
(`status !== 'pending'`).
|
||||||
- `publishPromoShares()` dans `backend/actions.ts` est le seul point de
|
- `publishPromoShares()` dans `src/lib/promo-publish.ts` est le seul point de
|
||||||
publication. Elle ne lève jamais et ignore tout réseau ayant déjà une ligne
|
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
|
`social_posts` en `posted` : c'est la garde anti-republication, qui couvre
|
||||||
aussi le cycle suspension → remise en ligne.
|
aussi le cycle suspension → remise en ligne.
|
||||||
- `retryPromoShare` ne sert qu'au rattrapage d'un échec sur un réseau déjà
|
- `retryPromoShare` ne sert qu'au rattrapage d'un échec sur un réseau déjà
|
||||||
choisi ; il ne peut pas élargir la diffusion.
|
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`
|
- Les URLs publiques des pages FB/LinkedIn sont des `site_settings`
|
||||||
(`association_facebook`, `association_linkedin`), éditables dans Paramètres.
|
(`association_facebook`, `association_linkedin`), éditables dans Paramètres.
|
||||||
|
|
||||||
## Sécurité
|
## 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
|
- Le journal d'activité agrège des saisies de tiers, dont le formulaire de
|
||||||
contact **public** : il est filtré à l'écriture (`sanitizeActivityMessage`) et
|
contact **public** : il est filtré à l'écriture (`sanitizeActivityMessage`) et
|
||||||
rendu en éléments React (`activityNodes`), jamais en HTML brut.
|
rendu en éléments React (`activityNodes`), jamais en HTML brut.
|
||||||
@@ -112,13 +202,240 @@ site sans traitement supplémentaire.
|
|||||||
- Sessions JWT limitées à 7 jours (`auth.config.ts`), HSTS et suppression de
|
- 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`
|
`X-Powered-By` dans `next.config.mjs`. Le port Postgres de `docker-compose`
|
||||||
n'est publié que sur `127.0.0.1`.
|
n'est publié que sur `127.0.0.1`.
|
||||||
- `npm test` verrouille ces protections (`tests/security.test.ts`).
|
- `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
|
## Roles
|
||||||
|
|
||||||
`admin` > `moderator` > `editor` are staff; `member` is an adhérent linked to a
|
`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`.
|
`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
|
## Docker notes
|
||||||
|
|
||||||
- Migrations + optional seed run on container start via `docker-entrypoint.sh`.
|
- Migrations + optional seed run on container start via `docker-entrypoint.sh`.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -45,6 +45,57 @@ charge des données de démonstration. Ensuite :
|
|||||||
> **Postgres déjà existant ?** Supprimez le service `postgres` de `docker-compose.yml`
|
> **Postgres déjà existant ?** Supprimez le service `postgres` de `docker-compose.yml`
|
||||||
> et pointez `DATABASE_URL` vers votre instance.
|
> 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)
|
## Comptes de démonstration (seed)
|
||||||
|
|
||||||
| E-mail | Mot de passe | Rôle |
|
| E-mail | Mot de passe | Rôle |
|
||||||
@@ -63,8 +114,10 @@ charge des données de démonstration. Ensuite :
|
|||||||
| Adhérents (CRUD) | ✅ | ✅ | ✅ | — |
|
| Adhérents (CRUD) | ✅ | ✅ | ✅ | — |
|
||||||
| Modération des promotions | ✅ | ✅ | — | — |
|
| Modération des promotions | ✅ | ✅ | — | — |
|
||||||
| Publication réseaux sociaux | ✅ | ✅ | — | — |
|
| Publication réseaux sociaux | ✅ | ✅ | — | — |
|
||||||
|
| Informations adhérents | ✅ | ✅ | — | — |
|
||||||
|
| E-mails & boîte mail | ✅ | — | — | — |
|
||||||
| Administrateurs | ✅ | — | — | — |
|
| 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.
|
`/backend` est protégé par le middleware ; chaque vue affine l'accès selon le rôle.
|
||||||
|
|
||||||
@@ -166,7 +219,7 @@ npm run dev # http://localhost:3000
|
|||||||
| `npm run db:generate` | Génère les migrations Drizzle |
|
| `npm run db:generate` | Génère les migrations Drizzle |
|
||||||
| `npm run db:migrate` | Applique les migrations |
|
| `npm run db:migrate` | Applique les migrations |
|
||||||
| `npm run db:seed` | Insère les données de démonstration |
|
| `npm run db:seed` | Insère les données de démonstration |
|
||||||
| `npm test` | Tests de sécurité (filtre XSS, limitation de connexion, chiffrement) |
|
| `npm test` | Tests unitaires et de sécurité (XSS, chiffrement, MIME, file d'envoi) |
|
||||||
|
|
||||||
## Variables d'environnement
|
## Variables d'environnement
|
||||||
|
|
||||||
@@ -179,10 +232,51 @@ Voir [`.env.example`](./.env.example). Les principales :
|
|||||||
- `SEED_ADMIN_EMAIL` / `SEED_ADMIN_PASSWORD` / `SEED_ADMIN_NAME` — premier admin
|
- `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
|
- `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.
|
normalement dans **Backend › Réseaux sociaux**, aucune variable n'est requise.
|
||||||
- `SOCIAL_TOKEN_KEY` — clé de chiffrement des jetons réseaux (défaut : `AUTH_SECRET`)
|
- `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` /
|
- `FACEBOOK_PAGE_ID` / `FACEBOOK_PAGE_ACCESS_TOKEN`, `LINKEDIN_ORGANIZATION_URN` /
|
||||||
`LINKEDIN_ACCESS_TOKEN` — repli si aucun compte n'est connecté via le backoffice
|
`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
|
## Note sur le logo
|
||||||
|
|
||||||
Le logo (`public/assets/logo.svg`) est une recréation vectorielle aux couleurs de
|
Le logo (`public/assets/logo.svg`) est une recréation vectorielle aux couleurs de
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -93,5 +93,6 @@ networks:
|
|||||||
proxy:
|
proxy:
|
||||||
# The reverse proxy's existing network. Must already exist (created by the
|
# The reverse proxy's existing network. Must already exist (created by the
|
||||||
# NPM/nginx stack). Override the name with PROXY_NETWORK if it differs.
|
# NPM/nginx stack). Override the name with PROXY_NETWORK if it differs.
|
||||||
|
# Sans proxy existant : voir docker-compose.caddy.yml (README).
|
||||||
external: true
|
external: true
|
||||||
name: ${PROXY_NETWORK:-nginx_default}
|
name: ${PROXY_NETWORK:-nginx_default}
|
||||||
@@ -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";
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "members" ADD COLUMN "contact_email" varchar(200);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE "social_accounts" ADD COLUMN "last_check_at" timestamp with time zone;--> statement-breakpoint
|
||||||
|
ALTER TABLE "social_accounts" ADD COLUMN "last_check_ok" boolean;--> statement-breakpoint
|
||||||
|
ALTER TABLE "social_accounts" ADD COLUMN "last_check_error" text;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "promotions" ADD COLUMN "starts_on" date;--> statement-breakpoint
|
||||||
|
ALTER TABLE "promotions" ADD COLUMN "ends_on" date;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TYPE "public"."promo_status" ADD VALUE 'scheduled';--> statement-breakpoint
|
||||||
|
ALTER TABLE "promotions" ADD COLUMN "publish_at" timestamp with time zone;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE "members" ADD COLUMN "contact_first_name" varchar(120);--> statement-breakpoint
|
||||||
|
ALTER TABLE "members" ADD COLUMN "contact_last_name" varchar(120);--> statement-breakpoint
|
||||||
|
ALTER TABLE "members" ADD COLUMN "contact_phone" varchar(40);
|
||||||
@@ -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");
|
||||||
@@ -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
@@ -85,6 +85,62 @@
|
|||||||
"when": 1788512273181,
|
"when": 1788512273181,
|
||||||
"tag": "0011_drop_temp_password",
|
"tag": "0011_drop_temp_password",
|
||||||
"breakpoints": true
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -4,10 +4,27 @@ const nextConfig = {
|
|||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
// Pas d'en-tête X-Powered-By : inutile de renseigner un attaquant sur la pile.
|
// Pas d'en-tête X-Powered-By : inutile de renseigner un attaquant sur la pile.
|
||||||
poweredByHeader: false,
|
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: {
|
experimental: {
|
||||||
// Les images d'entête/logo sont envoyées en data-URL via server action.
|
// Les images d'entête/logo sont envoyées en data-URL via server action.
|
||||||
serverActions: { bodySizeLimit: "4mb" },
|
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() {
|
async headers() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
Generated
+21
@@ -13,6 +13,7 @@
|
|||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"next": "^15.5.25",
|
"next": "^15.5.25",
|
||||||
"next-auth": "^5.0.0-beta.32",
|
"next-auth": "^5.0.0-beta.32",
|
||||||
|
"nodemailer": "^8.0.11",
|
||||||
"pg": "^8.23.0",
|
"pg": "^8.23.0",
|
||||||
"react": "19.0.0",
|
"react": "19.0.0",
|
||||||
"react-dom": "19.0.0",
|
"react-dom": "19.0.0",
|
||||||
@@ -21,6 +22,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/node": "^22.10.5",
|
"@types/node": "^22.10.5",
|
||||||
|
"@types/nodemailer": "^8.0.1",
|
||||||
"@types/pg": "^8.11.10",
|
"@types/pg": "^8.11.10",
|
||||||
"@types/react": "^19.0.7",
|
"@types/react": "^19.0.7",
|
||||||
"@types/react-dom": "^19.0.3",
|
"@types/react-dom": "^19.0.3",
|
||||||
@@ -1220,6 +1222,16 @@
|
|||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/nodemailer": {
|
||||||
|
"version": "8.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz",
|
||||||
|
"integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/pg": {
|
"node_modules/@types/pg": {
|
||||||
"version": "8.20.0",
|
"version": "8.20.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
|
||||||
@@ -1711,6 +1723,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/nodemailer": {
|
||||||
|
"version": "8.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.11.tgz",
|
||||||
|
"integrity": "sha512-nrO/pDAUKl+wXX+lx16tDLbnm0fW6sK/x8mgohaCpg+CdCEl482bD4tCuAZk2DyliruiNTIZxRCoWkDqJEnAiA==",
|
||||||
|
"license": "MIT-0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/oauth4webapi": {
|
"node_modules/oauth4webapi": {
|
||||||
"version": "3.8.6",
|
"version": "3.8.6",
|
||||||
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.6.tgz",
|
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.6.tgz",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"next": "^15.5.25",
|
"next": "^15.5.25",
|
||||||
"next-auth": "^5.0.0-beta.32",
|
"next-auth": "^5.0.0-beta.32",
|
||||||
|
"nodemailer": "^8.0.11",
|
||||||
"pg": "^8.23.0",
|
"pg": "^8.23.0",
|
||||||
"react": "19.0.0",
|
"react": "19.0.0",
|
||||||
"react-dom": "19.0.0",
|
"react-dom": "19.0.0",
|
||||||
@@ -28,6 +29,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/node": "^22.10.5",
|
"@types/node": "^22.10.5",
|
||||||
|
"@types/nodemailer": "^8.0.1",
|
||||||
"@types/pg": "^8.11.10",
|
"@types/pg": "^8.11.10",
|
||||||
"@types/react": "^19.0.7",
|
"@types/react": "^19.0.7",
|
||||||
"@types/react-dom": "^19.0.3",
|
"@types/react-dom": "^19.0.3",
|
||||||
|
|||||||
+111
-23
@@ -1,12 +1,26 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound, permanentRedirect } from "next/navigation";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Sparkle } from "@/components/Sparkle";
|
import { Sparkle } from "@/components/Sparkle";
|
||||||
import { SiteHeader } from "@/components/SiteHeader";
|
import { SiteHeader } from "@/components/SiteHeader";
|
||||||
import { SiteFooter } from "@/components/SiteFooter";
|
import { SiteFooter } from "@/components/SiteFooter";
|
||||||
import { VitrineImage } from "@/components/VitrineImage";
|
import { VitrineImage } from "@/components/VitrineImage";
|
||||||
import { PromoImage } from "@/components/PromoImage";
|
import { PromoImage } from "@/components/PromoImage";
|
||||||
import { 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 {
|
import {
|
||||||
formatSlots,
|
formatSlots,
|
||||||
getOpenStatus,
|
getOpenStatus,
|
||||||
@@ -14,6 +28,7 @@ import {
|
|||||||
parseMemberHours,
|
parseMemberHours,
|
||||||
websiteLabel,
|
websiteLabel,
|
||||||
} from "@/lib/member-profile";
|
} from "@/lib/member-profile";
|
||||||
|
import { formatValidity } from "@/lib/promo-validity";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -86,15 +101,32 @@ function ContactAction({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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({
|
export async function generateMetadata({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
}): Promise<Metadata> {
|
}): Promise<Metadata> {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const member = await getPublicMember(Number(id));
|
const memberId = parseMemberParam(id);
|
||||||
if (!member) return { title: "Adhérent — Plein R" };
|
const member = memberId ? await getPublicMember(memberId) : null;
|
||||||
return { title: `${member.name} — Plein R`, description: member.description ?? undefined };
|
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({
|
export default async function FicheAdherentPage({
|
||||||
@@ -103,13 +135,27 @@ export default async function FicheAdherentPage({
|
|||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const memberId = Number(id);
|
const memberId = parseMemberParam(id);
|
||||||
if (!memberId) notFound();
|
if (!memberId) notFound();
|
||||||
|
|
||||||
const member = await getPublicMember(memberId);
|
const member = await getPublicMember(memberId);
|
||||||
if (!member || member.status !== "active") notFound();
|
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 accent = member.accent ?? "#E0A63C";
|
||||||
|
|
||||||
const fullAddress = [member.address, [member.postalCode, member.city].filter(Boolean).join(" ")]
|
const fullAddress = [member.address, [member.postalCode, member.city].filter(Boolean).join(" ")]
|
||||||
@@ -119,11 +165,22 @@ export default async function FicheAdherentPage({
|
|||||||
[member.address, member.postalCode, member.city].filter(Boolean).join(", ")
|
[member.address, member.postalCode, member.city].filter(Boolean).join(", ")
|
||||||
);
|
);
|
||||||
const paragraphs = (member.description ?? "").split("\n").map((s) => s.trim()).filter(Boolean);
|
const paragraphs = (member.description ?? "").split("\n").map((s) => s.trim()).filter(Boolean);
|
||||||
const tags = (member.tags ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
// 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 hours = parseMemberHours(member.hours);
|
||||||
const openStatus = getOpenStatus(hours);
|
const openStatus = getOpenStatus(hours);
|
||||||
const normalizedWebsite = normalizeWebsite(member.website);
|
const normalizedWebsite = normalizeWebsite(member.website);
|
||||||
const shortWebsite = websiteLabel(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 phoneHref = member.phone ? `tel:${member.phone.replace(/[^\d+]/g, "")}` : null;
|
||||||
|
|
||||||
const sectionCard: React.CSSProperties = {
|
const sectionCard: React.CSSProperties = {
|
||||||
@@ -142,15 +199,25 @@ export default async function FicheAdherentPage({
|
|||||||
return (
|
return (
|
||||||
<div style={{ background: "#F6F2E8", minHeight: "100vh", fontFamily: "'Public Sans',sans-serif", color: "#33291D" }}>
|
<div style={{ background: "#F6F2E8", minHeight: "100vh", fontFamily: "'Public Sans',sans-serif", color: "#33291D" }}>
|
||||||
<SiteHeader active="annuaire" />
|
<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 */}
|
{/* 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>
|
<Link href="/annuaire" style={{ textDecoration: "none", color: "#9a6638", fontWeight: 600 }}>Annuaire</Link>
|
||||||
{member.categoryLabel && (<><span>›</span><span style={{ color: "#9a8d72" }}>{member.categoryLabel}</span></>)}
|
{member.categoryLabel && (<><span>›</span><span style={{ color: "#9a8d72" }}>{member.categoryLabel}</span></>)}
|
||||||
<span>›</span>
|
<span>›</span>
|
||||||
<span style={{ color: "#3c3322", fontWeight: 600 }}>{member.name}</span>
|
<span style={{ color: "#3c3322", fontWeight: 600 }} aria-current="page">{member.name}</span>
|
||||||
</div>
|
</nav>
|
||||||
|
|
||||||
{/* cover + identity */}
|
{/* cover + identity */}
|
||||||
<section style={{ position: "relative", borderRadius: 22, overflow: "hidden", border: "1px solid #e6dcc6" }}>
|
<section style={{ position: "relative", borderRadius: 22, overflow: "hidden", border: "1px solid #e6dcc6" }}>
|
||||||
@@ -309,8 +376,8 @@ export default async function FicheAdherentPage({
|
|||||||
{phoneHref && member.phone && (
|
{phoneHref && member.phone && (
|
||||||
<ContactAction href={phoneHref} icon="phone" label="Appeler" detail={member.phone} />
|
<ContactAction href={phoneHref} icon="phone" label="Appeler" detail={member.phone} />
|
||||||
)}
|
)}
|
||||||
{member.email && (
|
{contactEmail && (
|
||||||
<ContactAction href={`mailto:${member.email}`} icon="mail" label="Envoyer un e-mail" detail={member.email} />
|
<ContactAction href={`mailto:${contactEmail}`} icon="mail" label="Envoyer un e-mail" detail={contactEmail} />
|
||||||
)}
|
)}
|
||||||
{normalizedWebsite && shortWebsite && (
|
{normalizedWebsite && shortWebsite && (
|
||||||
<ContactAction href={normalizedWebsite} icon="website" label="Visiter le site" detail={shortWebsite} external />
|
<ContactAction href={normalizedWebsite} icon="website" label="Visiter le site" detail={shortWebsite} external />
|
||||||
@@ -324,10 +391,31 @@ export default async function FicheAdherentPage({
|
|||||||
external
|
external
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!phoneHref && !member.email && !normalizedWebsite && !fullAddress && (
|
{!phoneHref && !contactEmail && !normalizedWebsite && !fullAddress && (
|
||||||
<div className="practical-empty">Coordonnées non communiquées.</div>
|
<div className="practical-empty">Coordonnées non communiquées.</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{contact && (
|
||||||
|
<div className="contact-referent">
|
||||||
|
<div className="contact-referent__label">
|
||||||
|
<span style={{ width: 7, height: 7, borderRadius: "50%", background: accent }} />
|
||||||
|
Contact adhérent
|
||||||
|
</div>
|
||||||
|
{contact.name && <div className="contact-referent__name">{contact.name}</div>}
|
||||||
|
{contact.phone &&
|
||||||
|
(contactTel ? (
|
||||||
|
<a className="contact-referent__phone" href={contactTel}>
|
||||||
|
{contact.phone}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<div className="contact-referent__phone">{contact.phone}</div>
|
||||||
|
))}
|
||||||
|
<div className="contact-referent__hint">
|
||||||
|
Visible uniquement parce que vous êtes connecté : ces coordonnées ne sont pas
|
||||||
|
publiées sur le site.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -382,8 +470,8 @@ export default async function FicheAdherentPage({
|
|||||||
<div style={{ padding: "14px 15px 15px" }}>
|
<div style={{ padding: "14px 15px 15px" }}>
|
||||||
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 16, margin: "0 0 5px", color: "#26201a" }}>{p.title}</h3>
|
<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 style={{ margin: "0 0 11px", fontSize: 13, color: "#8c8068", lineHeight: 1.5 }}>{p.text}</p>
|
||||||
{p.validUntil && (
|
{formatValidity(p) && (
|
||||||
<div style={{ borderTop: "1px solid #f0e8d6", paddingTop: 10, fontSize: 12, color: "#a99c82" }}>{p.validUntil}</div>
|
<div style={{ borderTop: "1px solid #f0e8d6", paddingTop: 10, fontSize: 12, color: "#a99c82" }}>{formatValidity(p)}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@@ -392,24 +480,24 @@ export default async function FicheAdherentPage({
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* contact CTA */}
|
{/* contact CTA */}
|
||||||
{(member.email || phoneHref || normalizedWebsite) && (
|
{(contactEmail || phoneHref || normalizedWebsite) && (
|
||||||
<section style={{ background: "linear-gradient(120deg,#13324F,#1d4a72)", borderRadius: 18, padding: 22, color: "#fff" }}>
|
<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>
|
<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>
|
<p style={{ margin: "0 0 14px", fontSize: 13.5, color: "#bcd3e6" }}>Contactez {member.name} directement.</p>
|
||||||
<a
|
<a
|
||||||
href={member.email ? `mailto:${member.email}` : phoneHref ?? normalizedWebsite ?? undefined}
|
href={contactEmail ? `mailto:${contactEmail}` : phoneHref ?? normalizedWebsite ?? undefined}
|
||||||
target={!member.email && !phoneHref ? "_blank" : undefined}
|
target={!contactEmail && !phoneHref ? "_blank" : undefined}
|
||||||
rel={!member.email && !phoneHref ? "noopener noreferrer" : undefined}
|
rel={!contactEmail && !phoneHref ? "noopener noreferrer" : undefined}
|
||||||
className="font-display"
|
className="font-display"
|
||||||
style={{ textDecoration: "none", display: "block", textAlign: "center", background: "#E0A63C", color: "#33291D", fontWeight: 700, fontSize: 14.5, padding: 12, borderRadius: 11 }}
|
style={{ textDecoration: "none", display: "block", textAlign: "center", background: "#E0A63C", color: "#33291D", fontWeight: 700, fontSize: 14.5, padding: 12, borderRadius: 11 }}
|
||||||
>
|
>
|
||||||
{member.email ? "Envoyer un message" : phoneHref ? "Appeler maintenant" : "Visiter le site"}
|
{contactEmail ? "Envoyer un message" : phoneHref ? "Appeler maintenant" : "Visiter le site"}
|
||||||
</a>
|
</a>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</main>
|
||||||
|
|
||||||
<SiteFooter />
|
<SiteFooter />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,28 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import Link from "next/link";
|
import { MemberCard, type MemberCardData } from "@/components/MemberCard";
|
||||||
import { VitrineImage } from "@/components/VitrineImage";
|
import type { MemberContact } from "@/lib/member-contact";
|
||||||
|
|
||||||
export type DirectoryMember = {
|
export type DirectoryMember = MemberCardData & {
|
||||||
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;
|
|
||||||
hasPromo: boolean;
|
hasPromo: boolean;
|
||||||
promoBadge: string | null;
|
promoBadge: string | null;
|
||||||
|
/** Référent de l'adhérent, `null` tant que le visiteur n'est pas connecté. */
|
||||||
|
contact: MemberContact | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
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({
|
export function AnnuaireClient({
|
||||||
members,
|
members,
|
||||||
categories,
|
categories,
|
||||||
@@ -190,46 +178,9 @@ export function AnnuaireClient({
|
|||||||
{/* grid / empty */}
|
{/* grid / empty */}
|
||||||
{list.length > 0 ? (
|
{list.length > 0 ? (
|
||||||
<div className="grid grid-3" style={{ gap: 20 }}>
|
<div className="grid grid-3" style={{ gap: 20 }}>
|
||||||
{list.map((m, i) => {
|
{list.map((m, i) => (
|
||||||
return (
|
<MemberCard key={m.id} m={m} index={i} contact={m.contact} />
|
||||||
<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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ background: "#fff", border: "1px dashed #d8cdb4", borderRadius: 18, padding: 54, textAlign: "center" }}>
|
<div style={{ background: "#fff", border: "1px dashed #d8cdb4", borderRadius: 18, padding: 54, textAlign: "center" }}>
|
||||||
|
|||||||
@@ -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'annuaire
|
||||||
|
</h2>
|
||||||
|
<CategoryLinks categories={categoriesInUse} currentSlug={category.slug} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<SiteFooter />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,17 +6,27 @@ import { MembershipModalButton } from "@/components/MembershipModalButton";
|
|||||||
import {
|
import {
|
||||||
getActiveMembersWithCategory,
|
getActiveMembersWithCategory,
|
||||||
getAllCategories,
|
getAllCategories,
|
||||||
|
getCategoriesInUse,
|
||||||
getLiveBadgesByMember,
|
getLiveBadgesByMember,
|
||||||
|
getMemberContacts,
|
||||||
} from "@/lib/queries";
|
} 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";
|
import { AnnuaireClient, type DirectoryMember } from "./AnnuaireClient";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
// Canonique sans paramètre de recherche : /annuaire?q=… ne crée pas de doublon.
|
||||||
title: "Annuaire — Plein R",
|
export const metadata: Metadata = pageMetadata({
|
||||||
|
title: "Annuaire des commerçants du Bassin de Pompey",
|
||||||
description:
|
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({
|
export default async function AnnuairePage({
|
||||||
searchParams,
|
searchParams,
|
||||||
@@ -24,12 +34,21 @@ export default async function AnnuairePage({
|
|||||||
searchParams: Promise<{ q?: string }>;
|
searchParams: Promise<{ q?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { q } = await searchParams;
|
const { q } = await searchParams;
|
||||||
const [rawMembers, categories, badges] = await Promise.all([
|
const [rawMembers, categories, badges, baseUrl, categoriesInUse, session] = await Promise.all([
|
||||||
getActiveMembersWithCategory(),
|
getActiveMembersWithCategory(),
|
||||||
getAllCategories(),
|
getAllCategories(),
|
||||||
getLiveBadgesByMember(),
|
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>();
|
const badgeByMember = new Map<number, string | null>();
|
||||||
for (const b of badges) {
|
for (const b of badges) {
|
||||||
if (b.memberId != null && !badgeByMember.has(b.memberId)) {
|
if (b.memberId != null && !badgeByMember.has(b.memberId)) {
|
||||||
@@ -41,13 +60,23 @@ export default async function AnnuairePage({
|
|||||||
...m,
|
...m,
|
||||||
hasPromo: badgeByMember.has(m.id),
|
hasPromo: badgeByMember.has(m.id),
|
||||||
promoBadge: badgeByMember.get(m.id) ?? null,
|
promoBadge: badgeByMember.get(m.id) ?? null,
|
||||||
|
contact: contacts?.get(m.id) ?? null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ background: "#F6F2E8", minHeight: "100vh", fontFamily: "'Public Sans',sans-serif", color: "#33291D" }}>
|
<div style={{ background: "#F6F2E8", minHeight: "100vh", fontFamily: "'Public Sans',sans-serif", color: "#33291D" }}>
|
||||||
<SiteHeader active="annuaire" logo />
|
<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 */}
|
{/* title block */}
|
||||||
<section style={{ position: "relative", padding: "14px 0 22px", overflow: "hidden" }}>
|
<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="#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 }}>
|
<p style={{ margin: 0, fontSize: 16.5, color: "#6c6150", maxWidth: 560 }}>
|
||||||
Trouvez un professionnel du Bassin de Pompey, découvrez sa fiche et ses bons plans.
|
Trouvez un professionnel du Bassin de Pompey, découvrez sa fiche et ses bons plans.
|
||||||
</p>
|
</p>
|
||||||
|
{signedIn && (
|
||||||
|
<p style={{ margin: "10px 0 0", fontSize: 13.5, color: "#9a8d72", maxWidth: 560 }}>
|
||||||
|
Vous êtes connecté : les coordonnées du référent de chaque adhérent (nom, prénom,
|
||||||
|
téléphone) sont affichées sur les fiches ci-dessous.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<AnnuaireClient members={members} categories={categories} initialQuery={q ?? ""} />
|
<AnnuaireClient members={members} categories={categories} initialQuery={q ?? ""} />
|
||||||
|
|
||||||
|
{/* 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 */}
|
{/* join CTA */}
|
||||||
<section
|
<section
|
||||||
style={{
|
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" }}
|
style={{ border: "none", background: "#E0A63C", color: "#33291D", fontWeight: 700, fontSize: 15.5, padding: "14px 26px", borderRadius: 12, whiteSpace: "nowrap" }}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</main>
|
||||||
|
|
||||||
<SiteFooter />
|
<SiteFooter />
|
||||||
</div>
|
</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;
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
authorizeUrl,
|
authorizeUrl,
|
||||||
getDecryptedAppSecret,
|
getDecryptedAppSecret,
|
||||||
getSocialAccount,
|
getSocialAccount,
|
||||||
siteUrl,
|
publicBaseUrl,
|
||||||
SOCIAL_NETWORKS,
|
SOCIAL_NETWORKS,
|
||||||
type SocialNetwork,
|
type SocialNetwork,
|
||||||
} from "@/lib/social-accounts";
|
} from "@/lib/social-accounts";
|
||||||
@@ -34,11 +34,13 @@ export async function GET(
|
|||||||
return back(request, "Réseau inconnu.");
|
return back(request, "Réseau inconnu.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const base = await siteUrl();
|
// Réglage enregistré, sinon l'adresse par laquelle l'administrateur est
|
||||||
|
// arrivé : c'est celle où le réseau nous renverra.
|
||||||
|
const base = await publicBaseUrl();
|
||||||
if (!base) {
|
if (!base) {
|
||||||
return back(
|
return back(
|
||||||
request,
|
request,
|
||||||
"Renseignez d'abord l'URL publique du site : elle sert à construire l'adresse de retour."
|
"Impossible de déterminer l'adresse publique du site : renseignez-la dans la section « URL publique du site »."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { and, asc, desc, eq, gte, inArray, sql } from "drizzle-orm";
|
import { and, asc, desc, eq, gte, inArray, sql } from "drizzle-orm";
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { meetingRegistrations, meetings, pastMeetingPhotos, pastMeetings } from "@/db/schema";
|
import { meetingRegistrations, meetings, pastMeetingPhotos, pastMeetings } from "@/db/schema";
|
||||||
|
import { JsonLd } from "@/components/JsonLd";
|
||||||
|
import { MeetingCard } from "@/components/MeetingCard";
|
||||||
import { PastMeetingGallery } from "@/components/PastMeetingGallery";
|
import { PastMeetingGallery } from "@/components/PastMeetingGallery";
|
||||||
import { SiteFooter } from "@/components/SiteFooter";
|
import { SiteFooter } from "@/components/SiteFooter";
|
||||||
import { SiteHeader } from "@/components/SiteHeader";
|
import { SiteHeader } from "@/components/SiteHeader";
|
||||||
@@ -13,9 +16,26 @@ import {
|
|||||||
parsePillars,
|
parsePillars,
|
||||||
splitLines,
|
splitLines,
|
||||||
} from "@/lib/site-settings";
|
} from "@/lib/site-settings";
|
||||||
|
import { breadcrumbJsonLd, eventJsonLd, pageMetadata } from "@/lib/seo";
|
||||||
|
import { publicBaseUrl } from "@/lib/seo-server";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
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) {
|
function formatDate(date: Date) {
|
||||||
return new Intl.DateTimeFormat("fr-FR", {
|
return new Intl.DateTimeFormat("fr-FR", {
|
||||||
weekday: "long",
|
weekday: "long",
|
||||||
@@ -30,7 +50,7 @@ function formatTime(date: Date) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function AssociationPage() {
|
export default async function AssociationPage() {
|
||||||
const [settings, session] = await Promise.all([getSiteSettings(), auth()]);
|
const [settings, session, baseUrl] = await Promise.all([getSiteSettings(), auth(), publicBaseUrl()]);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const memberId = session?.user.memberId ?? null;
|
const memberId = session?.user.memberId ?? null;
|
||||||
|
|
||||||
@@ -108,6 +128,15 @@ export default async function AssociationPage() {
|
|||||||
return (
|
return (
|
||||||
<div style={{ background: "#F6F2E8", minHeight: "100vh", fontFamily: "'Public Sans',sans-serif", color: "#33291D" }}>
|
<div style={{ background: "#F6F2E8", minHeight: "100vh", fontFamily: "'Public Sans',sans-serif", color: "#33291D" }}>
|
||||||
<SiteHeader active="association" logo />
|
<SiteHeader active="association" logo />
|
||||||
|
<JsonLd
|
||||||
|
data={[
|
||||||
|
breadcrumbJsonLd(baseUrl, [
|
||||||
|
{ name: "Accueil", path: "/" },
|
||||||
|
{ name: "L'association", path: "/association" },
|
||||||
|
]),
|
||||||
|
...upcoming.map((meeting) => eventJsonLd(baseUrl, meeting)),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<section className="container" style={{ paddingTop: 34, paddingBottom: 52 }}>
|
<section className="container" style={{ paddingTop: 34, paddingBottom: 52 }}>
|
||||||
@@ -204,40 +233,31 @@ export default async function AssociationPage() {
|
|||||||
const remaining = Math.max(0, meeting.capacity - registered);
|
const remaining = Math.max(0, meeting.capacity - registered);
|
||||||
const isRegistered = myMeetingIds.has(meeting.id);
|
const isRegistered = myMeetingIds.has(meeting.id);
|
||||||
return (
|
return (
|
||||||
<article id={`rencontre-${meeting.id}`} key={meeting.id} className="lift" style={{ background: "#fff", border: isRegistered ? "2px solid #1f8a5b" : "1px solid #e6dcc6", borderRadius: 12, overflow: "hidden", scrollMarginTop: 100 }}>
|
<MeetingCard
|
||||||
<div style={{ position: "relative", aspectRatio: "16 / 10", background: meeting.imageUrl ? `center/cover no-repeat url(${meeting.imageUrl})` : "repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)" }}>
|
key={meeting.id}
|
||||||
{isRegistered && (
|
meeting={{
|
||||||
<span style={{ position: "absolute", top: 12, right: 12, display: "inline-flex", alignItems: "center", gap: 6, background: "#1f8a5b", color: "#fff", borderRadius: 999, padding: "7px 12px", fontSize: 12, fontWeight: 800, boxShadow: "0 4px 14px rgba(22,72,49,0.28)" }}>
|
id: meeting.id,
|
||||||
✓ Inscrit
|
title: meeting.title,
|
||||||
</span>
|
description: meeting.description,
|
||||||
)}
|
location: meeting.location,
|
||||||
</div>
|
imageUrl: meeting.imageUrl,
|
||||||
<div style={{ padding: 18 }}>
|
// Formatées ici : côté client, le fuseau du visiteur
|
||||||
<div style={{ color: "#9a6638", fontSize: 12.5, fontWeight: 800 }}>
|
// donnerait une autre heure et casserait l'hydratation.
|
||||||
{formatDate(meeting.startsAt)} · {formatTime(meeting.startsAt)}
|
dateLabel: formatDate(meeting.startsAt),
|
||||||
</div>
|
timeLabel: formatTime(meeting.startsAt),
|
||||||
<h3 className="font-display" style={{ margin: "7px 0 7px", color: "#26201a", fontSize: 21 }}>
|
capacity: meeting.capacity,
|
||||||
{meeting.title}
|
registered,
|
||||||
</h3>
|
participantsPerAccount: meeting.participantsPerAccount,
|
||||||
{meeting.location && <div style={{ fontSize: 13, color: "#6c6150", fontWeight: 700 }}>{meeting.location}</div>}
|
isRegistered,
|
||||||
<p style={{ color: "#8c8068", fontSize: 13.5, lineHeight: 1.6, minHeight: 64 }}>
|
ctaLabel: isRegistered
|
||||||
{meeting.description}
|
? "Modifier mon inscription"
|
||||||
</p>
|
: remaining <= 0
|
||||||
<div style={{ display: "flex", justifyContent: "space-between", color: "#6c6150", fontSize: 12.5, fontWeight: 800, marginBottom: 12 }}>
|
? "Complet"
|
||||||
<span>{registered}/{meeting.capacity} inscrits</span>
|
: memberId
|
||||||
<span>{remaining} place(s)</span>
|
? "S'inscrire"
|
||||||
</div>
|
: "Connexion et inscription",
|
||||||
<div style={{ color: "#8c8068", fontSize: 12.5, marginBottom: 10 }}>
|
}}
|
||||||
Jusqu'à {meeting.participantsPerAccount} participant{meeting.participantsPerAccount > 1 ? "s" : ""} par compte
|
/>
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
href={`/inscription/${meeting.id}`}
|
|
||||||
style={{ display: "block", textAlign: "center", textDecoration: "none", background: remaining <= 0 && !isRegistered ? "#a99c82" : "#13324F", color: "#fff", borderRadius: 8, padding: 11, fontWeight: 800 }}
|
|
||||||
>
|
|
||||||
{isRegistered ? "Modifier mon inscription" : remaining <= 0 ? "Complet" : memberId ? "S'inscrire" : "Connexion et inscription"}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import Link from "next/link";
|
|||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useEffect, useState, type ReactNode } from "react";
|
import { useEffect, useState, type ReactNode } from "react";
|
||||||
import { BackendIcon, type BackendIconName } from "@/components/BackendIcons";
|
import { BackendIcon, type BackendIconName } from "@/components/BackendIcons";
|
||||||
import { can, ROLE_LABELS } from "@/lib/rbac";
|
import { can, isStaff, ROLE_LABELS } from "@/lib/rbac";
|
||||||
import type { AppRole } from "@/types/next-auth";
|
import type { AppRole } from "@/types/next-auth";
|
||||||
import { doSignOut } from "./actions";
|
import { doSignOut } from "./actions";
|
||||||
|
|
||||||
@@ -19,17 +19,21 @@ const TITLES: Record<string, [string, string]> = {
|
|||||||
"/backend/rencontres": ["Rencontres", "Gérer les prochaines dates"],
|
"/backend/rencontres": ["Rencontres", "Gérer les prochaines dates"],
|
||||||
"/backend/inscriptions": ["Inscriptions", "Gérer les participants aux rencontres"],
|
"/backend/inscriptions": ["Inscriptions", "Gérer les participants aux rencontres"],
|
||||||
"/backend/rencontres-passees": ["Rencontres passées", "Publier les archives et leurs photos"],
|
"/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/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/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/administrateurs": ["Administrateurs", "Gérer les accès à l'administration"],
|
||||||
"/backend/categories": ["Catégories", "Gérer les métiers de l'annuaire"],
|
"/backend/categories": ["Catégories", "Gérer les métiers de l'annuaire"],
|
||||||
"/backend/parametres": ["Paramètres du site", "Configurer l'association et les mentions légales"],
|
"/backend/parametres": ["Paramètres du site", "Configurer l'association et les mentions légales"],
|
||||||
"/backend/espace": ["Mon espace adhérent", "Publiez et suivez vos promotions"],
|
"/backend/espace": ["Mon profil", "Votre fiche publique, vos inscriptions et votre droit à l'image"],
|
||||||
|
"/backend/espace/promotions": ["Mes promotions", "Proposez une offre et suivez celles en ligne"],
|
||||||
|
"/backend/espace/informations": ["Informations", "Les annonces de l'association"],
|
||||||
"/backend/changer-mot-de-passe": ["Mot de passe", "Sécurisez votre compte"],
|
"/backend/changer-mot-de-passe": ["Mot de passe", "Sécurisez votre compte"],
|
||||||
};
|
};
|
||||||
|
|
||||||
type Capability = Parameters<typeof can>[1];
|
type Capability = Parameters<typeof can>[1];
|
||||||
type BadgeKey = "inbox" | "promos";
|
type BadgeKey = "inbox" | "promos" | "infos";
|
||||||
|
|
||||||
type NavItem = {
|
type NavItem = {
|
||||||
href: string;
|
href: string;
|
||||||
@@ -71,6 +75,7 @@ const SECTIONS: NavSection[] = [
|
|||||||
{
|
{
|
||||||
label: "Communication",
|
label: "Communication",
|
||||||
items: [
|
items: [
|
||||||
|
{ href: "/backend/informations", label: "Informations", icon: "news", capability: "manageInformations" },
|
||||||
{ href: "/backend/emails", label: "E-mails", icon: "emails", capability: "manageEmails" },
|
{ href: "/backend/emails", label: "E-mails", icon: "emails", capability: "manageEmails" },
|
||||||
{ href: "/backend/reseaux", label: "Réseaux sociaux", icon: "social", capability: "manageSettings" },
|
{ href: "/backend/reseaux", label: "Réseaux sociaux", icon: "social", capability: "manageSettings" },
|
||||||
],
|
],
|
||||||
@@ -79,13 +84,18 @@ const SECTIONS: NavSection[] = [
|
|||||||
label: "Configuration",
|
label: "Configuration",
|
||||||
items: [
|
items: [
|
||||||
{ href: "/backend/categories", label: "Catégories", icon: "categories", capability: "manageCategories" },
|
{ 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/parametres", label: "Paramètres du site", icon: "settings", capability: "manageSettings" },
|
||||||
{ href: "/backend/administrateurs", label: "Administrateurs", icon: "admins", capability: "manageAdmins" },
|
{ href: "/backend/administrateurs", label: "Administrateurs", icon: "admins", capability: "manageAdmins" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Côté adhérent",
|
label: "Côté adhérent",
|
||||||
items: [{ href: "/backend/espace", label: "Mon espace", icon: "space" }],
|
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" },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -120,11 +130,13 @@ export function BackendShell({
|
|||||||
user,
|
user,
|
||||||
pendingCount,
|
pendingCount,
|
||||||
inboxCount = 0,
|
inboxCount = 0,
|
||||||
|
infoCount = 0,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
user: { name: string; role: AppRole };
|
user: { name: string; role: AppRole };
|
||||||
pendingCount: number;
|
pendingCount: number;
|
||||||
inboxCount?: number;
|
inboxCount?: number;
|
||||||
|
infoCount?: number;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -160,7 +172,7 @@ export function BackendShell({
|
|||||||
return () => window.removeEventListener("keydown", onKey);
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
}, [menuOpen]);
|
}, [menuOpen]);
|
||||||
|
|
||||||
const badges: Record<BadgeKey, number> = { inbox: inboxCount, promos: pendingCount };
|
const badges: Record<BadgeKey, number> = { inbox: inboxCount, promos: pendingCount, infos: infoCount };
|
||||||
|
|
||||||
const sections = SECTIONS.map((section) => ({
|
const sections = SECTIONS.map((section) => ({
|
||||||
...section,
|
...section,
|
||||||
@@ -180,6 +192,43 @@ export function BackendShell({
|
|||||||
</form>
|
</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 (
|
return (
|
||||||
<div className="backend" style={{ fontFamily: "'Public Sans',sans-serif", color: "#33291D" }}>
|
<div className="backend" style={{ fontFamily: "'Public Sans',sans-serif", color: "#33291D" }}>
|
||||||
{menuOpen && (
|
{menuOpen && (
|
||||||
|
|||||||
+792
-150
File diff suppressed because it is too large.
Load diff
@@ -14,6 +14,11 @@ export function AddMemberPanel({
|
|||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [created, setCreated] = useState<CreatedMemberAccount | null>(null);
|
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();
|
const router = useRouter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -38,8 +43,18 @@ export function AddMemberPanel({
|
|||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<form
|
<form
|
||||||
|
key={attempt}
|
||||||
action={async (fd) => {
|
action={async (fd) => {
|
||||||
|
setError(null);
|
||||||
const result = await addMember(fd);
|
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);
|
setOpen(false);
|
||||||
// On reste sur la page : le mot de passe temporaire n'est affiché
|
// On reste sur la page : le mot de passe temporaire n'est affiché
|
||||||
// qu'ici, une seule fois, et n'est conservé nulle part.
|
// qu'ici, une seule fois, et n'est conservé nulle part.
|
||||||
@@ -48,18 +63,23 @@ export function AddMemberPanel({
|
|||||||
}}
|
}}
|
||||||
style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 16, padding: 22, marginTop: 14 }}
|
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 className="grid grid-2" style={{ gap: 16 }}>
|
||||||
<div>
|
<div>
|
||||||
<label className="field-label">Nom de l'adhérent</label>
|
<label className="field-label">Nom de l'adhérent</label>
|
||||||
<input name="name" required placeholder="ex : Au Bon Pain" className="field" />
|
<input name="name" required placeholder="ex : Au Bon Pain" className="field" defaultValue={draft.name ?? ""} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="field-label">E-mail (identifiant de connexion)</label>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<label className="field-label">Catégorie</label>
|
<label className="field-label">Catégorie</label>
|
||||||
<select name="categoryId" className="field" defaultValue="">
|
<select name="categoryId" className="field" defaultValue={draft.categoryId ?? ""}>
|
||||||
<option value="">—</option>
|
<option value="">—</option>
|
||||||
{categories.map((c) => (
|
{categories.map((c) => (
|
||||||
<option key={c.id} value={c.id}>
|
<option key={c.id} value={c.id}>
|
||||||
@@ -70,7 +90,7 @@ export function AddMemberPanel({
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="field-label">Commune</label>
|
<label className="field-label">Commune</label>
|
||||||
<select name="city" className="field" defaultValue="">
|
<select name="city" className="field" defaultValue={draft.city ?? ""}>
|
||||||
<option value="">—</option>
|
<option value="">—</option>
|
||||||
{BASSIN_POMPEY_COMMUNES.map((c) => (
|
{BASSIN_POMPEY_COMMUNES.map((c) => (
|
||||||
<option key={c} value={c}>
|
<option key={c} value={c}>
|
||||||
@@ -81,7 +101,7 @@ export function AddMemberPanel({
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="field-label">Statut</label>
|
<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="pending">En attente</option>
|
||||||
<option value="active">Actif</option>
|
<option value="active">Actif</option>
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export function BackfillAccountsForm({ missingAccounts }: { missingAccounts: num
|
|||||||
if (issued) {
|
if (issued) {
|
||||||
return (
|
return (
|
||||||
<OneTimeCredentials
|
<OneTimeCredentials
|
||||||
items={issued.map((i) => ({ label: i.label, email: i.email, tempPassword: i.tempPassword }))}
|
items={issued}
|
||||||
title={`${issued.length} compte(s) créé(s) — identifiants à transmettre`}
|
title={`${issued.length} compte(s) créé(s) — identifiants à transmettre`}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -25,7 +25,8 @@ export function BackfillAccountsForm({ missingAccounts }: { missingAccounts: num
|
|||||||
setPending(true);
|
setPending(true);
|
||||||
try {
|
try {
|
||||||
const created = await createMissingMemberAccounts();
|
const created = await createMissingMemberAccounts();
|
||||||
setIssued(created.map((c) => ({ label: c.name, email: c.email, tempPassword: c.tempPassword })));
|
// On propage tout : l'état d'envoi accompagne chaque ligne.
|
||||||
|
setIssued(created.map((c) => ({ ...c, label: c.name })));
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} finally {
|
} finally {
|
||||||
setPending(false);
|
setPending(false);
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import { db } from "@/db";
|
|||||||
import { categories, members, users } from "@/db/schema";
|
import { categories, members, users } from "@/db/schema";
|
||||||
import { can } from "@/lib/rbac";
|
import { can } from "@/lib/rbac";
|
||||||
import { ImageField } from "@/components/ImageField";
|
import { ImageField } from "@/components/ImageField";
|
||||||
|
import { MemberContactFields } from "@/components/MemberContactFields";
|
||||||
import { HoursEditor } from "@/components/HoursEditor";
|
import { HoursEditor } from "@/components/HoursEditor";
|
||||||
|
import { TagsField } from "@/components/TagsField";
|
||||||
import { communeOptions } from "@/lib/communes";
|
import { communeOptions } from "@/lib/communes";
|
||||||
import { deleteMember, updateMember } from "../../actions";
|
import { deleteMember, updateMember } from "../../actions";
|
||||||
import { ResetPasswordButton } from "../ResetPasswordButton";
|
import { ResetPasswordButton } from "../ResetPasswordButton";
|
||||||
@@ -31,7 +33,7 @@ export default async function EditMemberPage({
|
|||||||
if (!member) notFound();
|
if (!member) notFound();
|
||||||
|
|
||||||
const cats = await db
|
const cats = await db
|
||||||
.select({ id: categories.id, label: categories.label })
|
.select({ id: categories.id, slug: categories.slug, label: categories.label })
|
||||||
.from(categories)
|
.from(categories)
|
||||||
.orderBy(asc(categories.sort));
|
.orderBy(asc(categories.sort));
|
||||||
|
|
||||||
@@ -72,9 +74,14 @@ export default async function EditMemberPage({
|
|||||||
<input name="name" required defaultValue={member.name} className="field" />
|
<input name="name" required defaultValue={member.name} className="field" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="field-label">E-mail</label>
|
<label className="field-label">E-mail du compte (identifiant, échanges avec l'association)</label>
|
||||||
<input name="email" type="email" defaultValue={member.email} className="field" />
|
<input name="email" type="email" defaultValue={member.email} className="field" />
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="field-label">E-mail de contact (affiché sur la fiche)</label>
|
||||||
|
<input name="contactEmail" type="email" defaultValue={member.contactEmail ?? ""} className="field" placeholder={member.email} />
|
||||||
|
<p className="field-hint">Laissé vide, la fiche publique affiche l'e-mail du compte.</p>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="field-label">Catégorie</label>
|
<label className="field-label">Catégorie</label>
|
||||||
<select name="categoryId" className="field" defaultValue={member.categoryId ?? ""}>
|
<select name="categoryId" className="field" defaultValue={member.categoryId ?? ""}>
|
||||||
@@ -126,6 +133,12 @@ export default async function EditMemberPage({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<MemberContactFields
|
||||||
|
firstName={member.contactFirstName}
|
||||||
|
lastName={member.contactLastName}
|
||||||
|
phone={member.contactPhone}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="grid grid-2" style={{ gap: 16, marginTop: 16 }}>
|
<div className="grid grid-2" style={{ gap: 16, marginTop: 16 }}>
|
||||||
<ImageField name="coverUrl" label="Image d'entête (bannière)" defaultValue={member.coverUrl ?? ""} height={130} />
|
<ImageField name="coverUrl" label="Image d'entête (bannière)" defaultValue={member.coverUrl ?? ""} height={130} />
|
||||||
<ImageField name="logoUrl" label="Logo" defaultValue={member.logoUrl ?? ""} height={130} fit="contain" />
|
<ImageField name="logoUrl" label="Logo" defaultValue={member.logoUrl ?? ""} height={130} fit="contain" />
|
||||||
@@ -134,11 +147,15 @@ export default async function EditMemberPage({
|
|||||||
<div style={{ marginTop: 16 }}>
|
<div style={{ marginTop: 16 }}>
|
||||||
<label className="field-label">Description (une ligne vide sépare les paragraphes)</label>
|
<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" }} />
|
<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>
|
||||||
|
|
||||||
<div style={{ marginTop: 16 }}>
|
<div style={{ marginTop: 16 }}>
|
||||||
<label className="field-label">Tags / spécialités (séparés par des virgules)</label>
|
<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>
|
||||||
|
|
||||||
<div style={{ marginTop: 16 }}>
|
<div style={{ marginTop: 16 }}>
|
||||||
|
|||||||
@@ -25,11 +25,16 @@ export function InviteAdminForm() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const result = await inviteAdmin(fd);
|
const result = await inviteAdmin(fd);
|
||||||
|
if (result && "error" in result) {
|
||||||
|
setError(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setIssued(result ?? null);
|
setIssued(result ?? null);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch {
|
} catch {
|
||||||
// Next masque le détail des erreurs serveur en production.
|
// Filet pour l'imprévu : les échecs de saisie, eux, sont renvoyés
|
||||||
setError("Impossible de créer ce compte : vérifiez que l'e-mail n'est pas déjà utilisé.");
|
// par l'action et affichés tels quels ci-dessus.
|
||||||
|
setError("Impossible de créer ce compte. Réessayez dans un instant.");
|
||||||
} finally {
|
} finally {
|
||||||
setPending(false);
|
setPending(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'expédie pour l'instant : les mots de passe temporaires restent affichés à
|
||||||
|
l'é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'application
|
||||||
|
<input className="field" name="appId" required defaultValue={account?.appId ?? ""} style={{ marginTop: 6 }} />
|
||||||
|
</label>
|
||||||
|
<label className="field-label">
|
||||||
|
Secret de l'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'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'envoi</div>
|
||||||
|
{log.length === 0 ? (
|
||||||
|
<div style={{ color: "#a99c82", fontSize: 13 }}>Aucun message expédié pour l'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,10 +10,22 @@ import { approveMembershipRequest, type CreatedMemberAccount } from "../actions"
|
|||||||
* Approuve une demande et affiche une seule fois les identifiants du compte
|
* 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,
|
* créé. On reste sur la page : une redirection ferait perdre le mot de passe,
|
||||||
* qui n'est conservé nulle part.
|
* qui n'est conservé nulle part.
|
||||||
|
*
|
||||||
|
* Le bouton ne s'affiche que pour une demande encore à traiter, mais c'est ce
|
||||||
|
* composant — et non la page — qui en décide : après l'approbation,
|
||||||
|
* `router.refresh()` renvoie la demande en « approuvée », et un masquage côté
|
||||||
|
* serveur démonterait le bloc d'identifiants avant qu'il ait été lu.
|
||||||
*/
|
*/
|
||||||
export function ApproveRequestForm({ requestId }: { requestId: number }) {
|
export function ApproveRequestForm({
|
||||||
|
requestId,
|
||||||
|
status,
|
||||||
|
}: {
|
||||||
|
requestId: number;
|
||||||
|
status: string;
|
||||||
|
}) {
|
||||||
const [created, setCreated] = useState<CreatedMemberAccount | null>(null);
|
const [created, setCreated] = useState<CreatedMemberAccount | null>(null);
|
||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
if (created) {
|
if (created) {
|
||||||
@@ -27,13 +39,20 @@ export function ApproveRequestForm({ requestId }: { requestId: number }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (status !== "new") return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
action={async (fd) => {
|
action={async (fd) => {
|
||||||
setPending(true);
|
setPending(true);
|
||||||
try {
|
try {
|
||||||
const result = await approveMembershipRequest(fd);
|
const result = await approveMembershipRequest(fd);
|
||||||
if (result) {
|
// Échec de saisie (e-mail déjà pris, demande sans e-mail) : la
|
||||||
|
// raison s'affiche à côté du bouton, la page reste en place.
|
||||||
|
if (result && "error" in result) {
|
||||||
|
setError(result.error);
|
||||||
|
} else if (result) {
|
||||||
|
setError(null);
|
||||||
setCreated(result);
|
setCreated(result);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
}
|
}
|
||||||
@@ -43,6 +62,11 @@ export function ApproveRequestForm({ requestId }: { requestId: number }) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<input type="hidden" name="id" value={requestId} />
|
<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
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={pending}
|
disabled={pending}
|
||||||
|
|||||||
@@ -72,13 +72,25 @@ export default async function DemandesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<StatusPill map={REQ_STATUS} status={r.status} />
|
<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 }}>
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
<ApproveRequestForm requestId={r.id} />
|
<ApproveRequestForm requestId={r.id} status={r.status} />
|
||||||
<form action={setRequestStatus}>
|
{r.status === "new" && (
|
||||||
<input type="hidden" name="id" value={r.id} />
|
<form action={setRequestStatus}>
|
||||||
<input type="hidden" name="status" value="rejected" />
|
<input type="hidden" name="id" value={r.id} />
|
||||||
<ActionBtn color="#d8472b">Rejeter</ActionBtn>
|
<input type="hidden" name="status" value="rejected" />
|
||||||
</form>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{r.message && (
|
{r.message && (
|
||||||
@@ -109,17 +121,30 @@ export default async function DemandesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<StatusPill map={MSG_STATUS} status={m.status} />
|
<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 }}>
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
<form action={setContactStatus}>
|
{m.status === "new" && (
|
||||||
<input type="hidden" name="id" value={m.id} />
|
<form action={setContactStatus}>
|
||||||
<input type="hidden" name="status" value="read" />
|
<input type="hidden" name="id" value={m.id} />
|
||||||
<ActionBtn color="#2C6FB3">Marquer lu</ActionBtn>
|
<input type="hidden" name="status" value="read" />
|
||||||
</form>
|
<ActionBtn color="#2C6FB3">Marquer lu</ActionBtn>
|
||||||
<form action={setContactStatus}>
|
</form>
|
||||||
<input type="hidden" name="id" value={m.id} />
|
)}
|
||||||
<input type="hidden" name="status" value="archived" />
|
{m.status !== "archived" ? (
|
||||||
<ActionBtn color="#a99c82">Archiver</ActionBtn>
|
<form action={setContactStatus}>
|
||||||
</form>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<p style={{ margin: "10px 0 0", fontSize: 13.5, color: "#5a5040", whiteSpace: "pre-wrap" }}>{m.message}</p>
|
<p style={{ margin: "10px 0 0", fontSize: 13.5, color: "#5a5040", whiteSpace: "pre-wrap" }}>{m.message}</p>
|
||||||
|
|||||||
@@ -2,21 +2,30 @@ import { redirect } from "next/navigation";
|
|||||||
import { getSession } from "@/lib/session";
|
import { getSession } from "@/lib/session";
|
||||||
import { EmailCreator } from "@/components/EmailCreator";
|
import { EmailCreator } from "@/components/EmailCreator";
|
||||||
import { can } from "@/lib/rbac";
|
import { can } from "@/lib/rbac";
|
||||||
import { getSiteSettings } from "@/lib/site-settings";
|
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 const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function EmailsPage() {
|
export default async function EmailsPage() {
|
||||||
const session = await getSession();
|
const session = await getSession();
|
||||||
if (!can(session?.user.role, "manageEmails")) redirect("/backend");
|
if (!can(session?.user.role, "manageEmails")) redirect("/backend");
|
||||||
const settings = await getSiteSettings();
|
|
||||||
const brand = {
|
|
||||||
associationName: settings.association_name,
|
|
||||||
address: settings.association_address,
|
|
||||||
email: settings.association_email,
|
|
||||||
phone: settings.association_phone,
|
|
||||||
siret: settings.association_siret,
|
|
||||||
};
|
|
||||||
|
|
||||||
return <EmailCreator brand={brand} />;
|
const [settings, choices, mailReady, base] = await Promise.all([
|
||||||
|
getSiteSettings(),
|
||||||
|
audienceOptions(),
|
||||||
|
isMailConfigured(),
|
||||||
|
siteUrl(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EmailCreator
|
||||||
|
brand={emailBrand(settings)}
|
||||||
|
choices={choices}
|
||||||
|
mailReady={mailReady}
|
||||||
|
siteUrl={base}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,8 +4,13 @@ import { useRef, useState } from "react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { publishPromo } from "../actions";
|
import { publishPromo } from "../actions";
|
||||||
import { PromoImage } from "@/components/PromoImage";
|
import { PromoImage } from "@/components/PromoImage";
|
||||||
|
import { MemberAvatar } from "@/components/MemberAvatar";
|
||||||
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
|
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
|
||||||
import type { SocialNetwork } from "@/lib/social";
|
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 =
|
const STRIPE_WARM =
|
||||||
"repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)";
|
"repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)";
|
||||||
@@ -15,22 +20,104 @@ const NETWORK_LABELS: Record<SocialNetwork, string> = {
|
|||||||
linkedin: "LinkedIn",
|
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({
|
export function MemberSpaceForm({
|
||||||
memberName,
|
memberName,
|
||||||
categories,
|
memberLogoUrl,
|
||||||
|
categoryGroups,
|
||||||
|
defaultCategory,
|
||||||
networks = [],
|
networks = [],
|
||||||
}: {
|
}: {
|
||||||
memberName: string;
|
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[];
|
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 router = useRouter();
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [text, setText] = useState("");
|
const [text, setText] = useState("");
|
||||||
const [cat, setCat] = useState(CATEGORIES[0]);
|
const [cat, setCat] = useState(initialCategory);
|
||||||
const [badge, setBadge] = useState("");
|
const [badge, setBadge] = useState("");
|
||||||
|
const [startsOn, setStartsOn] = useState("");
|
||||||
|
const [endsOn, setEndsOn] = useState("");
|
||||||
|
const [publishAt, setPublishAt] = useState("");
|
||||||
const [imgData, setImgData] = 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 [shares, setShares] = useState<SocialNetwork[]>([]);
|
||||||
const [submitted, setSubmitted] = useState(false);
|
const [submitted, setSubmitted] = useState(false);
|
||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
@@ -44,10 +131,20 @@ export function MemberSpaceForm({
|
|||||||
function onFile(e: React.ChangeEvent<HTMLInputElement>) {
|
function onFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
const f = e.target.files?.[0];
|
const f = e.target.files?.[0];
|
||||||
if (!f) return;
|
if (!f) return;
|
||||||
|
const bytes = f.size;
|
||||||
|
setImgInfo(null);
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = () => {
|
reader.onload = () => {
|
||||||
setImgData(String(reader.result));
|
const dataUrl = String(reader.result);
|
||||||
|
setImgData(dataUrl);
|
||||||
setSubmitted(false);
|
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);
|
reader.readAsDataURL(f);
|
||||||
}
|
}
|
||||||
@@ -56,7 +153,11 @@ export function MemberSpaceForm({
|
|||||||
setTitle("");
|
setTitle("");
|
||||||
setText("");
|
setText("");
|
||||||
setBadge("");
|
setBadge("");
|
||||||
|
setStartsOn("");
|
||||||
|
setEndsOn("");
|
||||||
|
setPublishAt("");
|
||||||
setImgData("");
|
setImgData("");
|
||||||
|
setImgInfo(null);
|
||||||
setShares([]);
|
setShares([]);
|
||||||
setSubmitted(false);
|
setSubmitted(false);
|
||||||
if (fileRef.current) fileRef.current.value = "";
|
if (fileRef.current) fileRef.current.value = "";
|
||||||
@@ -70,6 +171,9 @@ export function MemberSpaceForm({
|
|||||||
fd.set("text", text);
|
fd.set("text", text);
|
||||||
fd.set("category", cat);
|
fd.set("category", cat);
|
||||||
fd.set("badge", badge);
|
fd.set("badge", badge);
|
||||||
|
fd.set("startsOn", startsOn);
|
||||||
|
fd.set("endsOn", endsOn);
|
||||||
|
fd.set("publishAt", publishAt);
|
||||||
fd.set("imageUrl", imgData);
|
fd.set("imageUrl", imgData);
|
||||||
if (shares.includes("facebook")) fd.set("shareFacebook", "on");
|
if (shares.includes("facebook")) fd.set("shareFacebook", "on");
|
||||||
if (shares.includes("linkedin")) fd.set("shareLinkedin", "on");
|
if (shares.includes("linkedin")) fd.set("shareLinkedin", "on");
|
||||||
@@ -83,6 +187,17 @@ export function MemberSpaceForm({
|
|||||||
|
|
||||||
const hasImg = !!imgData;
|
const hasImg = !!imgData;
|
||||||
const hasBadge = !!badge.trim();
|
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 (
|
return (
|
||||||
<div className="grid grid-2" style={{ alignItems: "start" }}>
|
<div className="grid grid-2" style={{ alignItems: "start" }}>
|
||||||
@@ -130,6 +245,8 @@ export function MemberSpaceForm({
|
|||||||
<input ref={fileRef} type="file" accept="image/*" onChange={onFile} style={{ display: "none" }} />
|
<input ref={fileRef} type="file" accept="image/*" onChange={onFile} style={{ display: "none" }} />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
{imgInfo && <ImageReport info={imgInfo} />}
|
||||||
|
|
||||||
<label className="field-label">Titre de la promotion</label>
|
<label className="field-label">Titre de la promotion</label>
|
||||||
<input
|
<input
|
||||||
value={title}
|
value={title}
|
||||||
@@ -144,10 +261,16 @@ export function MemberSpaceForm({
|
|||||||
|
|
||||||
<div style={{ display: "flex", gap: 12, marginBottom: 16 }}>
|
<div style={{ display: "flex", gap: 12, marginBottom: 16 }}>
|
||||||
<div style={{ flex: 1 }}>
|
<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">
|
<select value={cat} onChange={(e) => setCat(e.target.value)} className="field">
|
||||||
{CATEGORIES.map((c) => (
|
{categoryGroups.map((group) => (
|
||||||
<option key={c}>{c}</option>
|
<optgroup key={group.label} label={group.label}>
|
||||||
|
{group.items.map((c) => (
|
||||||
|
<option key={c} value={c}>
|
||||||
|
{c}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -157,6 +280,56 @@ export function MemberSpaceForm({
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<label className="field-label">Texte de la promotion</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={text}
|
value={text}
|
||||||
@@ -218,7 +391,7 @@ export function MemberSpaceForm({
|
|||||||
<div style={{ display: "flex", gap: 10 }}>
|
<div style={{ display: "flex", gap: 10 }}>
|
||||||
<button
|
<button
|
||||||
onClick={submit}
|
onClick={submit}
|
||||||
disabled={pending}
|
disabled={pending || badRange}
|
||||||
className="font-display"
|
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 }}
|
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 }}
|
||||||
>
|
>
|
||||||
@@ -243,10 +416,31 @@ export function MemberSpaceForm({
|
|||||||
|
|
||||||
{/* LIVE PREVIEW */}
|
{/* LIVE PREVIEW */}
|
||||||
<div>
|
<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 }}>
|
<div style={{ fontSize: 12.5, letterSpacing: "0.08em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 700, marginBottom: 12 }}>
|
||||||
Aperçu en direct
|
Aperçu en direct
|
||||||
</div>
|
</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
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: "relative",
|
position: "relative",
|
||||||
@@ -281,8 +475,14 @@ export function MemberSpaceForm({
|
|||||||
{text.trim() ||
|
{text.trim() ||
|
||||||
"Le texte de votre offre apparaîtra ici au fur et à mesure que vous le rédigez."}
|
"Le texte de votre offre apparaîtra ici au fur et à mesure que vous le rédigez."}
|
||||||
</p>
|
</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={{ display: "flex", alignItems: "center", justifyContent: "space-between", borderTop: "1px solid #f0e8d6", paddingTop: 12 }}>
|
||||||
<div style={{ fontSize: 13, fontWeight: 700, color: "#3c3322" }}>{memberName}</div>
|
<div style={{ display: "flex", alignItems: "center", gap: 9, minWidth: 0 }}>
|
||||||
|
<MemberAvatar name={memberName} logoUrl={memberLogoUrl} size={28} />
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 700, color: "#3c3322" }}>{memberName}</span>
|
||||||
|
</div>
|
||||||
<span style={{ color: "#2C6FB3", fontWeight: 700, fontSize: 13 }}>Voir l'offre →</span>
|
<span style={{ color: "#2C6FB3", fontWeight: 700, fontSize: 13 }}>Voir l'offre →</span>
|
||||||
</div>
|
</div>
|
||||||
</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'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'association n'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+46
-240
@@ -1,58 +1,37 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { and, asc, desc, eq, gte, inArray, sql } from "drizzle-orm";
|
import { and, asc, desc, eq, gte, sql } from "drizzle-orm";
|
||||||
import { getSession } from "@/lib/session";
|
import { getSession } from "@/lib/session";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { categories, imageConsents, meetingRegistrations, meetings, members, promotions, socialPosts, type Member } from "@/db/schema";
|
import { categories, imageConsents, meetingRegistrations, meetings, members, promotions } from "@/db/schema";
|
||||||
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
|
import type { Member } from "@/db/schema";
|
||||||
import { configuredNetworks, SOCIAL_LABELS, type SocialNetwork } from "@/lib/social";
|
import { memberPath } from "@/lib/seo";
|
||||||
import { ImageField } from "@/components/ImageField";
|
import { ImageField } from "@/components/ImageField";
|
||||||
import { ImageConsentForm } from "@/components/ImageConsentForm";
|
import { ImageConsentForm } from "@/components/ImageConsentForm";
|
||||||
import { HoursEditor } from "@/components/HoursEditor";
|
import { HoursEditor } from "@/components/HoursEditor";
|
||||||
|
import { TagsField } from "@/components/TagsField";
|
||||||
|
import { MemberContactFields } from "@/components/MemberContactFields";
|
||||||
import { communeOptions } from "@/lib/communes";
|
import { communeOptions } from "@/lib/communes";
|
||||||
import {
|
import { unreadInformationCount } from "@/lib/informations";
|
||||||
saveImageConsent,
|
import { saveImageConsent, updateOwnProfile } from "../actions";
|
||||||
setOwnPromoShareTargets,
|
import { EspaceHeader } from "./EspaceHeader";
|
||||||
setOwnPromoSuspension,
|
|
||||||
updateOwnProfile,
|
|
||||||
} from "../actions";
|
|
||||||
import { MemberSpaceForm } from "./MemberSpaceForm";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
const PROMO_STATUS: Record<string, { label: string; bg: string; color: string }> = {
|
/** Espace adhérent, onglet Profil : fiche publique, inscriptions, droit à l'image. */
|
||||||
live: { label: "En ligne", bg: "#e6f4ec", color: "#1f8a5b" },
|
|
||||||
pending: { label: "En attente", bg: "#fbeede", color: "#9a6638" },
|
|
||||||
rejected: { label: "Refusée", bg: "#fbe9e6", color: "#d8472b" },
|
|
||||||
suspended: { label: "Suspendue", bg: "#fbe9e6", color: "#d8472b" },
|
|
||||||
expired: { label: "Expirée", bg: "#f1efe7", color: "#a99c82" },
|
|
||||||
};
|
|
||||||
|
|
||||||
function initialsOf(name: string) {
|
|
||||||
return name.split(/\s+/).map((w) => w[0]).join("").slice(0, 2).toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function EspacePage() {
|
export default async function EspacePage() {
|
||||||
const session = await getSession();
|
const session = await getSession();
|
||||||
const memberId = session?.user.memberId ?? null;
|
const memberId = session?.user.memberId ?? null;
|
||||||
const fallbackName = session?.user.name ?? "Adhérent";
|
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 memberName = fallbackName;
|
||||||
let subtitle = "Espace adhérent";
|
let subtitle = "Espace adhérent";
|
||||||
let profile: Member | null = null;
|
let profile: Member | null = null;
|
||||||
let myPromos: {
|
let livePromoCount = 0;
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
status: string;
|
|
||||||
createdAt: Date;
|
|
||||||
imageUrl: string | null;
|
|
||||||
suspendedBy: "member" | "staff" | null;
|
|
||||||
shareFacebook: boolean;
|
|
||||||
shareLinkedin: boolean;
|
|
||||||
}[] = [];
|
|
||||||
// Dernière tentative de publication par (promo, réseau).
|
|
||||||
let lastShare = new Map<string, { status: string; url: string | null; error: string | null }>();
|
|
||||||
let myRegistrations: { meetingId: number; title: string; startsAt: Date; location: string | null; participants: number; confirmed: boolean }[] = [];
|
let myRegistrations: { meetingId: number; title: string; startsAt: Date; location: string | null; participants: number; confirmed: boolean }[] = [];
|
||||||
let latestConsent: { decision: string; createdAt: Date } | null = null;
|
let latestConsent: { decision: string; createdAt: Date } | null = null;
|
||||||
|
let profileCategory: { slug: string; label: string } | null = null;
|
||||||
|
|
||||||
if (memberId) {
|
if (memberId) {
|
||||||
const [m] = await db.select().from(members).where(eq(members.id, memberId));
|
const [m] = await db.select().from(members).where(eq(members.id, memberId));
|
||||||
@@ -60,43 +39,16 @@ export default async function EspacePage() {
|
|||||||
profile = m;
|
profile = m;
|
||||||
memberName = m.name;
|
memberName = m.name;
|
||||||
const [cat] = m.categoryId
|
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(" · ");
|
subtitle = ["Espace adhérent", cat?.label, m.city].filter(Boolean).join(" · ");
|
||||||
}
|
}
|
||||||
myPromos = await db
|
const [live] = await db
|
||||||
.select({
|
.select({ total: sql<number>`count(*)` })
|
||||||
id: promotions.id,
|
|
||||||
title: promotions.title,
|
|
||||||
status: promotions.status,
|
|
||||||
createdAt: promotions.createdAt,
|
|
||||||
imageUrl: promotions.imageUrl,
|
|
||||||
suspendedBy: promotions.suspendedBy,
|
|
||||||
shareFacebook: promotions.shareFacebook,
|
|
||||||
shareLinkedin: promotions.shareLinkedin,
|
|
||||||
})
|
|
||||||
.from(promotions)
|
.from(promotions)
|
||||||
.where(eq(promotions.memberId, memberId))
|
.where(and(eq(promotions.memberId, memberId), eq(promotions.status, "live")));
|
||||||
.orderBy(desc(promotions.createdAt));
|
livePromoCount = Number(live?.total ?? 0);
|
||||||
if (myPromos.length > 0) {
|
|
||||||
const rows = await db
|
|
||||||
.select({
|
|
||||||
promotionId: socialPosts.promotionId,
|
|
||||||
network: socialPosts.network,
|
|
||||||
status: socialPosts.status,
|
|
||||||
url: socialPosts.url,
|
|
||||||
error: socialPosts.error,
|
|
||||||
})
|
|
||||||
.from(socialPosts)
|
|
||||||
.where(inArray(socialPosts.promotionId, myPromos.map((p) => p.id)))
|
|
||||||
.orderBy(desc(socialPosts.createdAt));
|
|
||||||
// Trié du plus récent au plus ancien : la première occurrence gagne.
|
|
||||||
lastShare = new Map();
|
|
||||||
for (const row of rows) {
|
|
||||||
const key = `${row.promotionId}:${row.network}`;
|
|
||||||
if (!lastShare.has(key)) lastShare.set(key, row);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
myRegistrations = await db
|
myRegistrations = await db
|
||||||
.select({
|
.select({
|
||||||
meetingId: meetings.id,
|
meetingId: meetings.id,
|
||||||
@@ -120,13 +72,6 @@ export default async function EspacePage() {
|
|||||||
latestConsent = consent ?? null;
|
latestConsent = consent ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const catRows = await db
|
|
||||||
.select({ label: categories.label })
|
|
||||||
.from(categories)
|
|
||||||
.orderBy(asc(categories.sort));
|
|
||||||
const categoryLabels = catRows.map((c) => c.label);
|
|
||||||
const networks = await configuredNetworks();
|
|
||||||
|
|
||||||
function fmtDate(d: Date) {
|
function fmtDate(d: Date) {
|
||||||
return new Date(d).toLocaleDateString("fr-FR", { day: "numeric", month: "long" });
|
return new Date(d).toLocaleDateString("fr-FR", { day: "numeric", month: "long" });
|
||||||
}
|
}
|
||||||
@@ -140,17 +85,14 @@ export default async function EspacePage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ background: "#13324F", borderRadius: 16, padding: "20px 24px", marginBottom: 22, display: "flex", alignItems: "center", gap: 15 }}>
|
<EspaceHeader
|
||||||
<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 }}>
|
memberName={memberName}
|
||||||
{initialsOf(memberName)}
|
subtitle={subtitle}
|
||||||
</span>
|
active="profil"
|
||||||
<div>
|
publicPath={profile && profile.status === "active" ? memberPath(profile) : null}
|
||||||
<div className="font-display" style={{ fontWeight: 700, fontSize: 18, color: "#fff" }}>
|
promoBadge={livePromoCount}
|
||||||
{memberName}
|
infoBadge={infoBadge}
|
||||||
</div>
|
/>
|
||||||
<div style={{ fontSize: 13, color: "#9bb6cd" }}>{subtitle}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{memberId && (
|
{memberId && (
|
||||||
<section style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 24, marginBottom: 28 }}>
|
<section style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 24, marginBottom: 28 }}>
|
||||||
@@ -194,6 +136,13 @@ export default async function EspacePage() {
|
|||||||
<label className="field-label">Nom</label>
|
<label className="field-label">Nom</label>
|
||||||
<input name="name" required defaultValue={profile.name} className="field" />
|
<input name="name" required defaultValue={profile.name} className="field" />
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="field-label">E-mail de contact (affiché sur la fiche)</label>
|
||||||
|
<input name="contactEmail" type="email" defaultValue={profile.contactEmail ?? ""} className="field" placeholder={profile.email} />
|
||||||
|
<p className="field-hint">
|
||||||
|
Indépendant de votre identifiant de connexion. Laissé vide, la fiche affiche l'e-mail de votre compte.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="field-label">Commune</label>
|
<label className="field-label">Commune</label>
|
||||||
<select name="city" className="field" defaultValue={profile.city ?? ""}>
|
<select name="city" className="field" defaultValue={profile.city ?? ""}>
|
||||||
@@ -223,6 +172,12 @@ export default async function EspacePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<MemberContactFields
|
||||||
|
firstName={profile.contactFirstName}
|
||||||
|
lastName={profile.contactLastName}
|
||||||
|
phone={profile.contactPhone}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="grid grid-2" style={{ gap: 16, marginTop: 16 }}>
|
<div className="grid grid-2" style={{ gap: 16, marginTop: 16 }}>
|
||||||
<ImageField name="coverUrl" label="Image d'entête (bannière)" defaultValue={profile.coverUrl ?? ""} height={130} />
|
<ImageField name="coverUrl" label="Image d'entête (bannière)" defaultValue={profile.coverUrl ?? ""} height={130} />
|
||||||
<ImageField name="logoUrl" label="Logo" defaultValue={profile.logoUrl ?? ""} height={130} fit="contain" />
|
<ImageField name="logoUrl" label="Logo" defaultValue={profile.logoUrl ?? ""} height={130} fit="contain" />
|
||||||
@@ -231,11 +186,15 @@ export default async function EspacePage() {
|
|||||||
<div style={{ marginTop: 16 }}>
|
<div style={{ marginTop: 16 }}>
|
||||||
<label className="field-label">Description (une ligne vide sépare les paragraphes)</label>
|
<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" }} />
|
<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>
|
||||||
|
|
||||||
<div style={{ marginTop: 16 }}>
|
<div style={{ marginTop: 16 }}>
|
||||||
<label className="field-label">Tags / spécialités (séparés par des virgules)</label>
|
<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>
|
||||||
|
|
||||||
<div style={{ marginTop: 16 }}>
|
<div style={{ marginTop: 16 }}>
|
||||||
@@ -280,159 +239,6 @@ export default async function EspacePage() {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<MemberSpaceForm memberName={memberName} categories={categoryLabels} networks={networks} />
|
|
||||||
|
|
||||||
<div style={{ marginTop: 28 }}>
|
|
||||||
<div style={{ fontSize: 12.5, letterSpacing: "0.08em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 700, marginBottom: 4 }}>
|
|
||||||
Mes promotions
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 13, color: "#8c8068", marginBottom: 12 }}>
|
|
||||||
Vous pouvez suspendre une promotion en ligne à tout moment : elle disparaît du site et
|
|
||||||
vous la remettez en ligne quand vous voulez.
|
|
||||||
</div>
|
|
||||||
{myPromos.length === 0 && (
|
|
||||||
<div style={{ fontSize: 13.5, color: "#a99c82" }}>Vous n'avez pas encore publié de promotion.</div>
|
|
||||||
)}
|
|
||||||
{myPromos.map((mp) => {
|
|
||||||
const st = PROMO_STATUS[mp.status] ?? PROMO_STATUS.expired;
|
|
||||||
const suspendedByStaff = mp.status === "suspended" && mp.suspendedBy === "staff";
|
|
||||||
return (
|
|
||||||
<div key={mp.id} style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 12, padding: "12px 14px", marginBottom: 10 }}>
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 13 }}>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
width: 42,
|
|
||||||
height: 42,
|
|
||||||
borderRadius: 9,
|
|
||||||
background: mp.imageUrl
|
|
||||||
? `url(${mp.imageUrl})`
|
|
||||||
: "repeating-linear-gradient(45deg,#efe9da,#efe9da 7px,#e6ddc9 7px,#e6ddc9 14px)",
|
|
||||||
backgroundSize: "cover",
|
|
||||||
backgroundPosition: "center",
|
|
||||||
flexShrink: 0,
|
|
||||||
opacity: mp.status === "suspended" ? 0.5 : 1,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<div style={{ fontSize: 14, fontWeight: 700, color: "#26201a", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
|
||||||
{mp.title}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 12, color: "#a99c82" }}>Publié le {fmtDate(mp.createdAt)}</div>
|
|
||||||
</div>
|
|
||||||
<span style={{ display: "inline-block", fontSize: 11.5, fontWeight: 700, padding: "4px 11px", borderRadius: 999, background: st.bg, color: st.color, flexShrink: 0 }}>
|
|
||||||
{st.label}
|
|
||||||
</span>
|
|
||||||
{mp.status === "live" && (
|
|
||||||
<form action={setOwnPromoSuspension}>
|
|
||||||
<input type="hidden" name="id" value={mp.id} />
|
|
||||||
<input type="hidden" name="action" value="suspend" />
|
|
||||||
<button type="submit" style={{ border: "1px solid #e0c3bb", background: "#fff", color: "#d8472b", fontWeight: 700, fontSize: 12.5, padding: "7px 13px", borderRadius: 9, cursor: "pointer", whiteSpace: "nowrap" }}>
|
|
||||||
Suspendre
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
{mp.status === "suspended" && !suspendedByStaff && (
|
|
||||||
<form action={setOwnPromoSuspension}>
|
|
||||||
<input type="hidden" name="id" value={mp.id} />
|
|
||||||
<input type="hidden" name="action" value="restore" />
|
|
||||||
<button type="submit" style={{ border: "none", background: "#1f8a5b", color: "#fff", fontWeight: 700, fontSize: 12.5, padding: "8px 14px", borderRadius: 9, cursor: "pointer", whiteSpace: "nowrap" }}>
|
|
||||||
Remettre en ligne
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{suspendedByStaff && (
|
|
||||||
<div style={{ marginTop: 10, background: "#fbe9e6", border: "1px solid #f2d5cf", color: "#a8503c", borderRadius: 9, padding: "9px 11px", fontSize: 12.5, lineHeight: 1.5 }}>
|
|
||||||
Cette promotion a été suspendue par l'association. Contactez-la pour la
|
|
||||||
remettre en ligne.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{networks.length > 0 && (
|
|
||||||
<PromoShareState
|
|
||||||
promo={mp}
|
|
||||||
networks={networks}
|
|
||||||
lastShare={lastShare}
|
|
||||||
editable={mp.status === "pending"}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* État de diffusion d'une promo côté adhérent : modifiable tant qu'elle est en
|
|
||||||
* attente, purement informatif dès qu'elle est validée.
|
|
||||||
*/
|
|
||||||
function PromoShareState({
|
|
||||||
promo,
|
|
||||||
networks,
|
|
||||||
lastShare,
|
|
||||||
editable,
|
|
||||||
}: {
|
|
||||||
promo: { id: number; shareFacebook: boolean; shareLinkedin: boolean };
|
|
||||||
networks: SocialNetwork[];
|
|
||||||
lastShare: Map<string, { status: string; url: string | null; error: string | null }>;
|
|
||||||
editable: boolean;
|
|
||||||
}) {
|
|
||||||
const wanted = (n: SocialNetwork) => (n === "facebook" ? promo.shareFacebook : promo.shareLinkedin);
|
|
||||||
const chosen = networks.filter(wanted);
|
|
||||||
|
|
||||||
if (editable) {
|
|
||||||
return (
|
|
||||||
<form action={setOwnPromoShareTargets} style={{ marginTop: 11, borderTop: "1px solid #f0e8d6", paddingTop: 11 }}>
|
|
||||||
<input type="hidden" name="id" value={promo.id} />
|
|
||||||
<div style={{ fontSize: 11.5, color: "#8c8068", marginBottom: 8, lineHeight: 1.5 }}>
|
|
||||||
Diffusion prévue après validation — modifiable tant que la promotion est en attente.
|
|
||||||
</div>
|
|
||||||
<div style={{ display: "flex", gap: 10, flexWrap: "wrap", alignItems: "center" }}>
|
|
||||||
{networks.map((network) => (
|
|
||||||
<label key={network} style={{ display: "inline-flex", alignItems: "center", gap: 7, fontSize: 12.5, fontWeight: 700, color: SOCIAL_BRAND[network], cursor: "pointer" }}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
name={network === "facebook" ? "shareFacebook" : "shareLinkedin"}
|
|
||||||
defaultChecked={wanted(network)}
|
|
||||||
style={{ accentColor: SOCIAL_BRAND[network], margin: 0 }}
|
|
||||||
/>
|
|
||||||
<SocialIcon network={network} size={15} />
|
|
||||||
{SOCIAL_LABELS[network]}
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
<button type="submit" style={{ border: "1px solid #d8cdb4", background: "#fff", color: "#6c6150", fontWeight: 700, fontSize: 12, padding: "6px 12px", borderRadius: 8, cursor: "pointer" }}>
|
|
||||||
Mettre à jour
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chosen.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ marginTop: 11, borderTop: "1px solid #f0e8d6", paddingTop: 11, display: "flex", gap: 8, flexWrap: "wrap" }}>
|
|
||||||
{chosen.map((network) => {
|
|
||||||
const last = lastShare.get(`${promo.id}:${network}`);
|
|
||||||
const posted = last?.status === "posted";
|
|
||||||
const failed = last?.status === "failed";
|
|
||||||
const detail = posted ? "publié" : failed ? "échec de publication" : "en attente";
|
|
||||||
const color = posted ? "#1f8a5b" : failed ? "#d8472b" : "#9a6638";
|
|
||||||
const chip = (
|
|
||||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 7, background: "#faf7ef", border: "1px solid #f0e8d6", borderRadius: 999, padding: "5px 12px", fontSize: 11.5, fontWeight: 700, color }}>
|
|
||||||
<SocialIcon network={network} size={13} />
|
|
||||||
{SOCIAL_LABELS[network]} · {detail}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
return posted && last?.url ? (
|
|
||||||
<a key={network} href={last.url} target="_blank" rel="noopener noreferrer" style={{ textDecoration: "none" }}>
|
|
||||||
{chip}
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<span key={network}>{chip}</span>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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'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'association. {scheduleLabel(mp.publishAt)} — site et réseaux en même temps.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{suspendedByStaff && (
|
||||||
|
<div style={{ marginTop: 10, background: "#fbe9e6", border: "1px solid #f2d5cf", color: "#a8503c", borderRadius: 9, padding: "9px 11px", fontSize: 12.5, lineHeight: 1.5 }}>
|
||||||
|
Cette promotion a été suspendue par l'association. Contactez-la pour la remettre en ligne.
|
||||||
|
</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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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'est encore visible dans l'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { desc, eq } from "drizzle-orm";
|
import { desc, eq } from "drizzle-orm";
|
||||||
import { getSession } from "@/lib/session";
|
import { getSession } from "@/lib/session";
|
||||||
@@ -5,11 +6,19 @@ import { db } from "@/db";
|
|||||||
import { contactMessages, imageConsents, membershipRequests, promotions } from "@/db/schema";
|
import { contactMessages, imageConsents, membershipRequests, promotions } from "@/db/schema";
|
||||||
import { ImageConsentForm } from "@/components/ImageConsentForm";
|
import { ImageConsentForm } from "@/components/ImageConsentForm";
|
||||||
import { isStaff } from "@/lib/rbac";
|
import { isStaff } from "@/lib/rbac";
|
||||||
|
import { unreadInformationCount } from "@/lib/informations";
|
||||||
import { saveImageConsent } from "./actions";
|
import { saveImageConsent } from "./actions";
|
||||||
import { BackendShell } from "./BackendShell";
|
import { BackendShell } from "./BackendShell";
|
||||||
|
import { NOINDEX } from "@/lib/seo";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
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({
|
export default async function BackendLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
@@ -62,6 +71,11 @@ export default async function BackendLayout({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 pendingCount = 0;
|
||||||
let inboxCount = 0;
|
let inboxCount = 0;
|
||||||
if (isStaff(session.user.role)) {
|
if (isStaff(session.user.role)) {
|
||||||
@@ -79,6 +93,7 @@ export default async function BackendLayout({
|
|||||||
user={{ name: session.user.name ?? "Adhérent", role: session.user.role }}
|
user={{ name: session.user.name ?? "Adhérent", role: session.user.role }}
|
||||||
pendingCount={pendingCount}
|
pendingCount={pendingCount}
|
||||||
inboxCount={inboxCount}
|
inboxCount={inboxCount}
|
||||||
|
infoCount={infoCount}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</BackendShell>
|
</BackendShell>
|
||||||
|
|||||||
+57
-15
@@ -3,10 +3,17 @@ import { redirect } from "next/navigation";
|
|||||||
import { desc, eq } from "drizzle-orm";
|
import { desc, eq } from "drizzle-orm";
|
||||||
import { getSession } from "@/lib/session";
|
import { getSession } from "@/lib/session";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { activityLog, contactMessages, members, membershipRequests, promotions } from "@/db/schema";
|
import { activityLog, contactMessages, members, membershipRequests, promotions, socialPosts } from "@/db/schema";
|
||||||
import { activityNodes } from "@/lib/activity";
|
import { activityNodes } from "@/lib/activity";
|
||||||
import { can, isStaff } from "@/lib/rbac";
|
import { can, isStaff } from "@/lib/rbac";
|
||||||
import { expiryStatus, getSocialAccounts, SOCIAL_LABELS } from "@/lib/social-accounts";
|
import { countVisiblePromotions } from "@/lib/queries";
|
||||||
|
import {
|
||||||
|
expiryStatus,
|
||||||
|
getSocialAccounts,
|
||||||
|
SOCIAL_LABELS,
|
||||||
|
tokenHealthCached,
|
||||||
|
type SocialNetwork,
|
||||||
|
} from "@/lib/social-accounts";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -42,7 +49,7 @@ export default async function DashboardPage() {
|
|||||||
|
|
||||||
const [activeMembers, livePromos, pendingPromos, requests, newMessages, activity] = await Promise.all([
|
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: 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: promotions.id }).from(promotions).where(eq(promotions.status, "pending")),
|
||||||
db.select({ id: membershipRequests.id }).from(membershipRequests).where(eq(membershipRequests.status, "new")),
|
db.select({ id: membershipRequests.id }).from(membershipRequests).where(eq(membershipRequests.status, "new")),
|
||||||
db.select({ id: contactMessages.id }).from(contactMessages).where(eq(contactMessages.status, "new")),
|
db.select({ id: contactMessages.id }).from(contactMessages).where(eq(contactMessages.status, "new")),
|
||||||
@@ -51,14 +58,47 @@ export default async function DashboardPage() {
|
|||||||
|
|
||||||
const pendingCount = pendingPromos.length;
|
const pendingCount = pendingPromos.length;
|
||||||
|
|
||||||
// Jetons réseaux à renouveler : sans alerte, on ne découvre l'expiration
|
// Jetons réseaux à renouveler. Deux signaux, car une date d'expiration ne
|
||||||
// qu'au moment où une publication échoue.
|
// suffit pas : un jeton de page Facebook n'expire jamais mais peut être
|
||||||
const expiringNetworks = can(session?.user.role, "manageSettings")
|
// révoqué, et cela ne se voit qu'à la première publication en échec.
|
||||||
? (await getSocialAccounts())
|
const expiringNetworks: { network: SocialNetwork; status: string }[] = [];
|
||||||
.filter((a) => a.accessToken && a.targetId)
|
if (can(session?.user.role, "manageSettings")) {
|
||||||
.map((a) => ({ network: a.network, status: expiryStatus(a.expiresAt) }))
|
const connected = (await getSocialAccounts()).filter((a) => a.accessToken && a.targetId);
|
||||||
.filter((a) => a.status === "soon" || a.status === "expired")
|
|
||||||
: [];
|
// 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) {
|
function timeAgo(date: Date) {
|
||||||
const diff = Date.now() - new Date(date).getTime();
|
const diff = Date.now() - new Date(date).getTime();
|
||||||
@@ -90,9 +130,11 @@ export default async function DashboardPage() {
|
|||||||
{expiringNetworks.map((n) => (
|
{expiringNetworks.map((n) => (
|
||||||
<div key={n.network}>
|
<div key={n.network}>
|
||||||
<strong>{SOCIAL_LABELS[n.network]}</strong>{" "}
|
<strong>{SOCIAL_LABELS[n.network]}</strong>{" "}
|
||||||
{n.status === "expired"
|
{n.status === "broken"
|
||||||
? "— le jeton a expiré, les publications échoueront."
|
? "— la dernière publication a été refusée : le jeton ne fonctionne plus."
|
||||||
: "— le jeton expire dans moins de 7 jours."}{" "}
|
: n.status === "expired"
|
||||||
|
? "— le jeton a expiré, les publications échoueront."
|
||||||
|
: "— le jeton expire dans moins de 7 jours."}{" "}
|
||||||
Reconnecter le compte →
|
Reconnecter le compte →
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -101,7 +143,7 @@ export default async function DashboardPage() {
|
|||||||
|
|
||||||
<div className="grid grid-4" style={{ marginBottom: 26 }}>
|
<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="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" />
|
<StatCard label="À modérer" value={pendingCount} hint="en attente de validation" valueColor="#9a6638" hintColor="#9a6638" />
|
||||||
<Link href="/backend/demandes" style={{ textDecoration: "none" }}>
|
<Link href="/backend/demandes" style={{ textDecoration: "none" }}>
|
||||||
<StatCard
|
<StatCard
|
||||||
|
|||||||
@@ -98,6 +98,32 @@ export default async function ParametresPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section style={panel}>
|
||||||
|
<h2 className="font-display" style={title}>Référencement & mesure d'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'est
|
||||||
|
posée sur le site. La mesure d'audience ne couvre que les pages publiques — ni le
|
||||||
|
backoffice, ni l'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'activer.
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<button type="submit" style={submitButton}>Enregistrer les paramètres</button>
|
<button type="submit" style={submitButton}>Enregistrer les paramètres</button>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ import { can } from "@/lib/rbac";
|
|||||||
import { configuredNetworks, SOCIAL_LABELS } from "@/lib/social";
|
import { configuredNetworks, SOCIAL_LABELS } from "@/lib/social";
|
||||||
import { moderatePromo, retryPromoShare } from "../actions";
|
import { moderatePromo, retryPromoShare } from "../actions";
|
||||||
import { PromoImage } from "@/components/PromoImage";
|
import { PromoImage } from "@/components/PromoImage";
|
||||||
|
import { MemberAvatar } from "@/components/MemberAvatar";
|
||||||
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
|
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";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -20,6 +23,7 @@ const STATUS_CHIP: Record<string, { label: string; bg: string; color: string }>
|
|||||||
pending: { label: "En attente", bg: "#fbeede", color: "#9a6638" },
|
pending: { label: "En attente", bg: "#fbeede", color: "#9a6638" },
|
||||||
live: { label: "En ligne", bg: "#e6f4ec", color: "#1f8a5b" },
|
live: { label: "En ligne", bg: "#e6f4ec", color: "#1f8a5b" },
|
||||||
suspended: { label: "Suspendue", bg: "#fbe9e6", color: "#d8472b" },
|
suspended: { label: "Suspendue", bg: "#fbe9e6", color: "#d8472b" },
|
||||||
|
scheduled: { label: "Programmée", bg: "#eaf0f6", color: "#2C6FB3" },
|
||||||
};
|
};
|
||||||
|
|
||||||
function fmtDate(d: Date) {
|
function fmtDate(d: Date) {
|
||||||
@@ -41,23 +45,30 @@ export default async function PromotionsPage() {
|
|||||||
text: promotions.text,
|
text: promotions.text,
|
||||||
category: promotions.category,
|
category: promotions.category,
|
||||||
status: promotions.status,
|
status: promotions.status,
|
||||||
|
publishAt: promotions.publishAt,
|
||||||
|
validUntil: promotions.validUntil,
|
||||||
|
startsOn: promotions.startsOn,
|
||||||
|
endsOn: promotions.endsOn,
|
||||||
imageUrl: promotions.imageUrl,
|
imageUrl: promotions.imageUrl,
|
||||||
suspendedBy: promotions.suspendedBy,
|
suspendedBy: promotions.suspendedBy,
|
||||||
suspendedAt: promotions.suspendedAt,
|
suspendedAt: promotions.suspendedAt,
|
||||||
shareFacebook: promotions.shareFacebook,
|
shareFacebook: promotions.shareFacebook,
|
||||||
shareLinkedin: promotions.shareLinkedin,
|
shareLinkedin: promotions.shareLinkedin,
|
||||||
memberName: members.name,
|
memberName: members.name,
|
||||||
|
memberLogoUrl: members.logoUrl,
|
||||||
})
|
})
|
||||||
.from(promotions)
|
.from(promotions)
|
||||||
.leftJoin(members, eq(promotions.memberId, members.id))
|
.leftJoin(members, eq(promotions.memberId, members.id))
|
||||||
.where(inArray(promotions.status, ["pending", "live", "suspended"]))
|
.where(inArray(promotions.status, ["pending", "live", "suspended", "scheduled"]))
|
||||||
.orderBy(desc(promotions.createdAt));
|
.orderBy(desc(promotions.createdAt));
|
||||||
|
|
||||||
const pending = rows.filter((r) => r.status === "pending");
|
const pending = rows.filter((r) => r.status === "pending");
|
||||||
const live = rows.filter((r) => r.status === "live");
|
const live = rows.filter((r) => r.status === "live");
|
||||||
const suspended = rows.filter((r) => r.status === "suspended");
|
const suspended = rows.filter((r) => r.status === "suspended");
|
||||||
// En attente d'abord, puis en ligne, puis suspendues.
|
const scheduled = rows.filter((r) => r.status === "scheduled");
|
||||||
const ordered = [...pending, ...live, ...suspended];
|
// 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
|
const shares = ordered.length
|
||||||
? await db
|
? await db
|
||||||
@@ -90,6 +101,9 @@ export default async function PromotionsPage() {
|
|||||||
<span style={{ background: "#13324F", color: "#fff", fontSize: 13, fontWeight: 600, padding: "8px 16px", borderRadius: 999 }}>
|
<span style={{ background: "#13324F", color: "#fff", fontSize: 13, fontWeight: 600, padding: "8px 16px", borderRadius: 999 }}>
|
||||||
En attente · {pending.length}
|
En attente · {pending.length}
|
||||||
</span>
|
</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 }}>
|
<span style={{ background: "#fff", border: "1px solid #e6dcc6", color: "#6c6150", fontSize: 13, fontWeight: 600, padding: "8px 16px", borderRadius: 999 }}>
|
||||||
En ligne · {live.length}
|
En ligne · {live.length}
|
||||||
</span>
|
</span>
|
||||||
@@ -115,6 +129,7 @@ export default async function PromotionsPage() {
|
|||||||
{ordered.map((p) => {
|
{ordered.map((p) => {
|
||||||
const isPending = p.status === "pending";
|
const isPending = p.status === "pending";
|
||||||
const isSuspended = p.status === "suspended";
|
const isSuspended = p.status === "suspended";
|
||||||
|
const isScheduled = p.status === "scheduled";
|
||||||
const chip = STATUS_CHIP[p.status] ?? STATUS_CHIP.pending;
|
const chip = STATUS_CHIP[p.status] ?? STATUS_CHIP.pending;
|
||||||
const chosenNetworks = networks.filter((n) =>
|
const chosenNetworks = networks.filter((n) =>
|
||||||
n === "facebook" ? p.shareFacebook : p.shareLinkedin
|
n === "facebook" ? p.shareFacebook : p.shareLinkedin
|
||||||
@@ -169,13 +184,34 @@ export default async function PromotionsPage() {
|
|||||||
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 15.5, margin: "0 0 4px", color: "#26201a" }}>
|
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 15.5, margin: "0 0 4px", color: "#26201a" }}>
|
||||||
{p.title}
|
{p.title}
|
||||||
</h3>
|
</h3>
|
||||||
<div style={{ fontSize: 12.5, color: "#9a6638", fontWeight: 600, marginBottom: 8 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 8 }}>
|
||||||
{p.memberName}
|
<MemberAvatar name={p.memberName ?? ""} logoUrl={p.memberLogoUrl} size={24} />
|
||||||
|
<span style={{ fontSize: 12.5, color: "#9a6638", fontWeight: 600 }}>{p.memberName}</span>
|
||||||
</div>
|
</div>
|
||||||
<p style={{ margin: "0 0 12px", fontSize: 12.5, color: "#8c8068", lineHeight: 1.45, flex: 1 }}>
|
<p style={{ margin: "0 0 12px", fontSize: 12.5, color: "#8c8068", lineHeight: 1.45, flex: 1 }}>
|
||||||
{p.text}
|
{p.text}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* Le modérateur voit la période de validité avant de valider :
|
||||||
|
c'est elle qui partira dans le message réseaux sociaux. */}
|
||||||
|
{formatValidity(p) && (
|
||||||
|
<div style={{ fontSize: 12, color: "#9a8d72", fontWeight: 600, margin: "0 0 12px" }}>
|
||||||
|
{formatValidity(p)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{p.status === "live" && visibilityNote(p) && (
|
||||||
|
<div style={{ background: "#faf7ef", border: "1px solid #f0e8d6", color: "#9a8d72", borderRadius: 10, padding: "9px 11px", fontSize: 12, lineHeight: 1.45, marginBottom: 11 }}>
|
||||||
|
{visibilityNote(p)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isScheduled && (
|
||||||
|
<div style={{ background: "#eaf0f6", border: "1px solid #d3e0ee", color: "#2C6FB3", borderRadius: 10, padding: "9px 11px", fontSize: 12, lineHeight: 1.45, marginBottom: 11, fontWeight: 600 }}>
|
||||||
|
{scheduleLabel(p.publishAt)}. Rien n'est encore visible ni diffusé.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isSuspended && (
|
{isSuspended && (
|
||||||
<div style={{ background: "#fbe9e6", border: "1px solid #f2d5cf", color: "#a8503c", borderRadius: 10, padding: "9px 11px", fontSize: 12, lineHeight: 1.45, marginBottom: 11 }}>
|
<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"}
|
Suspendue par {p.suspendedBy === "member" ? "l'adhérent" : "l'association"}
|
||||||
@@ -210,10 +246,30 @@ export default async function PromotionsPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 11, color: "#a99c82", marginTop: 7, lineHeight: 1.45 }}>
|
<div style={{ fontSize: 11, color: "#a99c82", marginTop: 7, lineHeight: 1.45 }}>
|
||||||
Publié dès la validation. Ce choix n'est plus modifiable ensuite.
|
Diffusé en même temps que la mise en ligne : tout de suite si le
|
||||||
|
champ Publication est vide, à la date indiquée sinon. Ce choix
|
||||||
|
n'est plus modifiable ensuite.
|
||||||
</div>
|
</div>
|
||||||
</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" }}>
|
<button type="submit" style={{ width: "100%", border: "none", background: "#1f8a5b", color: "#fff", fontWeight: 700, fontSize: 13, padding: 10, borderRadius: 9, cursor: "pointer" }}>
|
||||||
Valider
|
Valider
|
||||||
</button>
|
</button>
|
||||||
@@ -230,6 +286,16 @@ export default async function PromotionsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isScheduled && (
|
||||||
|
<form action={moderatePromo} style={{ marginBottom: 8 }}>
|
||||||
|
<input type="hidden" name="id" value={p.id} />
|
||||||
|
<input type="hidden" name="action" value="publish-now" />
|
||||||
|
<button type="submit" style={{ width: "100%", border: "none", background: "#2C6FB3", color: "#fff", fontWeight: 700, fontSize: 13, padding: 10, borderRadius: 9, cursor: "pointer" }}>
|
||||||
|
Publier maintenant
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
{!isPending && (
|
{!isPending && (
|
||||||
<div style={{ display: "flex", gap: 8 }}>
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
<form action={moderatePromo} style={{ flex: 1 }}>
|
<form action={moderatePromo} style={{ flex: 1 }}>
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ import { meetingRegistrations, meetings } from "@/db/schema";
|
|||||||
import { ImageField } from "@/components/ImageField";
|
import { ImageField } from "@/components/ImageField";
|
||||||
import { MeetingEmailComposer } from "@/components/MeetingEmailComposer";
|
import { MeetingEmailComposer } from "@/components/MeetingEmailComposer";
|
||||||
import { can } from "@/lib/rbac";
|
import { can } from "@/lib/rbac";
|
||||||
import { getSiteSettings } from "@/lib/site-settings";
|
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";
|
import { createMeeting, deleteMeeting, updateMeeting } from "../actions";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -23,7 +26,8 @@ export default async function RencontresAdminPage() {
|
|||||||
const session = await getSession();
|
const session = await getSession();
|
||||||
if (!can(session?.user.role, "manageMeetings")) redirect("/backend");
|
if (!can(session?.user.role, "manageMeetings")) redirect("/backend");
|
||||||
|
|
||||||
const [rows, settings] = await Promise.all([
|
const canSend = can(session?.user.role, "manageEmails");
|
||||||
|
const [rows, settings, choices, mailReady, base] = await Promise.all([
|
||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
id: meetings.id,
|
id: meetings.id,
|
||||||
@@ -41,14 +45,11 @@ export default async function RencontresAdminPage() {
|
|||||||
.groupBy(meetings.id)
|
.groupBy(meetings.id)
|
||||||
.orderBy(desc(meetings.startsAt)),
|
.orderBy(desc(meetings.startsAt)),
|
||||||
getSiteSettings(),
|
getSiteSettings(),
|
||||||
|
audienceOptions(),
|
||||||
|
isMailConfigured(),
|
||||||
|
siteUrl(),
|
||||||
]);
|
]);
|
||||||
const emailBrand = {
|
const brand = emailBrand(settings);
|
||||||
associationName: settings.association_name,
|
|
||||||
address: settings.association_address,
|
|
||||||
email: settings.association_email,
|
|
||||||
phone: settings.association_phone,
|
|
||||||
siret: settings.association_siret,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "grid", gap: 28 }}>
|
<div style={{ display: "grid", gap: 28 }}>
|
||||||
@@ -139,7 +140,15 @@ export default async function RencontresAdminPage() {
|
|||||||
capacity: meeting.capacity,
|
capacity: meeting.capacity,
|
||||||
registered: Number(meeting.registered),
|
registered: Number(meeting.registered),
|
||||||
}}
|
}}
|
||||||
brand={emailBrand}
|
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 }}>
|
<form action={deleteMeeting} style={{ marginTop: 8 }}>
|
||||||
<input type="hidden" name="id" value={meeting.id} />
|
<input type="hidden" name="id" value={meeting.id} />
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
|
import { originFromHeaders } from "@/lib/site-url";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
import { getSession } from "@/lib/session";
|
import { getSession } from "@/lib/session";
|
||||||
import { can } from "@/lib/rbac";
|
import { can } from "@/lib/rbac";
|
||||||
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
|
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
|
||||||
import {
|
import {
|
||||||
|
checkTokenHealth,
|
||||||
expiryStatus,
|
expiryStatus,
|
||||||
getSocialAccounts,
|
getSocialAccounts,
|
||||||
listStoredTargets,
|
listStoredTargets,
|
||||||
@@ -13,6 +15,7 @@ import {
|
|||||||
SOCIAL_LABELS,
|
SOCIAL_LABELS,
|
||||||
SOCIAL_NETWORKS,
|
SOCIAL_NETWORKS,
|
||||||
type SocialTarget,
|
type SocialTarget,
|
||||||
|
type TokenHealth,
|
||||||
} from "@/lib/social-accounts";
|
} from "@/lib/social-accounts";
|
||||||
import { disconnectSocial, saveSitePublicUrl, saveSocialApp, selectSocialTarget } from "../actions";
|
import { disconnectSocial, saveSitePublicUrl, saveSocialApp, selectSocialTarget } from "../actions";
|
||||||
|
|
||||||
@@ -20,7 +23,14 @@ export const dynamic = "force-dynamic";
|
|||||||
|
|
||||||
const HELP: Record<
|
const HELP: Record<
|
||||||
(typeof SOCIAL_NETWORKS)[number],
|
(typeof SOCIAL_NETWORKS)[number],
|
||||||
{ portal: string; steps: string[]; caution?: string }
|
{
|
||||||
|
portal: string;
|
||||||
|
steps: string[];
|
||||||
|
caution?: string;
|
||||||
|
tools?: { label: string; href: string }[];
|
||||||
|
/** Les deux liens utiles au moment de renouveler un jeton. */
|
||||||
|
renew: { note: string; links: { label: string; hint: string; href: string }[] };
|
||||||
|
}
|
||||||
> = {
|
> = {
|
||||||
facebook: {
|
facebook: {
|
||||||
portal: "https://developers.facebook.com/apps",
|
portal: "https://developers.facebook.com/apps",
|
||||||
@@ -29,6 +39,29 @@ const HELP: Record<
|
|||||||
"Ajoutez le produit « Connexion Facebook » puis collez l'URL de redirection ci-dessous dans « URI de redirection OAuth valides ».",
|
"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.",
|
"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: {
|
linkedin: {
|
||||||
portal: "https://www.linkedin.com/developers/apps",
|
portal: "https://www.linkedin.com/developers/apps",
|
||||||
@@ -38,7 +71,28 @@ const HELP: Record<
|
|||||||
"Onglet Produits : demandez « Community Management API », indispensable pour publier au nom de la page.",
|
"Onglet Produits : demandez « Community Management API », indispensable pour publier au nom de la page.",
|
||||||
],
|
],
|
||||||
caution:
|
caution:
|
||||||
"LinkedIn soumet cette demande à une revue (page vérifiée, nom légal, adresse, politique de confidentialité) et ses jetons expirent au bout de 60 jours : il faudra recliquer sur Reconnecter environ tous les deux mois.",
|
"LinkedIn soumet cette demande à une revue (page vérifiée, nom légal, adresse, politique de confidentialité) et ses jetons expirent au bout de 60 jours : il faudra recliquer sur « Renouveler le jeton » environ tous les deux mois.",
|
||||||
|
renew: {
|
||||||
|
note:
|
||||||
|
"Les jetons LinkedIn expirent au bout de 60 jours : le bouton relance l'autorisation et " +
|
||||||
|
"repart pour deux mois. Les deux liens ci-dessous permettent de contrôler l'échéance.",
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
label: "Jetons de votre application",
|
||||||
|
hint: "voir la durée de validité restante des jetons émis",
|
||||||
|
href: "https://www.linkedin.com/developers/tools/oauth",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Documentation : durée de vie des jetons",
|
||||||
|
hint: "règles de rafraîchissement et conditions d'accès",
|
||||||
|
href: "https://learn.microsoft.com/linkedin/shared/authentication/programmatic-refresh-tokens",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
tools: [
|
||||||
|
{ label: "Jetons de votre application (durée de validité)", href: "https://www.linkedin.com/developers/tools/oauth" },
|
||||||
|
{ label: "Documentation : durée de vie et rafraîchissement des jetons", href: "https://learn.microsoft.com/linkedin/shared/authentication/programmatic-refresh-tokens" },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -61,10 +115,7 @@ export default async function ReseauxPage({
|
|||||||
|
|
||||||
// Adresse par laquelle l'administrateur consulte cette page : proposée par
|
// Adresse par laquelle l'administrateur consulte cette page : proposée par
|
||||||
// défaut pour lui éviter de la recopier à la main.
|
// défaut pour lui éviter de la recopier à la main.
|
||||||
const requestHeaders = await headers();
|
const detected = originFromHeaders(await headers());
|
||||||
const host = requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host") ?? "";
|
|
||||||
const protocol = requestHeaders.get("x-forwarded-proto") ?? (host.startsWith("localhost") ? "http" : "https");
|
|
||||||
const detected = host ? `${protocol}://${host}` : "";
|
|
||||||
|
|
||||||
// Calculées ici : le rendu des cartes n'est pas asynchrone.
|
// Calculées ici : le rendu des cartes n'est pas asynchrone.
|
||||||
const redirectUris = new Map<string, string>();
|
const redirectUris = new Map<string, string>();
|
||||||
@@ -72,6 +123,16 @@ export default async function ReseauxPage({
|
|||||||
redirectUris.set(network, await redirectUri(network));
|
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.
|
// Cibles à proposer quand le compte administre plusieurs pages.
|
||||||
const targets = new Map<string, SocialTarget[]>();
|
const targets = new Map<string, SocialTarget[]>();
|
||||||
let targetError: string | null = null;
|
let targetError: string | null = null;
|
||||||
@@ -124,8 +185,9 @@ export default async function ReseauxPage({
|
|||||||
</form>
|
</form>
|
||||||
{!base && detected && (
|
{!base && detected && (
|
||||||
<div style={{ marginTop: 10, fontSize: 12.5, color: "#9a6638" }}>
|
<div style={{ marginTop: 10, fontSize: 12.5, color: "#9a6638" }}>
|
||||||
Adresse détectée depuis votre navigation : <strong>{detected}</strong>. Vérifiez-la puis
|
Aucune adresse enregistrée : celle de votre navigation, <strong>{detected}</strong>, est
|
||||||
enregistrez.
|
utilisée par défaut. Facebook et LinkedIn n'acceptent qu'une adresse de retour
|
||||||
|
en <code>https</code> sur un nom de domaine : vérifiez-la puis enregistrez.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!base && !detected && (
|
{!base && !detected && (
|
||||||
@@ -141,6 +203,11 @@ export default async function ReseauxPage({
|
|||||||
const status = expiryStatus(account?.expiresAt);
|
const status = expiryStatus(account?.expiresAt);
|
||||||
const isConnected = !!account?.accessToken && !!account.targetId;
|
const isConnected = !!account?.accessToken && !!account.targetId;
|
||||||
const candidates = targets.get(network) ?? [];
|
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 (
|
return (
|
||||||
<section key={network} style={panel}>
|
<section key={network} style={panel}>
|
||||||
@@ -163,7 +230,7 @@ export default async function ReseauxPage({
|
|||||||
<h2 className="font-display" style={{ ...title, margin: 0, flex: 1 }}>
|
<h2 className="font-display" style={{ ...title, margin: 0, flex: 1 }}>
|
||||||
{SOCIAL_LABELS[network]}
|
{SOCIAL_LABELS[network]}
|
||||||
</h2>
|
</h2>
|
||||||
<StatusChip connected={isConnected} status={status} />
|
<StatusChip connected={isConnected} status={broken ? "expired" : status} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isConnected && (
|
{isConnected && (
|
||||||
@@ -173,15 +240,30 @@ export default async function ReseauxPage({
|
|||||||
{account?.expiresAt && status !== "never" && ` Jeton valable jusqu'au ${fmtDate(account.expiresAt)}.`}
|
{account?.expiresAt && status !== "never" && ` Jeton valable jusqu'au ${fmtDate(account.expiresAt)}.`}
|
||||||
{account?.connectedAt && ` Connecté le ${fmtDate(account.connectedAt)}.`}
|
{account?.connectedAt && ` Connecté le ${fmtDate(account.connectedAt)}.`}
|
||||||
<br />
|
<br />
|
||||||
Pour changer de page, relancez une connexion.
|
{tokenHealth?.ok && (
|
||||||
|
<span style={{ color: "#1f8a5b", fontWeight: 600 }}>
|
||||||
|
✓ Jeton vérifié à l'instant auprès de {SOCIAL_LABELS[network]} : il fonctionne.{" "}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
Pour changer de page ou renouveler l'accès, utilisez le bouton ci-dessous.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{broken && (
|
||||||
|
<Banner tone="error">
|
||||||
|
Le jeton {SOCIAL_LABELS[network]} ne fonctionne plus : les publications échoueront
|
||||||
|
tant que vous n'aurez pas cliqué sur « Renouveler le jeton ».
|
||||||
|
<div style={{ marginTop: 6, fontSize: 12, opacity: 0.85 }}>
|
||||||
|
Réponse de la plateforme : {tokenHealth.reason.slice(0, 220)}
|
||||||
|
</div>
|
||||||
|
</Banner>
|
||||||
|
)}
|
||||||
|
|
||||||
{(status === "soon" || status === "expired") && isConnected && (
|
{(status === "soon" || status === "expired") && isConnected && (
|
||||||
<Banner tone={status === "expired" ? "error" : "warn"}>
|
<Banner tone={status === "expired" ? "error" : "warn"}>
|
||||||
{status === "expired"
|
{status === "expired"
|
||||||
? `Le jeton ${SOCIAL_LABELS[network]} a expiré : les publications échoueront tant que vous n'aurez pas reconnecté le compte.`
|
? `Le jeton ${SOCIAL_LABELS[network]} a expiré : les publications échoueront tant que vous ne l'aurez pas renouvelé.`
|
||||||
: `Le jeton ${SOCIAL_LABELS[network]} expire bientôt. Un clic sur Reconnecter suffit à le renouveler.`}
|
: `Le jeton ${SOCIAL_LABELS[network]} expire bientôt. Un clic sur « Renouveler le jeton » suffit.`}
|
||||||
</Banner>
|
</Banner>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -234,24 +316,91 @@ export default async function ReseauxPage({
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div style={{ display: "flex", gap: 10, flexWrap: "wrap", borderTop: "1px solid #f0e8d6", paddingTop: 14 }}>
|
{/* Renouvellement : l'action et ses deux liens de contrôle, visibles
|
||||||
<a
|
sans avoir à déplier l'aide de configuration. */}
|
||||||
href={`/api/social/${network}/connect`}
|
{isConnected && (
|
||||||
className="font-display"
|
<div
|
||||||
style={{
|
style={{
|
||||||
textDecoration: "none",
|
background: needsRenewal ? "#fbeede" : "#faf7ef",
|
||||||
border: "none",
|
border: `1px solid ${needsRenewal ? "#ecd8b8" : "#f0e8d6"}`,
|
||||||
background: account?.appId ? SOCIAL_BRAND[network] : "#d8cdb4",
|
borderRadius: 12,
|
||||||
color: "#fff",
|
padding: 15,
|
||||||
fontWeight: 700,
|
marginBottom: 16,
|
||||||
fontSize: 14,
|
|
||||||
padding: "12px 20px",
|
|
||||||
borderRadius: 10,
|
|
||||||
pointerEvents: account?.appId ? undefined : "none",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isConnected ? "Reconnecter" : "Connecter"}
|
<div className="field-label" style={{ marginBottom: 4 }}>
|
||||||
</a>
|
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 && (
|
{isConnected && (
|
||||||
<form action={disconnectSocial}>
|
<form action={disconnectSocial}>
|
||||||
<input type="hidden" name="network" value={network} />
|
<input type="hidden" name="network" value={network} />
|
||||||
@@ -277,7 +426,28 @@ export default async function ReseauxPage({
|
|||||||
{help.portal}
|
{help.portal}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: 10 }}>
|
{help.tools && (
|
||||||
|
<div style={{ marginTop: 14 }}>
|
||||||
|
<div className="field-label">Outils pour inspecter un jeton</div>
|
||||||
|
<ul style={{ margin: "6px 0 0", paddingLeft: 20, fontSize: 13, lineHeight: 1.8 }}>
|
||||||
|
{help.tools.map((tool) => (
|
||||||
|
<li key={tool.href}>
|
||||||
|
<a href={tool.href} target="_blank" rel="noopener noreferrer" style={{ color: "#2C6FB3", fontWeight: 600 }}>
|
||||||
|
{tool.label}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div style={{ marginTop: 8, fontSize: 12.5, color: "#8c8068", lineHeight: 1.6 }}>
|
||||||
|
Ces outils servent à contrôler ou dépanner : le bouton
|
||||||
|
<strong> Connecter</strong> fait déjà l'échange complet — jeton court,
|
||||||
|
puis jeton de longue durée, puis jeton de page — vous n'avez aucun jeton à
|
||||||
|
copier à la main.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ marginTop: 14 }}>
|
||||||
<div className="field-label">URL de redirection à déclarer</div>
|
<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" }}>
|
<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 —"}
|
{base ? redirectUris.get(network) : "— renseignez d'abord l'URL publique du site ci-dessus —"}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { LegalPage } from "@/components/LegalPage";
|
import { LegalPage } from "@/components/LegalPage";
|
||||||
import { getSiteSettings } from "@/lib/site-settings";
|
import { getSiteSettings } from "@/lib/site-settings";
|
||||||
|
import { pageMetadata } from "@/lib/seo";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = pageMetadata({
|
||||||
title: "Confidentialité — Plein R",
|
title: "Politique de confidentialité",
|
||||||
description: "Politique de confidentialité et traitement des données personnelles par Plein R.",
|
description: "Politique de confidentialité et traitement des données personnelles par l'association Plein R.",
|
||||||
};
|
path: "/confidentialite",
|
||||||
|
});
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
|||||||
+348
-6
@@ -379,15 +379,15 @@ button {
|
|||||||
/* Boutons sociaux libellés : plus lisibles que deux icônes isolées, et ils
|
/* Boutons sociaux libellés : plus lisibles que deux icônes isolées, et ils
|
||||||
donnent enfin du contenu à la quatrième colonne. */
|
donnent enfin du contenu à la quatrième colonne. */
|
||||||
.footer-social {
|
.footer-social {
|
||||||
display: flex;
|
/* Grille à une colonne dimensionnée sur son contenu : tous les boutons
|
||||||
flex-direction: column;
|
prennent la largeur du plus long au lieu de s'étirer sur la colonne ou de
|
||||||
/* Sans cela les boutons s'étirent sur toute la colonne et deviennent des
|
former un escalier. */
|
||||||
pavés vides. */
|
display: inline-grid;
|
||||||
align-items: flex-start;
|
grid-template-columns: minmax(150px, max-content);
|
||||||
gap: 9px;
|
gap: 9px;
|
||||||
}
|
}
|
||||||
.footer-social a {
|
.footer-social a {
|
||||||
display: inline-flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 9px 13px;
|
padding: 9px 13px;
|
||||||
@@ -850,6 +850,81 @@ img {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Backend sans barre latérale (espace adhérent) ----
|
||||||
|
Un adhérent n'a que les trois onglets de son espace : une barre latérale
|
||||||
|
les répéterait. `.backend` étant une rangée flex, la variante repasse en
|
||||||
|
bloc pour que l'entête et le contenu s'empilent simplement. */
|
||||||
|
.backend--plain {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
/* L'entête s'aligne sur la colonne de contenu : sans cela le logo collerait
|
||||||
|
au bord de l'écran pendant que le contenu reste centré. */
|
||||||
|
.backend-header--plain {
|
||||||
|
padding: 14px max(24px, calc((100% - 1040px) / 2));
|
||||||
|
}
|
||||||
|
.backend-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 11px;
|
||||||
|
margin-right: auto;
|
||||||
|
min-width: 0;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.backend-brand__mark {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 5px;
|
||||||
|
border-radius: 11px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e6dcc6;
|
||||||
|
}
|
||||||
|
.backend-brand__text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
line-height: 1.15;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.backend-brand__name {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--ink-strong);
|
||||||
|
}
|
||||||
|
.backend-brand__sub {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--muted-3);
|
||||||
|
}
|
||||||
|
/* Sans barre latérale, il n'y a plus de tiroir où loger les actions sur
|
||||||
|
téléphone : elles restent dans l'entête à toutes les largeurs. */
|
||||||
|
.backend-header--plain .backend-header__actions {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.backend-content--plain {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1040px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.backend-header--plain {
|
||||||
|
padding: 11px 16px;
|
||||||
|
}
|
||||||
|
.backend-content--plain {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Le nom de marque s'efface avant les actions : le logo suffit à situer la
|
||||||
|
page, deux boutons tronqués ne servent à rien. */
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.backend-brand__text {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.backend-header--plain .backend-action {
|
||||||
|
padding: 8px 11px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 10px;
|
width: 10px;
|
||||||
height: 10px;
|
height: 10px;
|
||||||
@@ -948,6 +1023,231 @@ img {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
/* Onglets de l'espace adhérent (Profil / Promotions), sous le bandeau. */
|
||||||
|
.espace-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e6dcc6;
|
||||||
|
border-top: none;
|
||||||
|
border-radius: 0 0 16px 16px;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
.espace-tab {
|
||||||
|
flex: 1;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 11px 14px;
|
||||||
|
border-radius: 11px;
|
||||||
|
font-family: "Bricolage Grotesque", "Public Sans", system-ui, sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 14.5px;
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.15s ease, color 0.15s ease;
|
||||||
|
}
|
||||||
|
.espace-tab:hover {
|
||||||
|
background: #faf7ef;
|
||||||
|
color: var(--brown);
|
||||||
|
}
|
||||||
|
.espace-tab.active {
|
||||||
|
background: var(--gold);
|
||||||
|
color: #33291D;
|
||||||
|
}
|
||||||
|
.espace-tab__badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
padding: 0 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #1f8a5b;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.espace-tab.active .espace-tab__badge {
|
||||||
|
background: #33291D;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
/* Corps d'une information : rendu par `richTextNodes`, donc uniquement des
|
||||||
|
p / ul / ol / li / strong / em / a / br. Le rythme vertical vit ici plutôt
|
||||||
|
qu'en styles en ligne, parce qu'il porte sur des balises engendrées. */
|
||||||
|
.rich-text {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
.rich-text > *:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.rich-text > *:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.rich-text p {
|
||||||
|
margin: 0 0 11px;
|
||||||
|
}
|
||||||
|
.rich-text h4 {
|
||||||
|
font-family: "Bricolage Grotesque", "Public Sans", system-ui, sans-serif;
|
||||||
|
font-size: 15.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ink-strong);
|
||||||
|
margin: 16px 0 8px;
|
||||||
|
}
|
||||||
|
.rich-text ul,
|
||||||
|
.rich-text ol {
|
||||||
|
margin: 0 0 11px;
|
||||||
|
padding-left: 21px;
|
||||||
|
}
|
||||||
|
.rich-text li {
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
.rich-text strong {
|
||||||
|
color: var(--ink-strong);
|
||||||
|
}
|
||||||
|
.rich-text a {
|
||||||
|
color: var(--blue);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Éditeur visuel des informations : ce qu'on voit est ce qui sera publié. La
|
||||||
|
zone reprend `.rich-text` pour le rythme des paragraphes et des listes, de
|
||||||
|
sorte que l'éditeur est son propre aperçu. */
|
||||||
|
.rich-editor-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
border: 1px solid var(--field-border);
|
||||||
|
border-bottom: none;
|
||||||
|
border-radius: 10px 10px 0 0;
|
||||||
|
background: #fff;
|
||||||
|
padding: 6px 7px;
|
||||||
|
}
|
||||||
|
.rich-editor-tool {
|
||||||
|
min-width: 30px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 9px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s ease, color 0.12s ease;
|
||||||
|
}
|
||||||
|
.rich-editor-tool:hover {
|
||||||
|
background: var(--cream-3);
|
||||||
|
color: var(--ink-strong);
|
||||||
|
}
|
||||||
|
.rich-editor-tool.is-active {
|
||||||
|
background: var(--gold);
|
||||||
|
color: #33291d;
|
||||||
|
}
|
||||||
|
.rich-editor-separator {
|
||||||
|
width: 1px;
|
||||||
|
height: 18px;
|
||||||
|
margin: 0 5px;
|
||||||
|
background: var(--border-2);
|
||||||
|
}
|
||||||
|
.rich-editor-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
border: 1px solid var(--field-border);
|
||||||
|
border-bottom: none;
|
||||||
|
background: var(--cream);
|
||||||
|
padding: 8px 9px;
|
||||||
|
}
|
||||||
|
.rich-editor-link .field {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 180px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.rich-editor {
|
||||||
|
position: relative;
|
||||||
|
min-height: 190px;
|
||||||
|
border: 1px solid var(--field-border);
|
||||||
|
border-radius: 0 0 10px 10px;
|
||||||
|
background: var(--field-bg);
|
||||||
|
padding: 13px 15px;
|
||||||
|
overflow-y: auto;
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
.rich-editor:focus {
|
||||||
|
outline: 2px solid rgba(44, 111, 179, 0.35);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
/* Le texte d'invite s'efface dès la première frappe : `:empty` ne suffit pas,
|
||||||
|
le navigateur laisse un paragraphe vide dans la zone. Il est posé en
|
||||||
|
superposition et non à la place du paragraphe : masquer celui-ci ferait
|
||||||
|
tomber le curseur à côté, et le navigateur écrirait du texte nu hors de tout
|
||||||
|
bloc. */
|
||||||
|
.rich-editor[data-empty="true"]::before {
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
position: absolute;
|
||||||
|
left: 15px;
|
||||||
|
right: 15px;
|
||||||
|
top: 13px;
|
||||||
|
color: var(--muted-4);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rédaction d'une information : éditeur et listes côte à côte, empilés dès
|
||||||
|
que la colonne de droite n'a plus la place d'être lisible. */
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.informations-split {
|
||||||
|
grid-template-columns: minmax(0, 1fr) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* Suggestions de tags : pastilles cliquables sous le champ. */
|
||||||
|
.tag-suggestions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.tag-suggestions__label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--muted-2);
|
||||||
|
margin-right: 2px;
|
||||||
|
}
|
||||||
|
.tag-suggestion {
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 5px 11px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px dashed #d8cdb4;
|
||||||
|
background: #faf7ef;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
transition: background 0.15s ease, border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
.tag-suggestion:hover {
|
||||||
|
background: #f6efdc;
|
||||||
|
border-color: var(--gold);
|
||||||
|
}
|
||||||
|
.tag-suggestion--all {
|
||||||
|
border-style: solid;
|
||||||
|
color: var(--brown);
|
||||||
|
}
|
||||||
|
/* Conseil de rédaction sous un champ : discret, mais lu avant de saisir. */
|
||||||
|
.field-hint {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--muted-2);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- Studio e-mail Plein R ---- */
|
/* ---- Studio e-mail Plein R ---- */
|
||||||
.email-workbench {
|
.email-workbench {
|
||||||
@@ -1312,6 +1612,48 @@ img {
|
|||||||
color: var(--muted-4);
|
color: var(--muted-4);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
/* Référent de l'adhérent : rendu seulement quand le visiteur est connecté —
|
||||||
|
le HTML public ne contient jamais ce bloc, rien n'est masqué en CSS. */
|
||||||
|
.contact-referent {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.contact-referent__label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
color: var(--muted-2);
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.contact-referent__name {
|
||||||
|
margin-top: 7px;
|
||||||
|
color: var(--ink-strong);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.contact-referent__phone {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 3px;
|
||||||
|
color: var(--blue);
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.contact-referent__phone:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.contact-referent__hint {
|
||||||
|
margin-top: 7px;
|
||||||
|
color: var(--muted-4);
|
||||||
|
font-size: 11.5px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- Éditeur hebdomadaire des horaires ---- */
|
/* ---- Éditeur hebdomadaire des horaires ---- */
|
||||||
.hours-editor {
|
.hours-editor {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { and, asc, eq, gte, sql } from "drizzle-orm";
|
import { and, asc, eq, gte, sql } from "drizzle-orm";
|
||||||
import { getSession } from "@/lib/session";
|
import { getSession } from "@/lib/session";
|
||||||
@@ -6,9 +7,16 @@ import { SiteFooter } from "@/components/SiteFooter";
|
|||||||
import { SiteHeader } from "@/components/SiteHeader";
|
import { SiteHeader } from "@/components/SiteHeader";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { meetingRegistrations, meetings, members } from "@/db/schema";
|
import { meetingRegistrations, meetings, members } from "@/db/schema";
|
||||||
|
import { NOINDEX } from "@/lib/seo";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
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) {
|
function formatDate(date: Date) {
|
||||||
return new Intl.DateTimeFormat("fr-FR", {
|
return new Intl.DateTimeFormat("fr-FR", {
|
||||||
weekday: "long",
|
weekday: "long",
|
||||||
|
|||||||
+90
-8
@@ -1,4 +1,20 @@
|
|||||||
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";
|
import "./globals.css";
|
||||||
|
|
||||||
// Rendu dynamique pour toutes les pages : la CSP à nonce du middleware ne peut
|
// Rendu dynamique pour toutes les pages : la CSP à nonce du middleware ne peut
|
||||||
@@ -6,17 +22,80 @@ import "./globals.css";
|
|||||||
// interrogent de toute façon la base.
|
// interrogent de toute façon la base.
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const viewport: Viewport = {
|
||||||
title: "Plein R — Commerçants & entreprises du Bassin de Pompey",
|
width: "device-width",
|
||||||
description:
|
initialScale: 1,
|
||||||
"Association des commerçants et entreprises du Bassin de Pompey. Réseau · Rencontre · Réussite.",
|
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,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
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 (
|
return (
|
||||||
<html lang="fr">
|
<html lang="fr">
|
||||||
<head>
|
<head>
|
||||||
@@ -30,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"
|
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"
|
rel="stylesheet"
|
||||||
/>
|
/>
|
||||||
<link rel="icon" href="/assets/logo.png" type="image/png" />
|
|
||||||
{/* Fallback: if JS is disabled, never keep revealed content hidden. */}
|
{/* Fallback: if JS is disabled, never keep revealed content hidden. */}
|
||||||
<noscript>
|
<noscript>
|
||||||
<style>{`.reveal,.reveal-stagger>*{opacity:1!important}`}</style>
|
<style>{`.reveal,.reveal-stagger>*{opacity:1!important}`}</style>
|
||||||
</noscript>
|
</noscript>
|
||||||
</head>
|
</head>
|
||||||
<body>{children}</body>
|
<body>
|
||||||
|
{children}
|
||||||
|
<JsonLd data={structuredData} />
|
||||||
|
<Analytics measurementId={settings?.google_analytics_id ?? ""} />
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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" }],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { LegalPage } from "@/components/LegalPage";
|
import { LegalPage } from "@/components/LegalPage";
|
||||||
import { getSiteSettings } from "@/lib/site-settings";
|
import { getSiteSettings } from "@/lib/site-settings";
|
||||||
|
import { pageMetadata } from "@/lib/seo";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = pageMetadata({
|
||||||
title: "Mentions légales — Plein R",
|
title: "Mentions légales",
|
||||||
description: "Mentions légales du site de l'association Plein R.",
|
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";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
|||||||
@@ -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'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'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'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'accueil
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<SiteFooter />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
<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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
+27
-105
@@ -1,3 +1,4 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Sparkle } from "@/components/Sparkle";
|
import { Sparkle } from "@/components/Sparkle";
|
||||||
import { Reveal } from "@/components/Reveal";
|
import { Reveal } from "@/components/Reveal";
|
||||||
@@ -6,26 +7,31 @@ import { SiteFooter } from "@/components/SiteFooter";
|
|||||||
import { MembershipModalButton } from "@/components/MembershipModalButton";
|
import { MembershipModalButton } from "@/components/MembershipModalButton";
|
||||||
import { MembersCarousel } from "@/components/MembersCarousel";
|
import { MembersCarousel } from "@/components/MembersCarousel";
|
||||||
import { StarField } from "@/components/StarField";
|
import { StarField } from "@/components/StarField";
|
||||||
import { PromoImage } from "@/components/PromoImage";
|
import { PromoCard } from "@/components/PromoCard";
|
||||||
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
|
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
|
||||||
import { getLivePromotions, getRotatingActiveMembers } from "@/lib/queries";
|
import { getActiveMemberCount, getLivePromotions, getRotatingActiveMembers } from "@/lib/queries";
|
||||||
import { getSiteSettings, socialLinks } from "@/lib/site-settings";
|
import { getSiteSettings, socialLinks } from "@/lib/site-settings";
|
||||||
|
import { SITE_DESCRIPTION, SITE_TITLE, pageMetadata } from "@/lib/seo";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
const STRIPE_WARM =
|
export const metadata: Metadata = {
|
||||||
"repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)";
|
...pageMetadata({
|
||||||
|
title: SITE_TITLE,
|
||||||
function badgeColor(badge: string | null) {
|
ogTitle: SITE_TITLE,
|
||||||
if (!badge) return "#1f8a5b";
|
description: SITE_DESCRIPTION,
|
||||||
return badge.includes("%") ? "#d8472b" : "#1f8a5b";
|
path: "/",
|
||||||
}
|
}),
|
||||||
|
// Titre absolu : la page d'accueil ne prend pas le suffixe « · Plein R ».
|
||||||
|
title: { absolute: SITE_TITLE },
|
||||||
|
};
|
||||||
|
|
||||||
export default async function AccueilPage() {
|
export default async function AccueilPage() {
|
||||||
const [promos, rotatingMembers, settings] = await Promise.all([
|
const [promos, rotatingMembers, settings, memberCount] = await Promise.all([
|
||||||
getLivePromotions(6),
|
getLivePromotions(6),
|
||||||
getRotatingActiveMembers(),
|
getRotatingActiveMembers(),
|
||||||
getSiteSettings(),
|
getSiteSettings(),
|
||||||
|
getActiveMemberCount(),
|
||||||
]);
|
]);
|
||||||
const socials = socialLinks(settings);
|
const socials = socialLinks(settings);
|
||||||
|
|
||||||
@@ -40,7 +46,7 @@ export default async function AccueilPage() {
|
|||||||
>
|
>
|
||||||
<SiteHeader active="accueil" />
|
<SiteHeader active="accueil" />
|
||||||
|
|
||||||
<div className="container">
|
<main className="container">
|
||||||
{/* hero */}
|
{/* hero */}
|
||||||
<section
|
<section
|
||||||
className="grid hero-grid"
|
className="grid hero-grid"
|
||||||
@@ -52,7 +58,7 @@ export default async function AccueilPage() {
|
|||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
src="/assets/logo.png"
|
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"
|
className="floatlogo"
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
@@ -101,10 +107,10 @@ export default async function AccueilPage() {
|
|||||||
className="font-display"
|
className="font-display"
|
||||||
style={{ fontWeight: 600, fontSize: 17, color: "#9a6638", margin: "0 0 16px" }}
|
style={{ fontWeight: 600, fontSize: 17, color: "#9a6638", margin: "0 0 16px" }}
|
||||||
>
|
>
|
||||||
Association des commerçants & entreprises du Bassin de Pompey
|
Association des commerçants & entreprises de service du Bassin de Pompey
|
||||||
</div>
|
</div>
|
||||||
<p style={{ fontSize: 17, lineHeight: 1.6, color: "#6c6150", margin: "0 0 24px", maxWidth: 520 }}>
|
<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é.
|
leurs bons plans et faites vivre le commerce de proximité.
|
||||||
</p>
|
</p>
|
||||||
<form
|
<form
|
||||||
@@ -212,99 +218,14 @@ export default async function AccueilPage() {
|
|||||||
Offres, réductions et nouveautés publiées par les commerçants du réseau.
|
Offres, réductions et nouveautés publiées par les commerçants du réseau.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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 →
|
Toutes les promos →
|
||||||
</a>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Reveal className="grid grid-3 reveal-stagger" style={{ marginTop: 26 }}>
|
<Reveal className="grid grid-3 reveal-stagger" style={{ marginTop: 26 }}>
|
||||||
{promos.map((p) => (
|
{promos.map((p) => (
|
||||||
<Link
|
<PromoCard key={p.id} promo={p} />
|
||||||
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'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>
|
|
||||||
))}
|
))}
|
||||||
</Reveal>
|
</Reveal>
|
||||||
</section>
|
</section>
|
||||||
@@ -437,8 +358,9 @@ export default async function AccueilPage() {
|
|||||||
Faites partie de l'aventure Plein R
|
Faites partie de l'aventure Plein R
|
||||||
</h2>
|
</h2>
|
||||||
<p style={{ margin: "0 auto 22px", fontSize: 15.5, color: "#f4e6d3", maxWidth: 480 }}>
|
<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
|
{memberCount > 0
|
||||||
ensemble.
|
? `Rejoignez les ${memberCount} commerçants et entreprises de service adhérents qui font réseau, se rencontrent et réussissent ensemble.`
|
||||||
|
: "Rejoignez les commerçants et entreprises de service qui font réseau, se rencontrent et réussissent ensemble."}
|
||||||
</p>
|
</p>
|
||||||
<MembershipModalButton
|
<MembershipModalButton
|
||||||
label="Devenir adhérent"
|
label="Devenir adhérent"
|
||||||
@@ -456,7 +378,7 @@ export default async function AccueilPage() {
|
|||||||
/>
|
/>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</main>
|
||||||
|
|
||||||
<SiteFooter />
|
<SiteFooter />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Sparkle } from "@/components/Sparkle";
|
||||||
|
import { SiteHeader } from "@/components/SiteHeader";
|
||||||
|
import { SiteFooter } from "@/components/SiteFooter";
|
||||||
|
import { MembershipModalButton } from "@/components/MembershipModalButton";
|
||||||
|
import { PromoCard } from "@/components/PromoCard";
|
||||||
|
import { JsonLd } from "@/components/JsonLd";
|
||||||
|
import { getLivePromotions } from "@/lib/queries";
|
||||||
|
import { breadcrumbJsonLd, memberListJsonLd, pageMetadata } from "@/lib/seo";
|
||||||
|
import { publicBaseUrl } from "@/lib/seo-server";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export const metadata: Metadata = pageMetadata({
|
||||||
|
title: "Bons plans et promotions du Bassin de Pompey",
|
||||||
|
description:
|
||||||
|
"Toutes les offres en cours chez les commerçants, artisans et entreprises adhérents Plein R : " +
|
||||||
|
"réductions, nouveautés et bons plans à Pompey, Frouard, Liverdun, Champigneulles…",
|
||||||
|
path: "/promotions",
|
||||||
|
});
|
||||||
|
|
||||||
|
export default async function PromotionsPage() {
|
||||||
|
// `null` : toutes les offres en cours, là où l'accueil n'en montre que six.
|
||||||
|
const [promos, baseUrl] = await Promise.all([getLivePromotions(null), publicBaseUrl()]);
|
||||||
|
|
||||||
|
// Une offre pointe vers la fiche de son commerçant : la liste structurée
|
||||||
|
// renvoie donc vers les adhérents, pas vers des URLs de promotion.
|
||||||
|
const listed = promos
|
||||||
|
.filter((p) => p.memberId != null)
|
||||||
|
.map((p) => ({ id: p.memberId as number, name: p.memberName ?? "", city: p.memberCity }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ background: "#F6F2E8", minHeight: "100vh", fontFamily: "'Public Sans',sans-serif", color: "#33291D" }}>
|
||||||
|
<SiteHeader active="promotions" logo />
|
||||||
|
<JsonLd
|
||||||
|
data={[
|
||||||
|
breadcrumbJsonLd(baseUrl, [
|
||||||
|
{ name: "Accueil", path: "/" },
|
||||||
|
{ name: "Promotions", path: "/promotions" },
|
||||||
|
]),
|
||||||
|
memberListJsonLd(baseUrl, "Promotions en cours chez les adhérents Plein R", listed),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<main className="container" style={{ paddingBottom: 56 }}>
|
||||||
|
{/* title block */}
|
||||||
|
<section style={{ position: "relative", padding: "14px 0 22px", overflow: "hidden" }}>
|
||||||
|
<Sparkle color="#E0A63C" size={18} style={{ top: 22, right: 30 }} duration={3.2} />
|
||||||
|
<Sparkle color="#6FB0C6" size={12} style={{ top: 60, right: 90 }} duration={2.7} delay={0.5} />
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 9,
|
||||||
|
background: "#fff",
|
||||||
|
border: "1px solid #e6dcc6",
|
||||||
|
color: "#9a6638",
|
||||||
|
fontSize: 12,
|
||||||
|
letterSpacing: "0.12em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
fontWeight: 800,
|
||||||
|
padding: "6px 13px",
|
||||||
|
borderRadius: 999,
|
||||||
|
marginBottom: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ width: 7, height: 7, borderRadius: "50%", background: "#E0A63C" }} />
|
||||||
|
{promos.length} offre{promos.length > 1 ? "s" : ""} en cours
|
||||||
|
</div>
|
||||||
|
<h1 className="font-display" style={{ fontWeight: 800, fontSize: "clamp(26px, 6vw, 40px)", letterSpacing: "-0.02em", margin: "0 0 8px", color: "#26201a" }}>
|
||||||
|
Les bons plans de nos adhérents
|
||||||
|
</h1>
|
||||||
|
<p style={{ margin: 0, fontSize: 16.5, color: "#6c6150", maxWidth: 560 }}>
|
||||||
|
Toutes les offres en cours chez les commerçants, artisans et entreprises du réseau.
|
||||||
|
Chaque promotion renvoie vers la fiche de son commerce.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{promos.length === 0 ? (
|
||||||
|
<section
|
||||||
|
style={{
|
||||||
|
background: "#fff",
|
||||||
|
border: "1px solid #e6dcc6",
|
||||||
|
borderRadius: 18,
|
||||||
|
padding: "38px 24px",
|
||||||
|
textAlign: "center",
|
||||||
|
color: "#8c8068",
|
||||||
|
fontSize: 15,
|
||||||
|
lineHeight: 1.7,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Aucune promotion en cours pour le moment.
|
||||||
|
<br />
|
||||||
|
<Link href="/annuaire" style={{ color: "#9a6638", fontWeight: 700 }}>
|
||||||
|
Parcourir l'annuaire des adhérents →
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-3" style={{ gap: 20 }}>
|
||||||
|
{promos.map((p) => (
|
||||||
|
<PromoCard key={p.id} promo={p} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* join CTA */}
|
||||||
|
<section
|
||||||
|
style={{
|
||||||
|
marginTop: 34,
|
||||||
|
position: "relative",
|
||||||
|
background: "linear-gradient(120deg,#13324F,#1d4a72)",
|
||||||
|
borderRadius: 20,
|
||||||
|
padding: "34px 40px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 28,
|
||||||
|
overflow: "hidden",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Sparkle color="#E0A63C" size={18} style={{ top: 20, right: 40 }} duration={3.2} />
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 12.5, letterSpacing: "0.12em", textTransform: "uppercase", color: "#9fc6e6", fontWeight: 700, marginBottom: 8 }}>
|
||||||
|
Vous êtes commerçant du Bassin de Pompey ?
|
||||||
|
</div>
|
||||||
|
<h2 className="font-display" style={{ fontWeight: 800, fontSize: 26, margin: 0, color: "#fff" }}>
|
||||||
|
Publiez vos offres sur Plein R
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<MembershipModalButton
|
||||||
|
label="Adhérer à l'association"
|
||||||
|
className="font-display"
|
||||||
|
style={{ border: "none", background: "#E0A63C", color: "#33291D", fontWeight: 700, fontSize: 15.5, padding: "14px 26px", borderRadius: 12, whiteSpace: "nowrap" }}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<SiteFooter />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,21 +1,34 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
import { asc, desc, inArray } from "drizzle-orm";
|
import { asc, desc, inArray } from "drizzle-orm";
|
||||||
|
import { JsonLd } from "@/components/JsonLd";
|
||||||
import { PastMeetingGallery } from "@/components/PastMeetingGallery";
|
import { PastMeetingGallery } from "@/components/PastMeetingGallery";
|
||||||
import { SiteFooter } from "@/components/SiteFooter";
|
import { SiteFooter } from "@/components/SiteFooter";
|
||||||
import { SiteHeader } from "@/components/SiteHeader";
|
import { SiteHeader } from "@/components/SiteHeader";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { meetingRegistrations, pastMeetingPhotos, pastMeetings } from "@/db/schema";
|
import { meetingRegistrations, pastMeetingPhotos, pastMeetings } from "@/db/schema";
|
||||||
import { splitLines } from "@/lib/site-settings";
|
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 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) {
|
function formatDate(date: Date) {
|
||||||
return new Intl.DateTimeFormat("fr-FR", { day: "numeric", month: "long", year: "numeric" }).format(date);
|
return new Intl.DateTimeFormat("fr-FR", { day: "numeric", month: "long", year: "numeric" }).format(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function PastMeetingsPage() {
|
export default async function PastMeetingsPage() {
|
||||||
const [archives, photos] = await Promise.all([
|
const [archives, photos, baseUrl] = await Promise.all([
|
||||||
db.select().from(pastMeetings).orderBy(desc(pastMeetings.eventDate)),
|
db.select().from(pastMeetings).orderBy(desc(pastMeetings.eventDate)),
|
||||||
db.select().from(pastMeetingPhotos).orderBy(asc(pastMeetingPhotos.position), asc(pastMeetingPhotos.id)),
|
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 linkedIds = archives.map((archive) => archive.meetingId).filter((id): id is number => id !== null);
|
||||||
const linkedRegistrations = linkedIds.length > 0
|
const linkedRegistrations = linkedIds.length > 0
|
||||||
@@ -37,6 +50,13 @@ export default async function PastMeetingsPage() {
|
|||||||
return (
|
return (
|
||||||
<div style={{ background: "#F6F2E8", minHeight: "100vh", fontFamily: "'Public Sans',sans-serif", color: "#33291D" }}>
|
<div style={{ background: "#F6F2E8", minHeight: "100vh", fontFamily: "'Public Sans',sans-serif", color: "#33291D" }}>
|
||||||
<SiteHeader active="association" logo />
|
<SiteHeader active="association" logo />
|
||||||
|
<JsonLd
|
||||||
|
data={breadcrumbJsonLd(baseUrl, [
|
||||||
|
{ name: "Accueil", path: "/" },
|
||||||
|
{ name: "L'association", path: "/association" },
|
||||||
|
{ name: "Rencontres passées", path: "/rencontres-passees" },
|
||||||
|
])}
|
||||||
|
/>
|
||||||
<main>
|
<main>
|
||||||
<section style={{ background: "#13324F", color: "#fff" }}>
|
<section style={{ background: "#13324F", color: "#fff" }}>
|
||||||
<div className="container" style={{ paddingTop: 58, paddingBottom: 54 }}>
|
<div className="container" style={{ paddingTop: 58, paddingBottom: 54 }}>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { headers } from "next/headers";
|
||||||
|
import { gtagConfigScript, gtagSrc, isTrackedPath, normalizeMeasurementId } from "@/lib/analytics";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Balises Google Analytics 4, posées uniquement si l'identifiant de mesure est
|
||||||
|
* renseigné dans Backend › Paramètres et si la page est publique.
|
||||||
|
*
|
||||||
|
* Les deux scripts portent le nonce de la CSP (`src/middleware.ts`) ; le
|
||||||
|
* chemin est lu dans l'en-tête `x-pathname` que le middleware recopie, un
|
||||||
|
* layout n'ayant pas accès à l'URL demandée.
|
||||||
|
*/
|
||||||
|
export async function Analytics({ measurementId }: { measurementId: string }) {
|
||||||
|
const id = normalizeMeasurementId(measurementId);
|
||||||
|
if (!id) return null;
|
||||||
|
|
||||||
|
const requestHeaders = await headers();
|
||||||
|
if (!isTrackedPath(requestHeaders.get("x-pathname") ?? "/")) return null;
|
||||||
|
const nonce = requestHeaders.get("x-nonce") ?? undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<script async src={gtagSrc(id)} nonce={nonce} />
|
||||||
|
<script nonce={nonce} dangerouslySetInnerHTML={{ __html: gtagConfigScript(id) }} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,7 +13,9 @@ export type BackendIconName =
|
|||||||
| "meetings"
|
| "meetings"
|
||||||
| "registrations"
|
| "registrations"
|
||||||
| "gallery"
|
| "gallery"
|
||||||
|
| "news"
|
||||||
| "emails"
|
| "emails"
|
||||||
|
| "mailbox"
|
||||||
| "social"
|
| "social"
|
||||||
| "categories"
|
| "categories"
|
||||||
| "settings"
|
| "settings"
|
||||||
@@ -70,6 +72,18 @@ const PATHS: Record<BackendIconName, ReactNode> = {
|
|||||||
<path d="m4 17 4.8-4.4a1.8 1.8 0 0 1 2.4 0L16 17M14 14.4l1.6-1.5a1.8 1.8 0 0 1 2.4 0L21 15.6" />
|
<path d="m4 17 4.8-4.4a1.8 1.8 0 0 1 2.4 0L16 17M14 14.4l1.6-1.5a1.8 1.8 0 0 1 2.4 0L21 15.6" />
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
|
news: (
|
||||||
|
<>
|
||||||
|
<path d="M17.5 4.5H4.2A1.2 1.2 0 0 0 3 5.7v12a1.8 1.8 0 0 0 1.8 1.8h13.4a2.8 2.8 0 0 0 2.8-2.8V9.2h-3.5V4.5Z" />
|
||||||
|
<path d="M6.2 8.3h8.1M6.2 11.6h8.1M6.2 14.9h5.2" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
mailbox: (
|
||||||
|
<>
|
||||||
|
<rect x="2.6" y="5" width="18.8" height="14" rx="2.4" />
|
||||||
|
<path d="m3.4 7.2 7.4 5.3a2 2 0 0 0 2.4 0l7.4-5.3" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
emails: (
|
emails: (
|
||||||
<>
|
<>
|
||||||
<path d="M20.5 3.5 10.8 13.2M20.5 3.5 14.3 21l-3.5-7.8L3 9.7l17.5-6.2Z" />
|
<path d="M20.5 3.5 10.8 13.2M20.5 3.5 14.3 21l-3.5-7.8L3 9.7l17.5-6.2Z" />
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { categoryPath } from "@/lib/seo";
|
||||||
|
|
||||||
|
export type CategoryLink = { slug: string; label: string; accent: string; memberCount: number };
|
||||||
|
|
||||||
|
/** Pastilles-liens vers les pages métier : contenu statique, lisible sans JavaScript. */
|
||||||
|
export function CategoryLinks({ categories, currentSlug }: { categories: CategoryLink[]; currentSlug?: string }) {
|
||||||
|
return (
|
||||||
|
<nav aria-label="Parcourir par métier" style={{ display: "flex", flexWrap: "wrap", gap: 9 }}>
|
||||||
|
{categories.map((c) => {
|
||||||
|
const active = c.slug === currentSlug;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={c.slug}
|
||||||
|
href={categoryPath(c.slug)}
|
||||||
|
aria-current={active ? "page" : undefined}
|
||||||
|
style={{
|
||||||
|
textDecoration: "none",
|
||||||
|
fontSize: 13.5,
|
||||||
|
fontWeight: 600,
|
||||||
|
padding: "8px 14px",
|
||||||
|
borderRadius: 999,
|
||||||
|
border: `1px solid ${active ? "#13324F" : "#e6dcc6"}`,
|
||||||
|
background: active ? "#13324F" : "#fff",
|
||||||
|
color: active ? "#fff" : "#3c3322",
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ width: 7, height: 7, borderRadius: "50%", background: active ? "#E0A63C" : c.accent, flexShrink: 0 }} />
|
||||||
|
{c.label}
|
||||||
|
<span style={{ opacity: 0.6 }}>{c.memberCount}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,9 +3,32 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { copyRichEmail, downloadHtml, downloadOutlookDraft } from "@/lib/email-client";
|
import { copyRichEmail, downloadHtml, downloadOutlookDraft } from "@/lib/email-client";
|
||||||
import { buildGeneralEmail, type EmailBrand, type GeneralEmailContent } from "@/lib/email-templates";
|
import { buildGeneralEmail, type EmailBrand, type GeneralEmailContent } from "@/lib/email-templates";
|
||||||
|
import {
|
||||||
|
MailRecipientPicker,
|
||||||
|
audienceCount,
|
||||||
|
audienceFrom,
|
||||||
|
type AudienceState,
|
||||||
|
type RecipientChoices,
|
||||||
|
} from "@/components/MailRecipientPicker";
|
||||||
|
import { sendSelfTest, sendStudioEmail } from "@/app/backend/actions";
|
||||||
|
|
||||||
export function EmailCreator({ brand }: { brand: EmailBrand }) {
|
export function EmailCreator({
|
||||||
const [baseUrl, setBaseUrl] = useState("");
|
brand,
|
||||||
|
choices,
|
||||||
|
mailReady,
|
||||||
|
siteUrl,
|
||||||
|
}: {
|
||||||
|
brand: EmailBrand;
|
||||||
|
choices: RecipientChoices;
|
||||||
|
/** Faux si aucune boîte n'expédie : l'envoi réel est alors désactivé. */
|
||||||
|
mailReady: boolean;
|
||||||
|
/** Adresse publique enregistrée, utilisée par l'aperçu comme par l'envoi. */
|
||||||
|
siteUrl: string;
|
||||||
|
}) {
|
||||||
|
const [baseUrl, setBaseUrl] = useState(siteUrl);
|
||||||
|
const [audience, setAudience] = useState<AudienceState>({ kind: "all", categoryId: "" });
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [sent, setSent] = useState<string | null>(null);
|
||||||
const [form, setForm] = useState<GeneralEmailContent>({
|
const [form, setForm] = useState<GeneralEmailContent>({
|
||||||
subject: `Actualités de ${brand.associationName}`,
|
subject: `Actualités de ${brand.associationName}`,
|
||||||
kicker: brand.associationName,
|
kicker: brand.associationName,
|
||||||
@@ -19,15 +42,35 @@ export function EmailCreator({ brand }: { brand: EmailBrand }) {
|
|||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setBaseUrl(window.location.origin);
|
// L'adresse enregistrée prime : c'est elle que verront les destinataires,
|
||||||
setForm((current) => ({ ...current, buttonUrl: `${window.location.origin}/association` }));
|
// et non celle par laquelle l'administrateur est arrivé.
|
||||||
}, []);
|
const origin = siteUrl || window.location.origin;
|
||||||
|
setBaseUrl(origin);
|
||||||
|
setForm((current) => ({ ...current, buttonUrl: `${origin}/association` }));
|
||||||
|
}, [siteUrl]);
|
||||||
|
|
||||||
const email = useMemo(
|
const email = useMemo(
|
||||||
() => (baseUrl ? buildGeneralEmail(form, baseUrl, brand) : null),
|
() => (baseUrl ? buildGeneralEmail(form, baseUrl, brand) : null),
|
||||||
[baseUrl, brand, form],
|
[baseUrl, brand, form],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const recipientCount = audienceCount(audience, choices);
|
||||||
|
|
||||||
|
async function deliver(task: () => Promise<{ queued: number; audience: string } | { error: string }>) {
|
||||||
|
setSending(true);
|
||||||
|
setError("");
|
||||||
|
setSent(null);
|
||||||
|
try {
|
||||||
|
const result = await task();
|
||||||
|
if ("error" in result) setError(result.error);
|
||||||
|
else setSent(`✓ ${result.queued} message(s) mis en file pour ${result.audience}.`);
|
||||||
|
} catch (cause) {
|
||||||
|
setError(cause instanceof Error ? cause.message : "Envoi impossible.");
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function change(name: keyof GeneralEmailContent, value: string) {
|
function change(name: keyof GeneralEmailContent, value: string) {
|
||||||
setForm((current) => ({ ...current, [name]: value }));
|
setForm((current) => ({ ...current, [name]: value }));
|
||||||
}
|
}
|
||||||
@@ -69,8 +112,37 @@ export function EmailCreator({ brand }: { brand: EmailBrand }) {
|
|||||||
<EmailArea label="Signature" value={form.signature} rows={3} onChange={(value) => change("signature", value)} />
|
<EmailArea label="Signature" value={form.signature} rows={3} onChange={(value) => change("signature", value)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 20, borderTop: "1px solid #f0e8d6", paddingTop: 16 }}>
|
||||||
|
<MailRecipientPicker choices={choices} value={audience} onChange={setAudience} />
|
||||||
|
<div style={{ display: "grid", gap: 9, marginTop: 14 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="email-primary-button"
|
||||||
|
disabled={!email || sending || !mailReady || recipientCount === 0}
|
||||||
|
onClick={() => email && deliver(() => sendStudioEmail({ content: form, audience: audienceFrom(audience) }))}
|
||||||
|
>
|
||||||
|
{sending ? "Envoi en cours…" : `Envoyer à ${recipientCount} adhérent${recipientCount > 1 ? "s" : ""}`}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="email-secondary-button"
|
||||||
|
disabled={!email || sending || !mailReady}
|
||||||
|
onClick={() => email && deliver(() => sendSelfTest({ subject: email.subject, html: email.html, text: form.body }))}
|
||||||
|
>
|
||||||
|
M'envoyer un test
|
||||||
|
</button>
|
||||||
|
{sent && <div style={{ color: "#1f8a5b", fontSize: 13, fontWeight: 700 }}>{sent}</div>}
|
||||||
|
{!mailReady && (
|
||||||
|
<p style={{ margin: 0, color: "#9a8d72", fontSize: 11.5, lineHeight: 1.55 }}>
|
||||||
|
Aucune boîte mail n'expédie pour l'instant : branchez-la dans Configuration › Boîte mail.
|
||||||
|
Les boutons ci-dessous restent utilisables.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && <div style={{ marginTop: 13, color: "#d8472b", fontSize: 13, fontWeight: 700 }}>{error}</div>}
|
{error && <div style={{ marginTop: 13, color: "#d8472b", fontSize: 13, fontWeight: 700 }}>{error}</div>}
|
||||||
<div style={{ display: "grid", gap: 9, marginTop: 20 }}>
|
<div style={{ display: "grid", gap: 9, marginTop: 20, borderTop: "1px solid #f0e8d6", paddingTop: 16 }}>
|
||||||
<button type="button" className="email-primary-button" disabled={!email} onClick={() => email && action("outlook", () => downloadOutlookDraft(email.subject, email.html))}>
|
<button type="button" className="email-primary-button" disabled={!email} onClick={() => email && action("outlook", () => downloadOutlookDraft(email.subject, email.html))}>
|
||||||
{flash === "outlook" ? "✓ Brouillon téléchargé" : "Télécharger le brouillon Outlook"}
|
{flash === "outlook" ? "✓ Brouillon téléchargé" : "Télécharger le brouillon Outlook"}
|
||||||
</button>
|
</button>
|
||||||
@@ -87,7 +159,8 @@ export function EmailCreator({ brand }: { brand: EmailBrand }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p style={{ margin: "13px 0 0", color: "#9a8d72", fontSize: 11.5, lineHeight: 1.55 }}>
|
<p style={{ margin: "13px 0 0", color: "#9a8d72", fontSize: 11.5, lineHeight: 1.55 }}>
|
||||||
Ouvrez fichier .eml pour obtenir un brouillon Outlook prêt à compléter avec destinataires.
|
Ces trois boutons restent là pour les cas particuliers : le .eml donne un brouillon Outlook
|
||||||
|
à compléter à la main.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { richTextNodes } from "@/lib/rich-text";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Une information de l'association. Rendu unique, partagé par le fil de
|
||||||
|
* l'espace adhérent et l'aperçu du back-office : une seule carte à maintenir.
|
||||||
|
*
|
||||||
|
* Le corps passe par `richTextNodes()` : des éléments React, jamais du HTML
|
||||||
|
* injecté.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type InformationCardProps = {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
imageUrl?: string | null;
|
||||||
|
publishedAt?: Date | null;
|
||||||
|
authorName?: string | null;
|
||||||
|
pinned?: boolean;
|
||||||
|
unread?: boolean;
|
||||||
|
/** Mention d'état supplémentaire (« Brouillon », « Lue par 12 / 40 »…). */
|
||||||
|
note?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDay(value: Date) {
|
||||||
|
return new Date(value).toLocaleDateString("fr-FR", { day: "numeric", month: "long", year: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InformationCard({
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
imageUrl,
|
||||||
|
publishedAt,
|
||||||
|
authorName,
|
||||||
|
pinned,
|
||||||
|
unread,
|
||||||
|
note,
|
||||||
|
}: InformationCardProps) {
|
||||||
|
// Une seule accentuation à la fois : l'épinglage prime sur la nouveauté,
|
||||||
|
// sinon les deux liserés se disputeraient le même bord.
|
||||||
|
const accent = pinned ? "#E0A63C" : unread ? "#1F8A5B" : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
style={{
|
||||||
|
background: unread || pinned ? "#fff" : "#fdfcf8",
|
||||||
|
border: `1px solid ${pinned ? "#e5cf9a" : "#e6dcc6"}`,
|
||||||
|
borderRadius: 14,
|
||||||
|
padding: "18px 20px",
|
||||||
|
marginBottom: 14,
|
||||||
|
boxShadow: accent ? `inset 4px 0 0 ${accent}` : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 9, flexWrap: "wrap", marginBottom: 9 }}>
|
||||||
|
{pinned && (
|
||||||
|
<span style={{ background: "#fbeede", color: "#9a6638", borderRadius: 999, padding: "4px 10px", fontSize: 11, fontWeight: 800 }}>
|
||||||
|
Épinglée
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{unread && (
|
||||||
|
<span style={{ background: "#e6f4ec", color: "#1f8a5b", borderRadius: 999, padding: "4px 10px", fontSize: 11, fontWeight: 800 }}>
|
||||||
|
Nouveau
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span style={{ color: "#9a8d72", fontSize: 12.5 }}>
|
||||||
|
{publishedAt ? `Publié le ${formatDay(publishedAt)}` : "Non publiée"}
|
||||||
|
{authorName ? ` par ${authorName}` : ""}
|
||||||
|
</span>
|
||||||
|
{note && (
|
||||||
|
<span style={{ background: "#f1efe7", color: "#a99c82", borderRadius: 999, padding: "4px 10px", fontSize: 11, fontWeight: 800 }}>
|
||||||
|
{note}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="font-display" style={{ margin: 0, fontWeight: 700, fontSize: 18, color: "#26201a" }}>
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{imageUrl && (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
alt=""
|
||||||
|
style={{ display: "block", width: "100%", maxHeight: 280, objectFit: "cover", borderRadius: 10, border: "1px solid #e6dcc6", margin: "13px 0 4px" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="rich-text" style={{ marginTop: 9 }}>
|
||||||
|
{richTextNodes(body)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { headers } from "next/headers";
|
||||||
|
import { serializeJsonLd } from "@/lib/seo";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bloc de données structurées `application/ld+json`.
|
||||||
|
*
|
||||||
|
* Le contenu est sérialisé par `serializeJsonLd`, qui échappe `<`, `>` et `&` :
|
||||||
|
* les descriptions saisies par les adhérents ne peuvent pas fermer la balise.
|
||||||
|
* Le nonce de la CSP est recopié par cohérence, même si un bloc JSON-LD n'est
|
||||||
|
* jamais exécuté par le navigateur.
|
||||||
|
*/
|
||||||
|
export async function JsonLd({ data }: { data: unknown | unknown[] }) {
|
||||||
|
const nonce = (await headers()).get("x-nonce") ?? undefined;
|
||||||
|
const items = Array.isArray(data) ? data : [data];
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<script
|
||||||
|
key={index}
|
||||||
|
type="application/ld+json"
|
||||||
|
nonce={nonce}
|
||||||
|
dangerouslySetInnerHTML={{ __html: serializeJsonLd(item) }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { Audience } from "@/lib/mail-recipients";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Choix des destinataires, partagé par le studio de composition et le
|
||||||
|
* composeur d'invitations.
|
||||||
|
*
|
||||||
|
* Les effectifs sont calculés côté serveur et passés en props : le navigateur
|
||||||
|
* n'a jamais besoin de la liste des adresses pour afficher un compteur.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type RecipientChoices = {
|
||||||
|
all: number;
|
||||||
|
categories: { value: string; label: string; count: number }[];
|
||||||
|
meeting?: { id: number; title: string; count: number } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AudienceState = { kind: Audience["kind"]; categoryId: string };
|
||||||
|
|
||||||
|
export function audienceFrom(state: AudienceState, meetingId?: number | null): Audience {
|
||||||
|
if (state.kind === "category" && state.categoryId) {
|
||||||
|
return { kind: "category", categoryId: Number(state.categoryId) };
|
||||||
|
}
|
||||||
|
if (state.kind === "meeting" && meetingId) return { kind: "meeting", meetingId };
|
||||||
|
return { kind: "all" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function audienceCount(state: AudienceState, choices: RecipientChoices): number {
|
||||||
|
if (state.kind === "category") {
|
||||||
|
return choices.categories.find((option) => option.value === state.categoryId)?.count ?? 0;
|
||||||
|
}
|
||||||
|
if (state.kind === "meeting") return choices.meeting?.count ?? 0;
|
||||||
|
return choices.all;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MailRecipientPicker({
|
||||||
|
choices,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
choices: RecipientChoices;
|
||||||
|
value: AudienceState;
|
||||||
|
onChange: (next: AudienceState) => void;
|
||||||
|
}) {
|
||||||
|
const options: { kind: Audience["kind"]; label: string; hint: string; count: number }[] = [
|
||||||
|
{ kind: "all", label: "Tous les adhérents actifs", hint: "", count: choices.all },
|
||||||
|
];
|
||||||
|
if (choices.categories.length > 0) {
|
||||||
|
options.push({ kind: "category", label: "Une catégorie de métier", hint: "", count: 0 });
|
||||||
|
}
|
||||||
|
if (choices.meeting) {
|
||||||
|
options.push({
|
||||||
|
kind: "meeting",
|
||||||
|
label: "Les inscrits à cette rencontre",
|
||||||
|
hint: choices.meeting.title,
|
||||||
|
count: choices.meeting.count,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<fieldset style={{ border: "none", margin: 0, padding: 0 }}>
|
||||||
|
<legend className="field-label" style={{ marginBottom: 8 }}>Destinataires</legend>
|
||||||
|
<div style={{ display: "grid", gap: 9 }}>
|
||||||
|
{options.map((option) => (
|
||||||
|
<div key={option.kind}>
|
||||||
|
<label style={{ display: "flex", gap: 10, alignItems: "flex-start", cursor: "pointer", fontSize: 13.5, color: "#3c3322" }}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="audience"
|
||||||
|
checked={value.kind === option.kind}
|
||||||
|
onChange={() => onChange({ ...value, kind: option.kind })}
|
||||||
|
style={{ marginTop: 3 }}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<strong style={{ color: "#26201a" }}>{option.label}</strong>
|
||||||
|
{option.kind !== "category" && (
|
||||||
|
<span style={{ color: "#9a8d72" }}> · {option.count} adresse{option.count > 1 ? "s" : ""}</span>
|
||||||
|
)}
|
||||||
|
{option.hint && <span style={{ display: "block", color: "#9a8d72", fontSize: 12.5 }}>{option.hint}</span>}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{option.kind === "category" && value.kind === "category" && (
|
||||||
|
<select
|
||||||
|
className="field"
|
||||||
|
value={value.categoryId}
|
||||||
|
onChange={(event) => onChange({ ...value, categoryId: event.target.value })}
|
||||||
|
style={{ marginTop: 8, marginLeft: 27, width: "calc(100% - 27px)" }}
|
||||||
|
>
|
||||||
|
<option value="">Choisir un métier…</option>
|
||||||
|
{choices.categories.map((category) => (
|
||||||
|
<option key={category.value} value={category.value}>
|
||||||
|
{category.label} ({category.count})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { markInformationsRead } from "@/app/backend/actions";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marque les informations affichées comme lues, une fois la page rendue.
|
||||||
|
*
|
||||||
|
* Le marquage ne peut pas se faire pendant le rendu de la page : le compteur
|
||||||
|
* de la barre latérale est calculé par le gabarit, un composant serveur
|
||||||
|
* distinct dont l'ordre de rendu n'est pas garanti — la pastille de l'onglet
|
||||||
|
* et celle du menu auraient pu se contredire sur la même page. Ici les deux
|
||||||
|
* retombent ensemble au rafraîchissement.
|
||||||
|
*/
|
||||||
|
export function MarkInformationsRead({ ids }: { ids: number[] }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const handled = useRef("");
|
||||||
|
const key = ids.join(",");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!key || handled.current === key) return;
|
||||||
|
handled.current = key;
|
||||||
|
void markInformationsRead(key.split(",").map(Number)).then(() => router.refresh());
|
||||||
|
}, [key, router]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Modal } from "@/components/Modal";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carte d'une rencontre à venir, de hauteur identique à ses voisines.
|
||||||
|
*
|
||||||
|
* Le texte d'une rencontre est libre : une description longue étirait sa carte,
|
||||||
|
* donc toute la rangée de la grille, et laissait les autres avec un grand vide
|
||||||
|
* sous leur bouton. Titre et description occupent désormais une hauteur figée
|
||||||
|
* et sont tronqués en fin de ligne ; le texte complet reste lisible dans une
|
||||||
|
* fenêtre ouverte par « Voir le détail ».
|
||||||
|
*
|
||||||
|
* Les dates arrivent **déjà formatées** depuis la page serveur : les formater
|
||||||
|
* ici les rendrait dans le fuseau du visiteur, différent de celui du serveur,
|
||||||
|
* et provoquerait une erreur d'hydratation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type UpcomingMeetingCardData = {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
location: string | null;
|
||||||
|
imageUrl: string | null;
|
||||||
|
/** « vendredi 3 octobre 2026 ». */
|
||||||
|
dateLabel: string;
|
||||||
|
/** « 18:30 ». */
|
||||||
|
timeLabel: string;
|
||||||
|
capacity: number;
|
||||||
|
registered: number;
|
||||||
|
participantsPerAccount: number;
|
||||||
|
isRegistered: boolean;
|
||||||
|
/** Libellé du bouton d'inscription, décidé côté serveur (session, places). */
|
||||||
|
ctaLabel: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const STRIPE =
|
||||||
|
"repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hauteurs figées des deux blocs de texte, même principe que `MemberCard` :
|
||||||
|
* elles alignent les cartes quel que soit le texte saisi par le staff.
|
||||||
|
*/
|
||||||
|
const TITLE_FONT = 21;
|
||||||
|
const TITLE_LINE = 1.25;
|
||||||
|
const TITLE_LINES = 2;
|
||||||
|
const DESC_FONT = 13.5;
|
||||||
|
const DESC_LINE = 1.6;
|
||||||
|
const DESC_LINES = 3;
|
||||||
|
|
||||||
|
const clampLines = (lines: number) =>
|
||||||
|
({
|
||||||
|
display: "-webkit-box",
|
||||||
|
WebkitLineClamp: lines,
|
||||||
|
WebkitBoxOrient: "vertical" as const,
|
||||||
|
overflow: "hidden",
|
||||||
|
}) as const;
|
||||||
|
|
||||||
|
export function MeetingCard({ meeting }: { meeting: UpcomingMeetingCardData }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const remaining = Math.max(0, meeting.capacity - meeting.registered);
|
||||||
|
const full = remaining <= 0 && !meeting.isRegistered;
|
||||||
|
const perAccount = `Jusqu'à ${meeting.participantsPerAccount} participant${meeting.participantsPerAccount > 1 ? "s" : ""} par compte`;
|
||||||
|
const cover = meeting.imageUrl ? `center/cover no-repeat url(${meeting.imageUrl})` : STRIPE;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
id={`rencontre-${meeting.id}`}
|
||||||
|
className="lift"
|
||||||
|
style={{
|
||||||
|
background: "#fff",
|
||||||
|
border: meeting.isRegistered ? "2px solid #1f8a5b" : "1px solid #e6dcc6",
|
||||||
|
borderRadius: 12,
|
||||||
|
overflow: "hidden",
|
||||||
|
scrollMarginTop: 100,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ position: "relative", aspectRatio: "16 / 10", background: cover, flexShrink: 0 }}>
|
||||||
|
{meeting.isRegistered && (
|
||||||
|
<span style={{ position: "absolute", top: 12, right: 12, display: "inline-flex", alignItems: "center", gap: 6, background: "#1f8a5b", color: "#fff", borderRadius: 999, padding: "7px 12px", fontSize: 12, fontWeight: 800, boxShadow: "0 4px 14px rgba(22,72,49,0.28)" }}>
|
||||||
|
✓ Inscrit
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: 18, display: "flex", flexDirection: "column", flex: 1 }}>
|
||||||
|
<div style={{ color: "#9a6638", fontSize: 12.5, fontWeight: 800 }}>
|
||||||
|
{meeting.dateLabel} · {meeting.timeLabel}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3
|
||||||
|
className="font-display"
|
||||||
|
style={{
|
||||||
|
margin: "7px 0 7px",
|
||||||
|
color: "#26201a",
|
||||||
|
fontSize: TITLE_FONT,
|
||||||
|
lineHeight: TITLE_LINE,
|
||||||
|
height: TITLE_FONT * TITLE_LINE * TITLE_LINES,
|
||||||
|
...clampLines(TITLE_LINES),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{meeting.title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Le lieu est facultatif : sa ligne est réservée dans tous les cas,
|
||||||
|
sinon une carte sans lieu remonterait tout son bas. */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: "#6c6150",
|
||||||
|
fontWeight: 700,
|
||||||
|
height: 13 * 1.4,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{meeting.location}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
color: "#8c8068",
|
||||||
|
fontSize: DESC_FONT,
|
||||||
|
lineHeight: DESC_LINE,
|
||||||
|
height: DESC_FONT * DESC_LINE * DESC_LINES,
|
||||||
|
margin: "8px 0 0",
|
||||||
|
...clampLines(DESC_LINES),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{meeting.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Espaceur : colle le bas de carte au bas du cadre, quelle que soit la
|
||||||
|
hauteur imposée par la plus haute carte de la rangée. */}
|
||||||
|
<div style={{ flex: 1, minHeight: 12 }} />
|
||||||
|
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", color: "#6c6150", fontSize: 12.5, fontWeight: 800, marginBottom: 8 }}>
|
||||||
|
<span>{meeting.registered}/{meeting.capacity} inscrits</span>
|
||||||
|
<span>{remaining} place(s)</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ color: "#8c8068", fontSize: 12.5, marginBottom: 10 }}>{perAccount}</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
style={{ flex: "0 0 auto", border: "1px solid #d6c9ad", background: "#fff", color: "#6f6450", borderRadius: 8, padding: "11px 14px", fontWeight: 800, fontSize: 13.5, cursor: "pointer", fontFamily: "inherit" }}
|
||||||
|
>
|
||||||
|
Voir le détail
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
href={`/inscription/${meeting.id}`}
|
||||||
|
style={{ flex: 1, textAlign: "center", textDecoration: "none", background: full ? "#a99c82" : "#13324F", color: "#fff", borderRadius: 8, padding: 11, fontWeight: 800 }}
|
||||||
|
>
|
||||||
|
{meeting.ctaLabel}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal open={open} onClose={() => setOpen(false)} title={meeting.title} maxWidth={560}>
|
||||||
|
{meeting.imageUrl && (
|
||||||
|
<div style={{ aspectRatio: "16 / 10", background: cover, borderRadius: 12, marginBottom: 16 }} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ color: "#9a6638", fontSize: 13, fontWeight: 800 }}>
|
||||||
|
{meeting.dateLabel} · {meeting.timeLabel}
|
||||||
|
</div>
|
||||||
|
{meeting.location && (
|
||||||
|
<div style={{ fontSize: 13.5, color: "#6c6150", fontWeight: 700, marginTop: 4 }}>{meeting.location}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{meeting.description && (
|
||||||
|
<p style={{ color: "#6c6150", fontSize: 14, lineHeight: 1.7, whiteSpace: "pre-line", margin: "14px 0 0" }}>
|
||||||
|
{meeting.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", gap: 12, background: "#faf7ef", border: "1px solid #f0e8d6", borderRadius: 12, padding: "12px 14px", margin: "18px 0 6px", fontSize: 13, fontWeight: 800, color: "#6c6150" }}>
|
||||||
|
<span>{meeting.registered}/{meeting.capacity} inscrits</span>
|
||||||
|
<span>{remaining} place(s) restante(s)</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ color: "#8c8068", fontSize: 12.5, marginBottom: 16 }}>{perAccount}</div>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href={`/inscription/${meeting.id}`}
|
||||||
|
style={{ display: "block", textAlign: "center", textDecoration: "none", background: full ? "#a99c82" : "#13324F", color: "#fff", borderRadius: 8, padding: 12, fontWeight: 800 }}
|
||||||
|
>
|
||||||
|
{meeting.ctaLabel}
|
||||||
|
</Link>
|
||||||
|
</Modal>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,20 +4,64 @@ import { useMemo, useState } from "react";
|
|||||||
import { Modal } from "@/components/Modal";
|
import { Modal } from "@/components/Modal";
|
||||||
import { copyRichEmail, downloadHtml, downloadOutlookDraft } from "@/lib/email-client";
|
import { copyRichEmail, downloadHtml, downloadOutlookDraft } from "@/lib/email-client";
|
||||||
import { buildMeetingEmail, defaultMeetingEmailTexts, type EmailBrand, type MeetingEmailData, type MeetingEmailTexts } from "@/lib/email-templates";
|
import { buildMeetingEmail, defaultMeetingEmailTexts, type EmailBrand, type MeetingEmailData, type MeetingEmailTexts } from "@/lib/email-templates";
|
||||||
|
import {
|
||||||
|
MailRecipientPicker,
|
||||||
|
audienceCount,
|
||||||
|
audienceFrom,
|
||||||
|
type AudienceState,
|
||||||
|
type RecipientChoices,
|
||||||
|
} from "@/components/MailRecipientPicker";
|
||||||
|
import { sendMeetingInvitations } from "@/app/backend/actions";
|
||||||
|
|
||||||
export function MeetingEmailComposer({ meeting, brand }: { meeting: MeetingEmailData; brand: EmailBrand }) {
|
export function MeetingEmailComposer({
|
||||||
|
meeting,
|
||||||
|
brand,
|
||||||
|
choices,
|
||||||
|
mailReady,
|
||||||
|
siteUrl,
|
||||||
|
}: {
|
||||||
|
meeting: MeetingEmailData;
|
||||||
|
brand: EmailBrand;
|
||||||
|
choices: RecipientChoices;
|
||||||
|
mailReady: boolean;
|
||||||
|
siteUrl: string;
|
||||||
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [baseUrl, setBaseUrl] = useState("");
|
const [baseUrl, setBaseUrl] = useState(siteUrl);
|
||||||
const [texts, setTexts] = useState<MeetingEmailTexts>(() => defaultMeetingEmailTexts(brand));
|
const [texts, setTexts] = useState<MeetingEmailTexts>(() => defaultMeetingEmailTexts(brand));
|
||||||
const [flash, setFlash] = useState("");
|
const [flash, setFlash] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const [audience, setAudience] = useState<AudienceState>({ kind: "all", categoryId: "" });
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [sent, setSent] = useState<string | null>(null);
|
||||||
const email = useMemo(() => (baseUrl ? buildMeetingEmail(meeting, texts, baseUrl, brand) : null), [baseUrl, brand, meeting, texts]);
|
const email = useMemo(() => (baseUrl ? buildMeetingEmail(meeting, texts, baseUrl, brand) : null), [baseUrl, brand, meeting, texts]);
|
||||||
|
const recipientCount = audienceCount(audience, choices);
|
||||||
|
|
||||||
function show() {
|
function show() {
|
||||||
setBaseUrl(window.location.origin);
|
// L'adresse enregistrée prime : c'est celle que verront les destinataires.
|
||||||
|
setBaseUrl(siteUrl || window.location.origin);
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deliver() {
|
||||||
|
setSending(true);
|
||||||
|
setError("");
|
||||||
|
setSent(null);
|
||||||
|
try {
|
||||||
|
const result = await sendMeetingInvitations({
|
||||||
|
meetingId: meeting.id,
|
||||||
|
texts,
|
||||||
|
audience: audienceFrom(audience, meeting.id),
|
||||||
|
});
|
||||||
|
if ("error" in result) setError(result.error);
|
||||||
|
else setSent(`✓ ${result.queued} invitation(s) mises en file pour ${result.audience}.`);
|
||||||
|
} catch (cause) {
|
||||||
|
setError(cause instanceof Error ? cause.message : "Envoi impossible.");
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function change(name: keyof MeetingEmailTexts, value: string) {
|
function change(name: keyof MeetingEmailTexts, value: string) {
|
||||||
setTexts((current) => ({ ...current, [name]: value }));
|
setTexts((current) => ({ ...current, [name]: value }));
|
||||||
}
|
}
|
||||||
@@ -46,8 +90,26 @@ export function MeetingEmailComposer({ meeting, brand }: { meeting: MeetingEmail
|
|||||||
<p style={{ margin: 0, color: "#8c8068", fontSize: 12, lineHeight: 1.55 }}>
|
<p style={{ margin: 0, color: "#8c8068", fontSize: 12, lineHeight: 1.55 }}>
|
||||||
Date, lieu, description, places et lien d'inscription repris automatiquement.
|
Date, lieu, description, places et lien d'inscription repris automatiquement.
|
||||||
</p>
|
</p>
|
||||||
|
<div style={{ borderTop: "1px solid #f0e8d6", paddingTop: 13 }}>
|
||||||
|
<MailRecipientPicker choices={choices} value={audience} onChange={setAudience} />
|
||||||
|
</div>
|
||||||
{error && <div style={{ color: "#d8472b", fontSize: 13, fontWeight: 700 }}>{error}</div>}
|
{error && <div style={{ color: "#d8472b", fontSize: 13, fontWeight: 700 }}>{error}</div>}
|
||||||
<button type="button" className="email-primary-button" disabled={!email} onClick={() => email && action("outlook", () => downloadOutlookDraft(email.subject, email.html, "invitation"))}>
|
{sent && <div style={{ color: "#1f8a5b", fontSize: 13, fontWeight: 700 }}>{sent}</div>}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="email-primary-button"
|
||||||
|
disabled={!email || sending || !mailReady || recipientCount === 0}
|
||||||
|
onClick={deliver}
|
||||||
|
>
|
||||||
|
{sending ? "Envoi en cours…" : `Envoyer à ${recipientCount} destinataire${recipientCount > 1 ? "s" : ""}`}
|
||||||
|
</button>
|
||||||
|
{!mailReady && (
|
||||||
|
<p style={{ margin: 0, color: "#9a8d72", fontSize: 11.5, lineHeight: 1.55 }}>
|
||||||
|
Aucune boîte mail ne répond : branchez-la dans Configuration › Boîte mail. Les boutons
|
||||||
|
ci-dessous restent utilisables.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button type="button" className="email-secondary-button" disabled={!email} onClick={() => email && action("outlook", () => downloadOutlookDraft(email.subject, email.html, "invitation"))}>
|
||||||
{flash === "outlook" ? "✓ Brouillon téléchargé" : "Télécharger brouillon Outlook"}
|
{flash === "outlook" ? "✓ Brouillon téléchargé" : "Télécharger brouillon Outlook"}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="email-secondary-button" disabled={!email} onClick={() => email && action("copy", () => copyRichEmail(email.html, `${email.subject}\n${email.registrationUrl}`))}>
|
<button type="button" className="email-secondary-button" disabled={!email} onClick={() => email && action("copy", () => copyRichEmail(email.html, `${email.subject}\n${email.registrationUrl}`))}>
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* Pastille ronde d'un adhérent : son logo, sinon ses initiales.
|
||||||
|
*
|
||||||
|
* Sert à identifier le commerce d'un coup d'œil devant son nom, notamment sur
|
||||||
|
* les cartes de promotion. Le logo est rendu en `contain` sur fond blanc : la
|
||||||
|
* plupart des logos d'adhérents sont des marques sur fond clair, un `cover` les
|
||||||
|
* rognerait.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function memberInitials(name: string): string {
|
||||||
|
return name
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((w) => w[0])
|
||||||
|
.join("")
|
||||||
|
.slice(0, 2)
|
||||||
|
.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MemberAvatar({
|
||||||
|
name,
|
||||||
|
logoUrl,
|
||||||
|
size = 26,
|
||||||
|
accent = "#E0A63C",
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
logoUrl?: string | null;
|
||||||
|
size?: number;
|
||||||
|
accent?: string;
|
||||||
|
}) {
|
||||||
|
const common: React.CSSProperties = {
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
borderRadius: "50%",
|
||||||
|
flexShrink: 0,
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
overflow: "hidden",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (logoUrl) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{ ...common, background: "#fff", border: "1px solid #eee3cf" }}
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={logoUrl}
|
||||||
|
alt=""
|
||||||
|
style={{ width: "100%", height: "100%", objectFit: "contain", padding: 2, boxSizing: "border-box" }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
...common,
|
||||||
|
background: accent,
|
||||||
|
color: "#fff",
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: Math.round(size * 0.4),
|
||||||
|
letterSpacing: "0.02em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{memberInitials(name)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { VitrineImage } from "@/components/VitrineImage";
|
||||||
|
import { memberPath } from "@/lib/seo";
|
||||||
|
import type { MemberContact } from "@/lib/member-contact";
|
||||||
|
|
||||||
|
export type MemberCardData = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
city: string | null;
|
||||||
|
address: string | null;
|
||||||
|
coverUrl: string | null;
|
||||||
|
logoUrl: string | null;
|
||||||
|
categoryLabel: string | null;
|
||||||
|
accent: string | null;
|
||||||
|
hasPromo?: boolean;
|
||||||
|
promoBadge?: string | 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)";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hauteurs figées des deux blocs de texte : une description longue ne doit pas
|
||||||
|
* étirer la carte (donc toute la rangée de la grille). Titre et description
|
||||||
|
* sont tronqués en fin de ligne et occupent toujours la même place, ce qui
|
||||||
|
* aligne les cartes entre elles quel que soit le texte saisi par l'adhérent.
|
||||||
|
*/
|
||||||
|
const TITLE_LINE = 1.25;
|
||||||
|
const TITLE_LINES = 2;
|
||||||
|
const DESC_FONT = 13;
|
||||||
|
const DESC_LINE = 1.5;
|
||||||
|
const DESC_LINES = 4;
|
||||||
|
|
||||||
|
const clampLines = (lines: number) =>
|
||||||
|
({
|
||||||
|
display: "-webkit-box",
|
||||||
|
WebkitLineClamp: lines,
|
||||||
|
WebkitBoxOrient: "vertical" as const,
|
||||||
|
overflow: "hidden",
|
||||||
|
}) as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carte d'un adhérent dans une grille (annuaire, page métier). Composant sans
|
||||||
|
* état : il se rend aussi bien côté serveur que dans le filtre client.
|
||||||
|
*
|
||||||
|
* `contact` (nom du référent, ligne directe) n'est passé qu'aux visiteurs
|
||||||
|
* connectés : la page ne le rapatrie pas du tout pour un visiteur anonyme, il
|
||||||
|
* n'y a donc rien à masquer en CSS. Le numéro reste en texte simple — un
|
||||||
|
* `tel:` imbriqué dans le lien de la carte serait un lien dans un lien.
|
||||||
|
*/
|
||||||
|
export function MemberCard({
|
||||||
|
m,
|
||||||
|
index = 0,
|
||||||
|
contact = null,
|
||||||
|
}: {
|
||||||
|
m: MemberCardData;
|
||||||
|
index?: number;
|
||||||
|
contact?: MemberContact | null;
|
||||||
|
}) {
|
||||||
|
const location = [m.address, m.city].filter(Boolean).join(" · ");
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={memberPath(m)}
|
||||||
|
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={index % 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, lineHeight: TITLE_LINE, margin: 0, color: "#26201a", minHeight: 18 * TITLE_LINE * TITLE_LINES, ...clampLines(TITLE_LINES) }}
|
||||||
|
>
|
||||||
|
{m.name}
|
||||||
|
</h3>
|
||||||
|
<p
|
||||||
|
style={{ margin: "6px 0 12px", fontSize: DESC_FONT, color: "#8c8068", lineHeight: DESC_LINE, height: DESC_FONT * DESC_LINE * DESC_LINES, ...clampLines(DESC_LINES) }}
|
||||||
|
>
|
||||||
|
{m.description}
|
||||||
|
</p>
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 7, fontSize: 12.5, color: "#6c6150", borderTop: "1px solid #f0e8d6", paddingTop: 11 }}>
|
||||||
|
<span style={{ width: 7, height: 7, borderRadius: "50%", background: m.accent ?? "#E0A63C", flex: "0 0 auto" }} />
|
||||||
|
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{location}</span>
|
||||||
|
</div>
|
||||||
|
{contact && (
|
||||||
|
<div style={{ marginTop: 10, borderTop: "1px dashed #e6dcc6", paddingTop: 10 }}>
|
||||||
|
<div style={{ fontSize: 10.5, letterSpacing: "0.1em", textTransform: "uppercase", fontWeight: 800, color: "#9a8d72", marginBottom: 4 }}>
|
||||||
|
Contact adhérent
|
||||||
|
</div>
|
||||||
|
{contact.name && (
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 700, color: "#3c3322", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||||
|
{contact.name}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{contact.phone && (
|
||||||
|
<div style={{ fontSize: 12.5, color: "#6c6150", marginTop: 2 }}>{contact.phone}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* Coordonnées du référent de l'adhérent (nom, prénom, ligne directe), dans les
|
||||||
|
* deux formulaires de fiche : l'espace adhérent et l'écran staff.
|
||||||
|
*
|
||||||
|
* Ces champs ne sont jamais publiés : l'annuaire et la fiche publique ne les
|
||||||
|
* affichent qu'à un visiteur connecté (`src/lib/member-contact.ts`).
|
||||||
|
*/
|
||||||
|
export function MemberContactFields({
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
phone,
|
||||||
|
}: {
|
||||||
|
firstName: string | null;
|
||||||
|
lastName: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<fieldset
|
||||||
|
style={{
|
||||||
|
marginTop: 16,
|
||||||
|
border: "1px solid #f0e8d6",
|
||||||
|
borderRadius: 12,
|
||||||
|
background: "#faf7ef",
|
||||||
|
padding: 16,
|
||||||
|
minWidth: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<legend className="field-label" style={{ padding: "0 6px" }}>
|
||||||
|
Contact adhérent
|
||||||
|
</legend>
|
||||||
|
<p className="field-hint" style={{ margin: "0 0 12px" }}>
|
||||||
|
La personne à joindre et sa ligne directe. Affiché sur l'annuaire et sur la fiche
|
||||||
|
<strong> uniquement aux visiteurs connectés</strong> : rien de tout cela n'apparaît sur
|
||||||
|
le site public ni dans les moteurs de recherche.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-2" style={{ gap: 16 }}>
|
||||||
|
<div>
|
||||||
|
<label className="field-label">Prénom</label>
|
||||||
|
<input name="contactFirstName" defaultValue={firstName ?? ""} className="field" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="field-label">Nom</label>
|
||||||
|
<input name="contactLastName" defaultValue={lastName ?? ""} className="field" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="field-label">Téléphone direct</label>
|
||||||
|
<input name="contactPhone" defaultValue={phone ?? ""} className="field" placeholder="06 12 ..." />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { VitrineImage } from "./VitrineImage";
|
import { VitrineImage } from "./VitrineImage";
|
||||||
|
import { memberPath } from "@/lib/seo";
|
||||||
|
|
||||||
export type CarouselMember = {
|
export type CarouselMember = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -24,7 +25,7 @@ const FADE_MS = 450;
|
|||||||
function Card({ m, visible, index }: { m: CarouselMember; visible: boolean; index: number }) {
|
function Card({ m, visible, index }: { m: CarouselMember; visible: boolean; index: number }) {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={`/adherents/${m.id}`}
|
href={memberPath(m)}
|
||||||
className="lift-card"
|
className="lift-card"
|
||||||
style={{
|
style={{
|
||||||
background: "#fff",
|
background: "#fff",
|
||||||
|
|||||||
@@ -7,8 +7,34 @@
|
|||||||
* l'état de ce composant, le temps que le staff le relève. Un rechargement de
|
* l'état de ce composant, le temps que le staff le relève. Un rechargement de
|
||||||
* la page le fait disparaître définitivement (il reste possible de
|
* la page le fait disparaître définitivement (il reste possible de
|
||||||
* réinitialiser le mot de passe pour en obtenir un nouveau).
|
* réinitialiser le mot de passe pour en obtenir un nouveau).
|
||||||
|
*
|
||||||
|
* L'envoi par e-mail est un **plus**, jamais un remplacement : le mot de passe
|
||||||
|
* reste affiché même quand le message est parti, et surtout quand il n'a pas
|
||||||
|
* pu partir. Boîte mail non configurée, le panneau se comporte exactement
|
||||||
|
* comme avant.
|
||||||
*/
|
*/
|
||||||
export type IssuedCredentialsItem = { label?: string; email: string; tempPassword: string };
|
export type IssuedCredentialsItem = {
|
||||||
|
label?: string;
|
||||||
|
email: string;
|
||||||
|
tempPassword: string;
|
||||||
|
mailed?: boolean;
|
||||||
|
mailError?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function Delivery({ item }: { item: IssuedCredentialsItem }) {
|
||||||
|
if (item.mailed === undefined) return null;
|
||||||
|
if (item.mailed) {
|
||||||
|
return (
|
||||||
|
<span style={{ color: "#1f8a5b", fontWeight: 700 }}>✓ Envoyé à {item.email}</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span style={{ color: "#a8503c", fontWeight: 700 }}>
|
||||||
|
Envoi impossible — transmettez-le à la main
|
||||||
|
{item.mailError ? ` (${item.mailError.slice(0, 120)})` : ""}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function OneTimeCredentials({
|
export function OneTimeCredentials({
|
||||||
items,
|
items,
|
||||||
@@ -18,6 +44,8 @@ export function OneTimeCredentials({
|
|||||||
title?: string;
|
title?: string;
|
||||||
}) {
|
}) {
|
||||||
if (items.length === 0) return null;
|
if (items.length === 0) return null;
|
||||||
|
const allMailed = items.every((item) => item.mailed);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="status"
|
role="status"
|
||||||
@@ -26,7 +54,7 @@ export function OneTimeCredentials({
|
|||||||
<div className="font-display" style={{ fontWeight: 700, fontSize: 14.5, marginBottom: 8 }}>
|
<div className="font-display" style={{ fontWeight: 700, fontSize: 14.5, marginBottom: 8 }}>
|
||||||
{title}
|
{title}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<div key={item.email} style={{ display: "flex", flexWrap: "wrap", gap: "4px 14px", alignItems: "baseline" }}>
|
<div key={item.email} style={{ display: "flex", flexWrap: "wrap", gap: "4px 14px", alignItems: "baseline" }}>
|
||||||
{item.label && <span style={{ fontWeight: 700, color: "#6f4b23" }}>{item.label}</span>}
|
{item.label && <span style={{ fontWeight: 700, color: "#6f4b23" }}>{item.label}</span>}
|
||||||
@@ -37,11 +65,14 @@ export function OneTimeCredentials({
|
|||||||
Mot de passe temporaire :{" "}
|
Mot de passe temporaire :{" "}
|
||||||
<strong style={{ fontFamily: "monospace", fontSize: 15, userSelect: "all" }}>{item.tempPassword}</strong>
|
<strong style={{ fontFamily: "monospace", fontSize: 15, userSelect: "all" }}>{item.tempPassword}</strong>
|
||||||
</span>
|
</span>
|
||||||
|
<Delivery item={item} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 12, marginTop: 10, lineHeight: 1.5 }}>
|
<div style={{ fontSize: 12, marginTop: 10, lineHeight: 1.5 }}>
|
||||||
Notez-le maintenant : il n'est affiché qu'une seule fois et n'est conservé nulle part.
|
{allMailed
|
||||||
|
? "Le message est parti, mais notez-le quand même : il n'est affiché qu'une seule fois et n'est conservé nulle part."
|
||||||
|
: "Notez-le maintenant : il n'est affiché qu'une seule fois et n'est conservé nulle part."}{" "}
|
||||||
Un changement de mot de passe sera exigé à la première connexion.
|
Un changement de mot de passe sera exigé à la première connexion.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* Carte d'une promotion sur le site public.
|
||||||
|
*
|
||||||
|
* Un seul rendu pour l'accueil (les 6 dernières) et la page `/promotions`
|
||||||
|
* (toutes les offres en cours) : les deux listes doivent rester identiques.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { PromoImage } from "@/components/PromoImage";
|
||||||
|
import { MemberAvatar } from "@/components/MemberAvatar";
|
||||||
|
import { formatValidity } from "@/lib/promo-validity";
|
||||||
|
import { memberPath } from "@/lib/seo";
|
||||||
|
|
||||||
|
const STRIPE_WARM =
|
||||||
|
"repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)";
|
||||||
|
|
||||||
|
/** Rouge pour une remise chiffrée, vert pour une offre en nature. */
|
||||||
|
function badgeColor(badge: string | null) {
|
||||||
|
if (!badge) return "#1f8a5b";
|
||||||
|
return badge.includes("%") ? "#d8472b" : "#1f8a5b";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PublicPromo = {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
text: string | null;
|
||||||
|
category: string | null;
|
||||||
|
badge: string | null;
|
||||||
|
imageUrl: string | null;
|
||||||
|
validUntil: string | null;
|
||||||
|
startsOn: string | null;
|
||||||
|
endsOn: string | null;
|
||||||
|
memberId: number | null;
|
||||||
|
memberName: string | null;
|
||||||
|
memberCity: string | null;
|
||||||
|
memberLogoUrl: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PromoCard({ promo: p }: { promo: PublicPromo }) {
|
||||||
|
const validity = formatValidity(p);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={p.memberId ? memberPath({ id: p.memberId, name: p.memberName ?? "", city: p.memberCity }) : "#"}
|
||||||
|
className="lift-card"
|
||||||
|
style={{
|
||||||
|
textDecoration: "none",
|
||||||
|
color: "inherit",
|
||||||
|
background: "#fff",
|
||||||
|
border: "1px solid #ece3d0",
|
||||||
|
borderRadius: 18,
|
||||||
|
overflow: "hidden",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
aspectRatio: "1 / 1",
|
||||||
|
background: STRIPE_WARM,
|
||||||
|
overflow: "hidden",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{p.imageUrl && <PromoImage src={p.imageUrl} alt={p.title ?? ""} />}
|
||||||
|
{!p.imageUrl && (
|
||||||
|
<span style={{ fontSize: 10.5, letterSpacing: "0.12em", color: "#a99c82", textTransform: "uppercase" }}>
|
||||||
|
photo de l'offre
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{p.badge && (
|
||||||
|
<span
|
||||||
|
className="font-display"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
background: badgeColor(p.badge),
|
||||||
|
color: "#fff",
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: 18,
|
||||||
|
padding: "8px 16px",
|
||||||
|
borderRadius: "0 0 0 16px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{p.badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{p.category && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 12,
|
||||||
|
left: 12,
|
||||||
|
background: "#9a6638",
|
||||||
|
color: "#fff",
|
||||||
|
borderRadius: 999,
|
||||||
|
padding: "5px 12px",
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{p.category}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: "17px 18px 18px", display: "flex", flexDirection: "column", flex: 1 }}>
|
||||||
|
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 18, margin: "0 0 5px", color: "#26201a" }}>
|
||||||
|
{p.title}
|
||||||
|
</h3>
|
||||||
|
<p style={{ margin: "0 0 14px", fontSize: 13.5, color: "#8c8068", lineHeight: 1.5, flex: 1 }}>
|
||||||
|
{p.text}
|
||||||
|
</p>
|
||||||
|
<div style={{ borderTop: "1px solid #f0e8d6", paddingTop: 12 }}>
|
||||||
|
{/* Le logo du commerce juste à gauche de son nom. */}
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 9 }}>
|
||||||
|
<MemberAvatar name={p.memberName ?? ""} logoUrl={p.memberLogoUrl} size={28} />
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 700, color: "#3c3322" }}>{p.memberName}</div>
|
||||||
|
{validity && <div style={{ fontSize: 11.5, color: "#a99c82" }}>{validity}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { richTextToEditorHtml } from "@/lib/rich-text";
|
||||||
|
import { serializeToRichText } from "@/lib/rich-text-dom";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Éditeur visuel : on écrit, on sélectionne, on clique sur **G**, et le texte
|
||||||
|
* devient gras à l'écran. Aucune syntaxe à connaître.
|
||||||
|
*
|
||||||
|
* Ce qui part au serveur reste pourtant le format balisé restreint : à chaque
|
||||||
|
* frappe, `serializeToRichText` 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 — c'était là, et non dans l'édition
|
||||||
|
* visuelle, que se trouvait le risque.
|
||||||
|
*
|
||||||
|
* `document.execCommand` est officiellement déprécié mais reste implémenté par
|
||||||
|
* tous les navigateurs, et c'est la seule façon d'obtenir une édition riche
|
||||||
|
* sans embarquer un moteur d'édition complet. Le jour où il faudrait en
|
||||||
|
* changer, seul ce fichier bougerait : le contrat de stockage est le
|
||||||
|
* sérialiseur, pas l'éditeur.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Tool = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
title: string;
|
||||||
|
command: string;
|
||||||
|
argument?: string;
|
||||||
|
/** Nom d'état interrogé pour allumer le bouton quand le curseur est dedans. */
|
||||||
|
state?: string;
|
||||||
|
render?: "bold" | "italic";
|
||||||
|
};
|
||||||
|
|
||||||
|
const TOOLS: Tool[] = [
|
||||||
|
{ key: "bold", label: "G", title: "Gras (Ctrl+B)", command: "bold", state: "bold", render: "bold" },
|
||||||
|
{ key: "italic", label: "I", title: "Italique (Ctrl+I)", command: "italic", state: "italic", render: "italic" },
|
||||||
|
{ key: "h4", label: "Sous-titre", title: "Transformer la ligne en sous-titre", command: "formatBlock", argument: "<h4>" },
|
||||||
|
{ key: "ul", label: "Liste à puces", title: "Liste à puces", command: "insertUnorderedList", state: "insertUnorderedList" },
|
||||||
|
{ key: "ol", label: "Liste numérotée", title: "Liste numérotée", command: "insertOrderedList", state: "insertOrderedList" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function RichTextEditor({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = "Rédigez votre message…",
|
||||||
|
ariaLabel = "Message",
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (markup: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
ariaLabel?: string;
|
||||||
|
}) {
|
||||||
|
const editorRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [active, setActive] = useState<Record<string, boolean>>({});
|
||||||
|
const [linkUrl, setLinkUrl] = useState<string | null>(null);
|
||||||
|
const savedRange = useRef<Range | null>(null);
|
||||||
|
|
||||||
|
const sync = useCallback(() => {
|
||||||
|
const editor = editorRef.current;
|
||||||
|
if (!editor) return;
|
||||||
|
editor.dataset.empty = String(editor.textContent?.trim().length === 0);
|
||||||
|
onChange(serializeToRichText(editor));
|
||||||
|
}, [onChange]);
|
||||||
|
|
||||||
|
// Contenu initial posé une seule fois : réécrire le HTML à chaque frappe
|
||||||
|
// replacerait le curseur au début de la zone.
|
||||||
|
useEffect(() => {
|
||||||
|
const editor = editorRef.current;
|
||||||
|
if (!editor) return;
|
||||||
|
editor.innerHTML = richTextToEditorHtml(value);
|
||||||
|
editor.dataset.empty = String(editor.textContent?.trim().length === 0);
|
||||||
|
try {
|
||||||
|
// Sans cela, Chrome écrit `<span style="font-weight:bold">` au lieu de
|
||||||
|
// `<b>` — le sérialiseur, qui ne lit pas les styles, perdrait le gras.
|
||||||
|
document.execCommand("styleWithCSS", false, "false");
|
||||||
|
document.execCommand("defaultParagraphSeparator", false, "p");
|
||||||
|
} catch {
|
||||||
|
/* Navigateur sans execCommand : la saisie reste possible, sans mise en forme. */
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Les boutons s'allument selon l'endroit où se trouve le curseur.
|
||||||
|
useEffect(() => {
|
||||||
|
function refresh() {
|
||||||
|
const editor = editorRef.current;
|
||||||
|
if (!editor || !editor.contains(document.getSelection()?.anchorNode ?? null)) return;
|
||||||
|
const next: Record<string, boolean> = {};
|
||||||
|
for (const tool of TOOLS) {
|
||||||
|
if (!tool.state) continue;
|
||||||
|
try {
|
||||||
|
next[tool.key] = document.queryCommandState(tool.state);
|
||||||
|
} catch {
|
||||||
|
next[tool.key] = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setActive(next);
|
||||||
|
}
|
||||||
|
document.addEventListener("selectionchange", refresh);
|
||||||
|
return () => document.removeEventListener("selectionchange", refresh);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function run(command: string, argument?: string) {
|
||||||
|
editorRef.current?.focus();
|
||||||
|
try {
|
||||||
|
document.execCommand(command, false, argument);
|
||||||
|
} catch {
|
||||||
|
/* Commande refusée : on laisse le texte tel quel. */
|
||||||
|
}
|
||||||
|
sync();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTool(tool: Tool) {
|
||||||
|
// Re-cliquer sur « Sous-titre » revient à un paragraphe ordinaire.
|
||||||
|
if (tool.command === "formatBlock" && currentBlock() === "H4") {
|
||||||
|
run("formatBlock", "<p>");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
run(tool.command, tool.argument);
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentBlock(): string {
|
||||||
|
try {
|
||||||
|
return String(document.queryCommandValue("formatBlock") ?? "").toUpperCase();
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openLink() {
|
||||||
|
const selection = document.getSelection();
|
||||||
|
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
|
||||||
|
setLinkUrl("");
|
||||||
|
savedRange.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// La sélection est perdue dès que le champ d'adresse prend le focus :
|
||||||
|
// on la met de côté pour la retrouver à la validation.
|
||||||
|
savedRange.current = selection.getRangeAt(0).cloneRange();
|
||||||
|
setLinkUrl("https://");
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmLink() {
|
||||||
|
const url = (linkUrl ?? "").trim();
|
||||||
|
setLinkUrl(null);
|
||||||
|
if (!url || !savedRange.current) return;
|
||||||
|
|
||||||
|
const selection = document.getSelection();
|
||||||
|
selection?.removeAllRanges();
|
||||||
|
selection?.addRange(savedRange.current);
|
||||||
|
savedRange.current = null;
|
||||||
|
// Une adresse non http(s) est écartée par le sérialiseur : le libellé
|
||||||
|
// reste, le lien disparaît.
|
||||||
|
run("createLink", url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPaste(event: React.ClipboardEvent<HTMLDivElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
const html = event.clipboardData.getData("text/html");
|
||||||
|
const text = event.clipboardData.getData("text/plain");
|
||||||
|
|
||||||
|
if (html) {
|
||||||
|
// Le HTML collé — d'un Word, d'une page web — est analysé dans un
|
||||||
|
// document **inerte** : `DOMParser` n'exécute rien et ne charge aucune
|
||||||
|
// ressource, contrairement à une affectation d'`innerHTML`. On le
|
||||||
|
// repasse ensuite par notre format, donc seule la mise en forme connue
|
||||||
|
// survit ; on ne réinjecte que du HTML produit par nous.
|
||||||
|
const parsed = new DOMParser().parseFromString(html, "text/html");
|
||||||
|
run("insertHTML", richTextToEditorHtml(serializeToRichText(parsed.body)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
run("insertText", text);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="rich-editor-toolbar" role="toolbar" aria-label="Mise en forme">
|
||||||
|
{TOOLS.map((tool) => (
|
||||||
|
<button
|
||||||
|
key={tool.key}
|
||||||
|
type="button"
|
||||||
|
title={tool.title}
|
||||||
|
aria-pressed={active[tool.key] ?? false}
|
||||||
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
|
onClick={() => applyTool(tool)}
|
||||||
|
className={`rich-editor-tool${active[tool.key] ? " is-active" : ""}`}
|
||||||
|
style={{
|
||||||
|
fontWeight: tool.render === "bold" ? 800 : undefined,
|
||||||
|
fontStyle: tool.render === "italic" ? "italic" : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tool.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<span className="rich-editor-separator" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Insérer un lien sur le texte sélectionné"
|
||||||
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
|
onClick={openLink}
|
||||||
|
className="rich-editor-tool"
|
||||||
|
>
|
||||||
|
Lien
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Retirer la mise en forme de la sélection"
|
||||||
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
|
onClick={() => run("removeFormat")}
|
||||||
|
className="rich-editor-tool"
|
||||||
|
>
|
||||||
|
Effacer la mise en forme
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{linkUrl !== null && (
|
||||||
|
<div className="rich-editor-link">
|
||||||
|
{savedRange.current ? (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
className="field"
|
||||||
|
value={linkUrl}
|
||||||
|
autoFocus
|
||||||
|
placeholder="https://…"
|
||||||
|
onChange={(event) => setLinkUrl(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
confirmLink();
|
||||||
|
}
|
||||||
|
if (event.key === "Escape") setLinkUrl(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button type="button" className="rich-editor-tool" onClick={confirmLink}>Ajouter</button>
|
||||||
|
<button type="button" className="rich-editor-tool" onClick={() => setLinkUrl(null)}>Annuler</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="field-hint">Sélectionnez d'abord le texte à transformer en lien.</span>
|
||||||
|
<button type="button" className="rich-editor-tool" onClick={() => setLinkUrl(null)}>Fermer</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={editorRef}
|
||||||
|
className="rich-editor rich-text"
|
||||||
|
contentEditable
|
||||||
|
suppressContentEditableWarning
|
||||||
|
role="textbox"
|
||||||
|
aria-multiline="true"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
data-placeholder={placeholder}
|
||||||
|
onInput={sync}
|
||||||
|
onBlur={sync}
|
||||||
|
onPaste={onPaste}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -32,7 +32,7 @@ export async function SiteFooter() {
|
|||||||
<div className="footer-title">Accès rapide</div>
|
<div className="footer-title">Accès rapide</div>
|
||||||
<div className="footer-links">
|
<div className="footer-links">
|
||||||
<Link href="/annuaire">Annuaire</Link>
|
<Link href="/annuaire">Annuaire</Link>
|
||||||
<Link href="/#promotions">Promotions</Link>
|
<Link href="/promotions">Promotions</Link>
|
||||||
<Link href="/association">L'association</Link>
|
<Link href="/association">L'association</Link>
|
||||||
<Link href="/rencontres-passees">Rencontres passées</Link>
|
<Link href="/rencontres-passees">Rencontres passées</Link>
|
||||||
<Link href="/login">Espace adhérent</Link>
|
<Link href="/login">Espace adhérent</Link>
|
||||||
@@ -61,7 +61,7 @@ export async function SiteFooter() {
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
aria-label={`Plein R sur ${s.label}`}
|
aria-label={`Plein R sur ${s.label}`}
|
||||||
>
|
>
|
||||||
<span style={{ color: SOCIAL_BRAND[s.network], display: "inline-flex" }}>
|
<span style={{ color: SOCIAL_BRAND[s.network], display: "inline-flex", width: 17, flexShrink: 0 }}>
|
||||||
<SocialIcon network={s.network} size={17} />
|
<SocialIcon network={s.network} size={17} />
|
||||||
</span>
|
</span>
|
||||||
{s.label}
|
{s.label}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import Link from "next/link";
|
|||||||
const NAV = [
|
const NAV = [
|
||||||
{ key: "accueil", label: "Accueil", href: "/" },
|
{ key: "accueil", label: "Accueil", href: "/" },
|
||||||
{ key: "annuaire", label: "Annuaire", href: "/annuaire" },
|
{ key: "annuaire", label: "Annuaire", href: "/annuaire" },
|
||||||
{ key: "promotions", label: "Promotions", href: "/#promotions" },
|
{ key: "promotions", label: "Promotions", href: "/promotions" },
|
||||||
{ key: "association", label: "L'association", href: "/association" },
|
{ key: "association", label: "L'association", href: "/association" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, type CSSProperties } from "react";
|
||||||
|
import { IMPLICIT_TLS_PORT, impliedSecure, tlsMismatch } from "@/lib/smtp-config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Réglages d'un serveur SMTP générique.
|
||||||
|
*
|
||||||
|
* Client parce que le port et le chiffrement sont **un seul choix** présenté
|
||||||
|
* en deux champs : changer le port réaligne la case, et un couple incohérent
|
||||||
|
* s'annonce à l'écran plutôt qu'en échec de connexion illisible. Voir
|
||||||
|
* `src/lib/smtp-config.ts` pour le pourquoi des deux ports.
|
||||||
|
*
|
||||||
|
* Aucun secret ne descend ici : le mot de passe enregistré n'est pas passé en
|
||||||
|
* prop, seul `hasPassword` dit s'il faut proposer de le conserver.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PRESETS = [
|
||||||
|
{ port: IMPLICIT_TLS_PORT, label: "465 · chiffré dès l'ouverture" },
|
||||||
|
{ port: 587, label: "587 · STARTTLS" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function SmtpAccountForm({
|
||||||
|
saveAction,
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
secure,
|
||||||
|
user,
|
||||||
|
hasPassword,
|
||||||
|
fromAddress,
|
||||||
|
fromName,
|
||||||
|
submitStyle,
|
||||||
|
}: {
|
||||||
|
saveAction: (formData: FormData) => void | Promise<void>;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
secure: boolean;
|
||||||
|
user: string;
|
||||||
|
hasPassword: boolean;
|
||||||
|
fromAddress: string;
|
||||||
|
fromName: string;
|
||||||
|
submitStyle: CSSProperties;
|
||||||
|
}) {
|
||||||
|
const [currentHost, setHost] = useState(host);
|
||||||
|
const [currentPort, setPort] = useState(String(port));
|
||||||
|
const [currentSecure, setSecure] = useState(secure);
|
||||||
|
|
||||||
|
const numericPort = Number(currentPort);
|
||||||
|
const knownPort = Number.isFinite(numericPort) && numericPort > 0;
|
||||||
|
const warning = knownPort ? tlsMismatch(numericPort, currentSecure) : null;
|
||||||
|
|
||||||
|
/** Le port choisi vaut choix du mode : la case suit, et reste reprenable à la main. */
|
||||||
|
function applyPort(value: string) {
|
||||||
|
setPort(value);
|
||||||
|
const next = Number(value);
|
||||||
|
if (Number.isFinite(next) && next > 0) setSecure(impliedSecure(next));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={saveAction}>
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))", gap: 12 }}>
|
||||||
|
<label className="field-label">
|
||||||
|
Serveur
|
||||||
|
<input
|
||||||
|
className="field"
|
||||||
|
name="smtpHost"
|
||||||
|
value={currentHost}
|
||||||
|
onChange={(event) => setHost(event.target.value)}
|
||||||
|
placeholder="smtp.ionos.fr"
|
||||||
|
style={{ marginTop: 6 }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field-label">
|
||||||
|
Port
|
||||||
|
<input
|
||||||
|
className="field"
|
||||||
|
name="smtpPort"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={65535}
|
||||||
|
value={currentPort}
|
||||||
|
onChange={(event) => applyPort(event.target.value)}
|
||||||
|
style={{ marginTop: 6 }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field-label">
|
||||||
|
Identifiant
|
||||||
|
<input className="field" name="smtpUser" defaultValue={user} placeholder="bureau@pleinr.fr" style={{ marginTop: 6 }} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: 7, marginTop: 9, flexWrap: "wrap", alignItems: "center" }}>
|
||||||
|
<span style={{ fontSize: 12, color: "#9a8d72", fontWeight: 700 }}>Ports courants</span>
|
||||||
|
{PRESETS.map((preset) => {
|
||||||
|
const on = numericPort === preset.port;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={preset.port}
|
||||||
|
type="button"
|
||||||
|
onClick={() => applyPort(String(preset.port))}
|
||||||
|
style={{
|
||||||
|
border: `1px solid ${on ? "#13324F" : "#d8cdb4"}`,
|
||||||
|
background: on ? "#13324F" : "#fff",
|
||||||
|
color: on ? "#fff" : "#6c6150",
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: 12,
|
||||||
|
padding: "6px 11px",
|
||||||
|
borderRadius: 999,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{preset.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", gap: 12, marginTop: 12 }}>
|
||||||
|
<label className="field-label">
|
||||||
|
Mot de passe
|
||||||
|
<input
|
||||||
|
className="field"
|
||||||
|
name="smtpPassword"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
placeholder={hasPassword ? "Enregistré — laissez vide pour le conserver" : "Mot de passe de la boîte"}
|
||||||
|
style={{ marginTop: 6 }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field-label">
|
||||||
|
Adresse d'expédition
|
||||||
|
<input className="field" name="fromAddress" defaultValue={fromAddress} placeholder="contact@pleinr.fr" style={{ marginTop: 6 }} />
|
||||||
|
</label>
|
||||||
|
<label className="field-label">
|
||||||
|
Nom affiché
|
||||||
|
<input className="field" name="fromName" defaultValue={fromName} style={{ marginTop: 6 }} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label style={{ display: "flex", gap: 9, alignItems: "center", marginTop: 12, fontSize: 13, color: "#6c6150" }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="smtpSecure"
|
||||||
|
checked={currentSecure}
|
||||||
|
onChange={(event) => setSecure(event.target.checked)}
|
||||||
|
/>
|
||||||
|
Connexion chiffrée dès l'ouverture (port 465). Décochez pour STARTTLS (port 587).
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{warning && (
|
||||||
|
<p style={{ marginTop: 8, fontSize: 12.5, color: "#a4571f", background: "#fdf3e3", border: "1px solid #ecd9b4", borderRadius: 9, padding: "9px 11px" }}>
|
||||||
|
{warning}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="field-hint" style={{ marginTop: 8 }}>
|
||||||
|
L'adresse d'expédition doit appartenir au domaine qui authentifie la connexion, sinon
|
||||||
|
SPF et DKIM échouent et les messages partent en indésirable.
|
||||||
|
</p>
|
||||||
|
<div style={{ display: "flex", gap: 9, marginTop: 14, flexWrap: "wrap" }}>
|
||||||
|
<button type="submit" style={submitStyle}>Enregistrer</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { formatTags, parseTags, suggestTags } from "@/lib/tags";
|
||||||
|
|
||||||
|
type CategoryOption = { id: number; slug: string; label: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Champ « tags » avec suggestions automatiques.
|
||||||
|
*
|
||||||
|
* Les suggestions se recalculent à chaque saisie à partir de la catégorie (le
|
||||||
|
* `<select name="categoryId">` du formulaire, ou une catégorie fixe), de la
|
||||||
|
* commune et de la description du même formulaire. Un clic ajoute le mot ;
|
||||||
|
* champ laissé vide = les suggestions sont appliquées à l'enregistrement
|
||||||
|
* (`autoTags` côté serveur), l'adhérent garde donc toujours la main.
|
||||||
|
*
|
||||||
|
* Le champ est volontairement **non contrôlé** : l'écouteur natif posé sur le
|
||||||
|
* formulaire provoque un rendu React au milieu de l'événement de saisie, et un
|
||||||
|
* champ contrôlé y perdrait la frappe en cours.
|
||||||
|
*/
|
||||||
|
export function TagsField({
|
||||||
|
name = "tags",
|
||||||
|
defaultValue = "",
|
||||||
|
placeholder = "Levain naturel, Produits locaux, Fait maison",
|
||||||
|
categories,
|
||||||
|
fixedCategory,
|
||||||
|
}: {
|
||||||
|
name?: string;
|
||||||
|
defaultValue?: string | null;
|
||||||
|
placeholder?: string;
|
||||||
|
/** Formulaire staff : catégories sélectionnables (le select s'appelle `categoryId`). */
|
||||||
|
categories?: CategoryOption[];
|
||||||
|
/** Espace adhérent : catégorie non modifiable. */
|
||||||
|
fixedCategory?: { slug: string | null; label: string | null } | null;
|
||||||
|
}) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [current, setCurrent] = useState<string[]>(() => parseTags(defaultValue));
|
||||||
|
const [context, setContext] = useState({ categoryId: "", city: "", description: "" });
|
||||||
|
|
||||||
|
// Lit les autres champs du formulaire et se tient à jour à chaque frappe.
|
||||||
|
useEffect(() => {
|
||||||
|
const form = inputRef.current?.form;
|
||||||
|
if (!form) return;
|
||||||
|
const read = () => {
|
||||||
|
const data = new FormData(form);
|
||||||
|
setContext({
|
||||||
|
categoryId: String(data.get("categoryId") ?? ""),
|
||||||
|
city: String(data.get("city") ?? ""),
|
||||||
|
description: String(data.get("description") ?? ""),
|
||||||
|
});
|
||||||
|
setCurrent(parseTags(inputRef.current?.value));
|
||||||
|
};
|
||||||
|
read();
|
||||||
|
form.addEventListener("input", read);
|
||||||
|
form.addEventListener("change", read);
|
||||||
|
return () => {
|
||||||
|
form.removeEventListener("input", read);
|
||||||
|
form.removeEventListener("change", read);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const suggestions = useMemo(() => {
|
||||||
|
const category = fixedCategory ?? categories?.find((c) => String(c.id) === context.categoryId) ?? null;
|
||||||
|
const all = suggestTags({
|
||||||
|
categorySlug: category?.slug,
|
||||||
|
categoryLabel: category?.label,
|
||||||
|
city: context.city,
|
||||||
|
description: context.description,
|
||||||
|
});
|
||||||
|
const have = new Set(current.map((t) => t.toLowerCase()));
|
||||||
|
return all.filter((t) => !have.has(t.toLowerCase()));
|
||||||
|
}, [fixedCategory, categories, context, current]);
|
||||||
|
|
||||||
|
function setTags(tags: string[]) {
|
||||||
|
const input = inputRef.current;
|
||||||
|
if (!input) return;
|
||||||
|
input.value = formatTags(tags);
|
||||||
|
setCurrent(parseTags(input.value));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
name={name}
|
||||||
|
defaultValue={defaultValue ?? ""}
|
||||||
|
className="field"
|
||||||
|
placeholder={placeholder}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
{suggestions.length > 0 && (
|
||||||
|
<div className="tag-suggestions" aria-label="Suggestions de tags">
|
||||||
|
<span className="tag-suggestions__label">Suggestions :</span>
|
||||||
|
{suggestions.map((tag) => (
|
||||||
|
<button key={tag} type="button" className="tag-suggestion" onClick={() => setTags([...current, tag])} title={`Ajouter « ${tag} »`}>
|
||||||
|
+ {tag}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button type="button" className="tag-suggestion tag-suggestion--all" onClick={() => setTags([...current, ...suggestions])}>
|
||||||
|
Tout ajouter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="field-hint">
|
||||||
|
Mots-clés que vos clients recherchent : produits, services, marques, quartier. Laissez vide pour que les
|
||||||
|
suggestions soient appliquées automatiquement à l'enregistrement. Ils s'affichent sur votre fiche et
|
||||||
|
sont transmis aux moteurs de recherche.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,7 +6,9 @@ const STRIPE_WARM =
|
|||||||
/**
|
/**
|
||||||
* Zone d'image "vitrine" d'un adhérent.
|
* Zone d'image "vitrine" d'un adhérent.
|
||||||
* - Photo de couverture présente -> affichée en plein cadre (cover).
|
* - Photo de couverture présente -> affichée en plein cadre (cover).
|
||||||
* - Sinon, le logo est affiché centré et réduit (contain ~80%), pas étiré.
|
* - Sinon, le logo est affiché centré et réduit (contain, ~60 % du cadre) sur
|
||||||
|
* un fond fait du logo lui-même flouté : le cadre prend les couleurs de la
|
||||||
|
* marque sans que le logo soit étiré ni ne déborde.
|
||||||
* - Sinon, fond rayé + texte placeholder.
|
* - Sinon, fond rayé + texte placeholder.
|
||||||
*/
|
*/
|
||||||
export function VitrineImage({
|
export function VitrineImage({
|
||||||
@@ -43,30 +45,43 @@ export function VitrineImage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (logoOnly) {
|
if (logoOnly) {
|
||||||
|
// `height` est fixe sur le cadre : les max-height en % du logo sont donc
|
||||||
|
// résolus, là où un conteneur intermédiaire sans hauteur les laissait sans
|
||||||
|
// effet et le logo débordait.
|
||||||
return (
|
return (
|
||||||
<div style={{ ...base, background: "linear-gradient(160deg,#fffdf8 0%,#f3ecdc 100%)", padding: "min(8%, 22px)" }}>
|
<div style={{ ...base, background: "linear-gradient(160deg,#fffdf8 0%,#f3ecdc 100%)" }}>
|
||||||
{/* Cadre thème autour du logo (évite le bloc blanc nu) */}
|
|
||||||
<div
|
<div
|
||||||
|
aria-hidden
|
||||||
style={{
|
style={{
|
||||||
background: "#fff",
|
position: "absolute",
|
||||||
border: "1px solid #e6dcc6",
|
inset: 0,
|
||||||
borderRadius: 14,
|
backgroundImage: `url(${logoOnly})`,
|
||||||
boxShadow: "0 14px 30px -20px rgba(40,30,15,0.35)",
|
backgroundSize: "cover",
|
||||||
padding: "min(6%, 18px) min(9%, 28px)",
|
backgroundPosition: "center",
|
||||||
maxWidth: "84%",
|
filter: "blur(28px) saturate(1.25)",
|
||||||
maxHeight: "84%",
|
transform: "scale(1.25)",
|
||||||
display: "flex",
|
opacity: 0.32,
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
<div
|
||||||
<img
|
aria-hidden
|
||||||
src={logoOnly}
|
style={{ position: "absolute", inset: 0, background: "linear-gradient(180deg, rgba(255,253,248,0.35) 0%, rgba(246,242,232,0.6) 100%)" }}
|
||||||
alt=""
|
/>
|
||||||
style={{ maxWidth: "100%", maxHeight: "100%", objectFit: "contain", display: "block" }}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
/>
|
<img
|
||||||
</div>
|
src={logoOnly}
|
||||||
|
alt=""
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
maxWidth: "62%",
|
||||||
|
maxHeight: "62%",
|
||||||
|
width: "auto",
|
||||||
|
height: "auto",
|
||||||
|
objectFit: "contain",
|
||||||
|
display: "block",
|
||||||
|
filter: "drop-shadow(0 12px 24px rgba(40,30,15,0.18))",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
/**
|
||||||
|
* Référentiel des catégories de métiers.
|
||||||
|
*
|
||||||
|
* Source unique pour le seed, la migration d'insertion (drizzle/0012_*) et le
|
||||||
|
* bouton « Ajouter » du backend. Les slugs sont stables : ils servent de clé
|
||||||
|
* d'unicité (`ON CONFLICT (slug)`) et ne doivent jamais être renommés, sinon la
|
||||||
|
* catégorie serait dupliquée. Un libellé renommé depuis le backend est conservé :
|
||||||
|
* la migration ne touche qu'à l'ordre d'affichage (`sort`).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const CATEGORY_PALETTE = [
|
||||||
|
{ accent: "#E0A63C", tint: "#f6efdc" },
|
||||||
|
{ accent: "#6FB0C6", tint: "#e7f0f3" },
|
||||||
|
{ accent: "#9a6638", tint: "#f4ebda" },
|
||||||
|
{ accent: "#2C6FB3", tint: "#eaf0f6" },
|
||||||
|
{ accent: "#5a7a5a", tint: "#eef0ec" },
|
||||||
|
{ accent: "#7a6f9c", tint: "#efe9f3" },
|
||||||
|
{ accent: "#c98a2e", tint: "#f7efe0" },
|
||||||
|
{ accent: "#3f8aa3", tint: "#e6eff2" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type CategoryDefinition = { slug: string; label: string };
|
||||||
|
|
||||||
|
// NB : les slugs historiques (alimentation, boulangerie, restauration, mode-beaute,
|
||||||
|
// artisanat, services, sante) sont conservés pour ne pas casser les liens existants.
|
||||||
|
export const CATEGORY_REFERENTIAL: CategoryDefinition[] = [
|
||||||
|
// ---- Bouche & alimentation ----
|
||||||
|
{ slug: "alimentation", label: "Alimentation / Épicerie" },
|
||||||
|
{ slug: "epicerie-fine", label: "Épicerie fine – Produits du terroir" },
|
||||||
|
{ slug: "supermarche", label: "Supermarché – Grande distribution" },
|
||||||
|
{ slug: "boulangerie", label: "Boulangerie – Pâtisserie" },
|
||||||
|
{ slug: "boucherie-charcuterie", label: "Boucherie – Charcuterie" },
|
||||||
|
{ slug: "poissonnerie", label: "Poissonnerie" },
|
||||||
|
{ slug: "primeur", label: "Primeur – Fruits & Légumes" },
|
||||||
|
{ slug: "fromagerie", label: "Fromagerie – Crèmerie" },
|
||||||
|
{ slug: "cave", label: "Cave – Vins & Spiritueux" },
|
||||||
|
{ slug: "brasserie-artisanale", label: "Brasserie artisanale – Boissons" },
|
||||||
|
{ slug: "chocolaterie", label: "Chocolaterie – Confiserie" },
|
||||||
|
{ slug: "glacier-salon-de-the", label: "Glacier – Salon de thé" },
|
||||||
|
{ slug: "traiteur", label: "Traiteur" },
|
||||||
|
{ slug: "restauration", label: "Restauration" },
|
||||||
|
{ slug: "pizzeria", label: "Pizzeria" },
|
||||||
|
{ slug: "restauration-rapide", label: "Restauration rapide – Food truck" },
|
||||||
|
{ slug: "cafe-bar", label: "Café – Bar – Brasserie" },
|
||||||
|
// ---- Mode, beauté & soin ----
|
||||||
|
{ slug: "mode-beaute", label: "Mode & Beauté" },
|
||||||
|
{ slug: "mode-vetements", label: "Mode & Vêtements" },
|
||||||
|
{ slug: "lingerie", label: "Lingerie" },
|
||||||
|
{ slug: "vetements-enfants", label: "Vêtements enfants" },
|
||||||
|
{ slug: "chaussures-maroquinerie", label: "Chaussures – Maroquinerie" },
|
||||||
|
{ slug: "bijouterie", label: "Bijouterie – Horlogerie" },
|
||||||
|
{ slug: "coiffure", label: "Coiffure" },
|
||||||
|
{ slug: "barbier", label: "Barbier" },
|
||||||
|
{ slug: "esthetique", label: "Esthétique – Institut" },
|
||||||
|
{ slug: "onglerie", label: "Onglerie – Prothésie ongulaire" },
|
||||||
|
{ slug: "parfumerie", label: "Parfumerie" },
|
||||||
|
{ slug: "bien-etre", label: "Bien-être – Spa – Massage" },
|
||||||
|
{ slug: "optique", label: "Optique" },
|
||||||
|
{ slug: "tatouage", label: "Tatouage – Piercing" },
|
||||||
|
// ---- Maison, équipement & décoration ----
|
||||||
|
{ slug: "bazar-discount", label: "Bazar – Discount – Équipement de la maison" },
|
||||||
|
{ slug: "decoration", label: "Décoration – Ameublement" },
|
||||||
|
{ slug: "cuisiniste", label: "Cuisines – Salles de bain" },
|
||||||
|
{ slug: "literie", label: "Literie" },
|
||||||
|
{ slug: "arts-de-la-table", label: "Arts de la table – Cadeaux" },
|
||||||
|
{ slug: "electromenager-multimedia", label: "Électroménager – Multimédia" },
|
||||||
|
{ slug: "fleuriste", label: "Fleuriste" },
|
||||||
|
{ slug: "jardinerie", label: "Jardinerie – Motoculture" },
|
||||||
|
{ slug: "bricolage-jardinage", label: "Bricolage – Jardinage" },
|
||||||
|
{ slug: "animalerie", label: "Animalerie – Services animaliers" },
|
||||||
|
// ---- Artisanat & bâtiment ----
|
||||||
|
{ slug: "artisanat", label: "Artisanat" },
|
||||||
|
{ slug: "batiment", label: "Bâtiment – Rénovation" },
|
||||||
|
{ slug: "architecte", label: "Architecte – Maître d'œuvre" },
|
||||||
|
{ slug: "maconnerie", label: "Maçonnerie – Gros œuvre" },
|
||||||
|
{ slug: "couverture-toiture", label: "Couverture – Toiture – Zinguerie" },
|
||||||
|
{ slug: "plomberie", label: "Plomberie – Chauffage" },
|
||||||
|
{ slug: "electricite", label: "Électricité" },
|
||||||
|
{ slug: "isolation-energie", label: "Isolation – Énergies renouvelables" },
|
||||||
|
{ slug: "menuiserie", label: "Menuiserie – Ébénisterie" },
|
||||||
|
{ slug: "serrurerie-metallerie", label: "Serrurerie – Métallerie" },
|
||||||
|
{ slug: "carrelage-sols", label: "Carrelage – Revêtements de sols" },
|
||||||
|
{ slug: "peinture", label: "Peinture – Décoration intérieure" },
|
||||||
|
{ slug: "paysagiste", label: "Paysagiste – Espaces verts" },
|
||||||
|
{ slug: "depannage-multiservices", label: "Dépannage – Multiservices" },
|
||||||
|
// ---- Auto & mobilité ----
|
||||||
|
{ slug: "automobile", label: "Automobile – Garage" },
|
||||||
|
{ slug: "carrosserie", label: "Carrosserie" },
|
||||||
|
{ slug: "controle-technique", label: "Contrôle technique" },
|
||||||
|
{ slug: "station-service-lavage", label: "Station-service – Lavage auto" },
|
||||||
|
{ slug: "location-vehicules", label: "Location de véhicules" },
|
||||||
|
{ slug: "cycles-motos", label: "Cycles – Motos" },
|
||||||
|
{ slug: "auto-ecole", label: "Auto-école" },
|
||||||
|
{ slug: "taxi-vtc", label: "Taxi – VTC" },
|
||||||
|
// ---- Santé ----
|
||||||
|
{ slug: "sante", label: "Santé – Pharmacie" },
|
||||||
|
{ slug: "medical", label: "Médical – Paramédical" },
|
||||||
|
{ slug: "audioprothese", label: "Audioprothèse" },
|
||||||
|
{ slug: "veterinaire", label: "Vétérinaire" },
|
||||||
|
// ---- Banque, assurance & immobilier ----
|
||||||
|
{ slug: "banque-assurance", label: "Banque – Assurance" },
|
||||||
|
{ slug: "assurance", label: "Assurance – Mutuelle – Prévoyance" },
|
||||||
|
{ slug: "courtier-credit", label: "Courtier – Crédit – Financement" },
|
||||||
|
{ slug: "immobilier", label: "Immobilier" },
|
||||||
|
{ slug: "diagnostic-immobilier", label: "Diagnostic immobilier – Expertise" },
|
||||||
|
// ---- Services aux particuliers ----
|
||||||
|
{ slug: "services", label: "Services" },
|
||||||
|
{ slug: "services-a-la-personne", label: "Services à la personne – Aide à domicile" },
|
||||||
|
{ slug: "pressing-retouches", label: "Pressing – Retouches – Cordonnerie" },
|
||||||
|
{ slug: "photographe", label: "Photographe – Vidéo" },
|
||||||
|
{ slug: "pompes-funebres", label: "Pompes funèbres" },
|
||||||
|
{ slug: "agence-voyages", label: "Agence de voyages" },
|
||||||
|
{ slug: "demenagement", label: "Déménagement – Garde-meubles" },
|
||||||
|
// ---- Services aux entreprises ----
|
||||||
|
{ slug: "comptabilite", label: "Comptabilité – Gestion" },
|
||||||
|
{ slug: "juridique", label: "Juridique – Notaire" },
|
||||||
|
{ slug: "conseil", label: "Conseil aux entreprises" },
|
||||||
|
{ slug: "communication", label: "Communication – Web – Marketing" },
|
||||||
|
{ slug: "imprimerie-enseignes", label: "Imprimerie – Enseignes – Signalétique" },
|
||||||
|
{ slug: "informatique", label: "Informatique – Téléphonie" },
|
||||||
|
{ slug: "nettoyage", label: "Nettoyage – Entretien" },
|
||||||
|
{ slug: "transport", label: "Transport – Logistique" },
|
||||||
|
{ slug: "securite", label: "Sécurité" },
|
||||||
|
{ slug: "coworking", label: "Coworking – Bureaux" },
|
||||||
|
{ slug: "interim-rh", label: "Intérim – Ressources humaines" },
|
||||||
|
{ slug: "recyclage-dechets", label: "Recyclage – Gestion des déchets" },
|
||||||
|
// ---- Culture, loisirs & formation ----
|
||||||
|
{ slug: "librairie-papeterie", label: "Librairie – Papeterie – Presse" },
|
||||||
|
{ slug: "tabac-presse", label: "Tabac – Presse – Loto" },
|
||||||
|
{ slug: "jouets-puericulture", label: "Jouets – Puériculture" },
|
||||||
|
{ slug: "sport-loisirs", label: "Sport – Loisirs" },
|
||||||
|
{ slug: "articles-de-sport", label: "Articles de sport" },
|
||||||
|
{ slug: "fitness", label: "Salle de sport – Fitness" },
|
||||||
|
{ slug: "culture-arts", label: "Culture – Arts" },
|
||||||
|
{ slug: "education-formation", label: "Éducation – Formation" },
|
||||||
|
{ slug: "tourisme-hotellerie", label: "Tourisme – Hôtellerie" },
|
||||||
|
{ slug: "evenementiel", label: "Événementiel" },
|
||||||
|
// ---- Entreprise, production & institutions ----
|
||||||
|
{ slug: "industrie", label: "Industrie – Production" },
|
||||||
|
{ slug: "agriculture", label: "Agriculture – Producteur local" },
|
||||||
|
{ slug: "partenaire-institutionnel", label: "Partenaire institutionnel – Collectivité" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Lignes prêtes à insérer : couleur tournante et ordre d'affichage. */
|
||||||
|
export function categoryRows() {
|
||||||
|
return CATEGORY_REFERENTIAL.map((c, i) => ({
|
||||||
|
...c,
|
||||||
|
...CATEGORY_PALETTE[i % CATEGORY_PALETTE.length],
|
||||||
|
sort: i + 1,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -19,6 +19,9 @@ export function demoMembers(catId: (slug: string) => number | null) {
|
|||||||
address: "12 rue de la République",
|
address: "12 rue de la République",
|
||||||
postalCode: "54390",
|
postalCode: "54390",
|
||||||
phone: "03 83 49 00 00",
|
phone: "03 83 49 00 00",
|
||||||
|
contactFirstName: "Julien",
|
||||||
|
contactLastName: "Marchal",
|
||||||
|
contactPhone: "06 12 34 56 78",
|
||||||
website: "https://www.aubonpain-frouard.fr",
|
website: "https://www.aubonpain-frouard.fr",
|
||||||
memberSince: 2021,
|
memberSince: 2021,
|
||||||
tags: "Levain naturel, Produits locaux, Fait maison, Sans conservateur",
|
tags: "Levain naturel, Produits locaux, Fait maison, Sans conservateur",
|
||||||
@@ -36,6 +39,9 @@ export function demoMembers(catId: (slug: string) => number | null) {
|
|||||||
address: "3 place Stanislas",
|
address: "3 place Stanislas",
|
||||||
postalCode: "54340",
|
postalCode: "54340",
|
||||||
phone: "03 83 24 00 00",
|
phone: "03 83 24 00 00",
|
||||||
|
contactFirstName: "Sophie",
|
||||||
|
contactLastName: "Renard",
|
||||||
|
contactPhone: "06 98 76 54 32",
|
||||||
memberSince: 2019,
|
memberSince: 2019,
|
||||||
tags: "Cuisine de saison, Brunch, Terrasse, Produits frais",
|
tags: "Cuisine de saison, Brunch, Terrasse, Produits frais",
|
||||||
hours: "Lundi – Vendredi|9h – 18h\nSamedi|9h – 19h\nDimanche|10h – 15h",
|
hours: "Lundi – Vendredi|9h – 18h\nSamedi|9h – 19h\nDimanche|10h – 15h",
|
||||||
@@ -49,6 +55,9 @@ export function demoMembers(catId: (slug: string) => number | null) {
|
|||||||
categoryId: catId("mode-beaute"),
|
categoryId: catId("mode-beaute"),
|
||||||
city: "Champigneulles",
|
city: "Champigneulles",
|
||||||
address: "7 av. des Tilleuls",
|
address: "7 av. des Tilleuls",
|
||||||
|
contactFirstName: "Émilie",
|
||||||
|
contactLastName: "Bertrand",
|
||||||
|
contactPhone: "06 45 67 89 01",
|
||||||
description: "Salon de coiffure & soins, sur rendez-vous du mardi au samedi.",
|
description: "Salon de coiffure & soins, sur rendez-vous du mardi au samedi.",
|
||||||
status: "active" as const,
|
status: "active" as const,
|
||||||
highlighted: true,
|
highlighted: true,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
text,
|
text,
|
||||||
varchar,
|
varchar,
|
||||||
boolean,
|
boolean,
|
||||||
|
date,
|
||||||
timestamp,
|
timestamp,
|
||||||
index,
|
index,
|
||||||
uniqueIndex,
|
uniqueIndex,
|
||||||
@@ -21,6 +22,10 @@ export const promoStatusEnum = pgEnum("promo_status", [
|
|||||||
"expired",
|
"expired",
|
||||||
"rejected",
|
"rejected",
|
||||||
"suspended",
|
"suspended",
|
||||||
|
// Validée par l'association mais en attente de sa date de publication
|
||||||
|
// (`publish_at`). Invisible du site public tant que l'échéance n'est pas
|
||||||
|
// atteinte, et non encore diffusée sur les réseaux.
|
||||||
|
"scheduled",
|
||||||
]);
|
]);
|
||||||
// Qui a suspendu la promotion : son créateur (l'adhérent) ou l'association.
|
// Qui a suspendu la promotion : son créateur (l'adhérent) ou l'association.
|
||||||
// Une promotion suspendue par l'association ne peut être réactivée que par elle.
|
// Une promotion suspendue par l'association ne peut être réactivée que par elle.
|
||||||
@@ -37,6 +42,22 @@ export const contactStatusEnum = pgEnum("contact_status", [
|
|||||||
"read",
|
"read",
|
||||||
"archived",
|
"archived",
|
||||||
]);
|
]);
|
||||||
|
export const infoStatusEnum = pgEnum("info_status", ["draft", "published"]);
|
||||||
|
export const mailProviderEnum = pgEnum("mail_provider", ["google", "microsoft", "smtp"]);
|
||||||
|
export const mailStatusEnum = pgEnum("mail_status", [
|
||||||
|
"queued",
|
||||||
|
"sending",
|
||||||
|
"sent",
|
||||||
|
"failed",
|
||||||
|
"cancelled",
|
||||||
|
]);
|
||||||
|
export const mailKindEnum = pgEnum("mail_kind", [
|
||||||
|
"credentials",
|
||||||
|
"information",
|
||||||
|
"meeting",
|
||||||
|
"studio",
|
||||||
|
"test",
|
||||||
|
]);
|
||||||
|
|
||||||
// ---- Categories (métiers) ----
|
// ---- Categories (métiers) ----
|
||||||
export const categories = pgTable("categories", {
|
export const categories = pgTable("categories", {
|
||||||
@@ -52,7 +73,18 @@ export const categories = pgTable("categories", {
|
|||||||
export const members = pgTable("members", {
|
export const members = pgTable("members", {
|
||||||
id: serial("id").primaryKey(),
|
id: serial("id").primaryKey(),
|
||||||
name: varchar("name", { length: 200 }).notNull(),
|
name: varchar("name", { length: 200 }).notNull(),
|
||||||
|
// E-mail administratif : identifiant de connexion à la création, échanges
|
||||||
|
// avec l'association. Jamais forcément celui que le commerce veut afficher.
|
||||||
email: varchar("email", { length: 200 }).notNull(),
|
email: varchar("email", { length: 200 }).notNull(),
|
||||||
|
// E-mail public de la fiche ; à vide, la fiche retombe sur `email`.
|
||||||
|
contactEmail: varchar("contact_email", { length: 200 }),
|
||||||
|
// Référent de l'adhérent : la personne à joindre au sein du commerce, avec
|
||||||
|
// sa ligne directe. Contrairement aux coordonnées de l'établissement, ces
|
||||||
|
// trois champs ne sont montrés qu'aux visiteurs connectés (cf.
|
||||||
|
// `src/lib/member-contact.ts`), jamais dans le HTML public ni le JSON-LD.
|
||||||
|
contactFirstName: varchar("contact_first_name", { length: 120 }),
|
||||||
|
contactLastName: varchar("contact_last_name", { length: 120 }),
|
||||||
|
contactPhone: varchar("contact_phone", { length: 40 }),
|
||||||
categoryId: integer("category_id").references(() => categories.id),
|
categoryId: integer("category_id").references(() => categories.id),
|
||||||
city: varchar("city", { length: 120 }),
|
city: varchar("city", { length: 120 }),
|
||||||
address: varchar("address", { length: 240 }),
|
address: varchar("address", { length: 240 }),
|
||||||
@@ -103,11 +135,18 @@ export const promotions = pgTable("promotions", {
|
|||||||
imageUrl: text("image_url"),
|
imageUrl: text("image_url"),
|
||||||
memberId: integer("member_id").references(() => members.id),
|
memberId: integer("member_id").references(() => members.id),
|
||||||
status: promoStatusEnum("status").notNull().default("pending"),
|
status: promoStatusEnum("status").notNull().default("pending"),
|
||||||
|
// Période de validité. Les deux bornes sont facultatives et indépendantes :
|
||||||
|
// une offre peut n'avoir qu'une date de fin, qu'une date de début, ou aucune.
|
||||||
|
startsOn: date("starts_on"),
|
||||||
|
endsOn: date("ends_on"),
|
||||||
|
/** @deprecated Texte libre des anciennes promos ; sert de repli à l'affichage. */
|
||||||
validUntil: varchar("valid_until", { length: 120 }),
|
validUntil: varchar("valid_until", { length: 120 }),
|
||||||
// Réseaux demandés à la soumission. Le choix reste modifiable tant que la
|
// Réseaux demandés à la soumission. Le choix reste modifiable tant que la
|
||||||
// promo est « pending » ; la validation le fige ET déclenche la publication.
|
// promo est « pending » ; la validation le fige ET déclenche la publication.
|
||||||
shareFacebook: boolean("share_facebook").notNull().default(false),
|
shareFacebook: boolean("share_facebook").notNull().default(false),
|
||||||
shareLinkedin: boolean("share_linkedin").notNull().default(false),
|
shareLinkedin: boolean("share_linkedin").notNull().default(false),
|
||||||
|
// Programmation de la publication. Vide = mise en ligne dès la validation.
|
||||||
|
publishAt: timestamp("publish_at", { withTimezone: true }),
|
||||||
suspendedBy: promoSuspendedByEnum("suspended_by"),
|
suspendedBy: promoSuspendedByEnum("suspended_by"),
|
||||||
suspendedById: integer("suspended_by_id").references(() => users.id, { onDelete: "set null" }),
|
suspendedById: integer("suspended_by_id").references(() => users.id, { onDelete: "set null" }),
|
||||||
suspendedAt: timestamp("suspended_at", { withTimezone: true }),
|
suspendedAt: timestamp("suspended_at", { withTimezone: true }),
|
||||||
@@ -151,6 +190,11 @@ export const socialAccounts = pgTable("social_accounts", {
|
|||||||
targetName: varchar("target_name", { length: 200 }),
|
targetName: varchar("target_name", { length: 200 }),
|
||||||
connectedById: integer("connected_by_id").references(() => users.id, { onDelete: "set null" }),
|
connectedById: integer("connected_by_id").references(() => users.id, { onDelete: "set null" }),
|
||||||
connectedAt: timestamp("connected_at", { withTimezone: true }),
|
connectedAt: timestamp("connected_at", { withTimezone: true }),
|
||||||
|
// Dernier contrôle de validité auprès de la plateforme : permet au tableau de
|
||||||
|
// bord d'alerter sans refaire un appel réseau à chaque affichage.
|
||||||
|
lastCheckAt: timestamp("last_check_at", { withTimezone: true }),
|
||||||
|
lastCheckOk: boolean("last_check_ok"),
|
||||||
|
lastCheckError: text("last_check_error"),
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -264,6 +308,114 @@ export const imageConsents = pgTable("image_consents", {
|
|||||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- Informations de l'association (fil de l'espace adhérent) ----
|
||||||
|
// Le corps est du texte balisé (`src/lib/rich-text.ts`), jamais du HTML :
|
||||||
|
// il est analysé puis rendu en éléments React.
|
||||||
|
export const informations = pgTable(
|
||||||
|
"informations",
|
||||||
|
{
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
title: varchar("title", { length: 200 }).notNull(),
|
||||||
|
body: text("body").notNull(),
|
||||||
|
imageUrl: text("image_url"),
|
||||||
|
status: infoStatusEnum("status").notNull().default("draft"),
|
||||||
|
pinned: boolean("pinned").notNull().default(false),
|
||||||
|
publishedAt: timestamp("published_at", { withTimezone: true }),
|
||||||
|
authorId: integer("author_id").references(() => users.id, { onDelete: "set null" }),
|
||||||
|
// Garde anti-double-diffusion, sur le modèle de `publishPromoShares` :
|
||||||
|
// une information déjà envoyée ne repart pas à la republication.
|
||||||
|
emailSentAt: timestamp("email_sent_at", { withTimezone: true }),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
feedIdx: index("informations_feed_idx").on(table.status, table.publishedAt),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Une ligne par lecture. Une simple date « vu jusqu'ici » sur l'utilisateur ne
|
||||||
|
// permettrait ni la pastille « nouveau » par information, ni le « lue par
|
||||||
|
// 12 / 40 » du back-office.
|
||||||
|
export const informationReads = pgTable(
|
||||||
|
"information_reads",
|
||||||
|
{
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
informationId: integer("information_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => informations.id, { onDelete: "cascade" }),
|
||||||
|
userId: integer("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
readAt: timestamp("read_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
uniq: uniqueIndex("information_reads_user_info_idx").on(table.userId, table.informationId),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- Boîte mail de l'association ----
|
||||||
|
// Une ligne par fournisseur, `is_active` désigne celui qui expédie. Les secrets
|
||||||
|
// sont chiffrés (`src/lib/crypto.ts`) et ne ressortent jamais vers le
|
||||||
|
// navigateur — même règle que `social_accounts`.
|
||||||
|
export const mailAccounts = pgTable("mail_accounts", {
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
provider: mailProviderEnum("provider").notNull().unique(),
|
||||||
|
// Lue chez le fournisseur pour Google et Microsoft : on expédie comme la
|
||||||
|
// boîte authentifiée, sinon SPF et DKIM tombent.
|
||||||
|
fromAddress: varchar("from_address", { length: 200 }),
|
||||||
|
fromName: varchar("from_name", { length: 200 }),
|
||||||
|
isActive: boolean("is_active").notNull().default(false),
|
||||||
|
appId: varchar("app_id", { length: 200 }),
|
||||||
|
appSecret: text("app_secret"),
|
||||||
|
accessToken: text("access_token"),
|
||||||
|
refreshToken: text("refresh_token"),
|
||||||
|
expiresAt: timestamp("expires_at", { withTimezone: true }),
|
||||||
|
smtpHost: varchar("smtp_host", { length: 200 }),
|
||||||
|
smtpPort: integer("smtp_port"),
|
||||||
|
smtpSecure: boolean("smtp_secure").notNull().default(true),
|
||||||
|
smtpUser: varchar("smtp_user", { length: 200 }),
|
||||||
|
smtpPassword: text("smtp_password"),
|
||||||
|
connectedById: integer("connected_by_id").references(() => users.id, { onDelete: "set null" }),
|
||||||
|
connectedAt: timestamp("connected_at", { withTimezone: true }),
|
||||||
|
lastCheckAt: timestamp("last_check_at", { withTimezone: true }),
|
||||||
|
lastCheckOk: boolean("last_check_ok"),
|
||||||
|
lastCheckError: text("last_check_error"),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// File d'attente d'envoi. `to_address` est un destinataire **unique** : une
|
||||||
|
// diffusion produit autant de lignes que d'adhérents, et aucune adresse ne peut
|
||||||
|
// donc apparaître aux yeux des autres.
|
||||||
|
export const mailMessages = pgTable(
|
||||||
|
"mail_messages",
|
||||||
|
{
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
kind: mailKindEnum("kind").notNull(),
|
||||||
|
status: mailStatusEnum("status").notNull().default("queued"),
|
||||||
|
toAddress: varchar("to_address", { length: 200 }).notNull(),
|
||||||
|
toName: varchar("to_name", { length: 200 }),
|
||||||
|
subject: varchar("subject", { length: 300 }).notNull(),
|
||||||
|
html: text("html").notNull(),
|
||||||
|
text: text("text"),
|
||||||
|
replyTo: varchar("reply_to", { length: 200 }),
|
||||||
|
informationId: integer("information_id").references(() => informations.id, { onDelete: "set null" }),
|
||||||
|
meetingId: integer("meeting_id").references(() => meetings.id, { onDelete: "set null" }),
|
||||||
|
memberId: integer("member_id").references(() => members.id, { onDelete: "set null" }),
|
||||||
|
createdById: integer("created_by_id").references(() => users.id, { onDelete: "set null" }),
|
||||||
|
provider: mailProviderEnum("provider"),
|
||||||
|
attempts: integer("attempts").notNull().default(0),
|
||||||
|
nextAttemptAt: timestamp("next_attempt_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
lockedAt: timestamp("locked_at", { withTimezone: true }),
|
||||||
|
sentAt: timestamp("sent_at", { withTimezone: true }),
|
||||||
|
error: text("error"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
dueIdx: index("mail_messages_due_idx").on(table.status, table.nextAttemptAt),
|
||||||
|
informationIdx: index("mail_messages_information_idx").on(table.informationId),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
// ---- Relations ----
|
// ---- Relations ----
|
||||||
export const membersRelations = relations(members, ({ one, many }) => ({
|
export const membersRelations = relations(members, ({ one, many }) => ({
|
||||||
category: one(categories, {
|
category: one(categories, {
|
||||||
@@ -334,6 +486,21 @@ export const imageConsentsRelations = relations(imageConsents, ({ one }) => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const informationsRelations = relations(informations, ({ one, many }) => ({
|
||||||
|
author: one(users, {
|
||||||
|
fields: [informations.authorId],
|
||||||
|
references: [users.id],
|
||||||
|
}),
|
||||||
|
reads: many(informationReads),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const informationReadsRelations = relations(informationReads, ({ one }) => ({
|
||||||
|
information: one(informations, {
|
||||||
|
fields: [informationReads.informationId],
|
||||||
|
references: [informations.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
export type Category = typeof categories.$inferSelect;
|
export type Category = typeof categories.$inferSelect;
|
||||||
export type Member = typeof members.$inferSelect;
|
export type Member = typeof members.$inferSelect;
|
||||||
export type User = typeof users.$inferSelect;
|
export type User = typeof users.$inferSelect;
|
||||||
@@ -350,3 +517,9 @@ export type MeetingRegistration = typeof meetingRegistrations.$inferSelect;
|
|||||||
export type PastMeeting = typeof pastMeetings.$inferSelect;
|
export type PastMeeting = typeof pastMeetings.$inferSelect;
|
||||||
export type PastMeetingPhoto = typeof pastMeetingPhotos.$inferSelect;
|
export type PastMeetingPhoto = typeof pastMeetingPhotos.$inferSelect;
|
||||||
export type ImageConsent = typeof imageConsents.$inferSelect;
|
export type ImageConsent = typeof imageConsents.$inferSelect;
|
||||||
|
export type Information = typeof informations.$inferSelect;
|
||||||
|
export type MailAccount = typeof mailAccounts.$inferSelect;
|
||||||
|
export type MailProvider = (typeof mailProviderEnum.enumValues)[number];
|
||||||
|
export type MailMessage = typeof mailMessages.$inferSelect;
|
||||||
|
export type MailKind = (typeof mailKindEnum.enumValues)[number];
|
||||||
|
export type InformationRead = typeof informationReads.$inferSelect;
|
||||||
+5
-87
@@ -5,6 +5,7 @@ import bcrypt from "bcryptjs";
|
|||||||
import { randomInt } from "node:crypto";
|
import { randomInt } from "node:crypto";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import * as schema from "./schema";
|
import * as schema from "./schema";
|
||||||
|
import { categoryRows } from "./categories";
|
||||||
import {
|
import {
|
||||||
DEMO_PASSWORD,
|
DEMO_PASSWORD,
|
||||||
demoActivity,
|
demoActivity,
|
||||||
@@ -40,93 +41,10 @@ async function main() {
|
|||||||
|
|
||||||
console.log("Seeding database…");
|
console.log("Seeding database…");
|
||||||
|
|
||||||
// ---- Categories (métiers) — liste complète ----
|
// ---- Categories (métiers) : référentiel partagé (src/db/categories.ts) ----
|
||||||
const PALETTE = [
|
// La migration 0012 insère déjà ce référentiel ; le seed le rejoue par sécurité
|
||||||
{ accent: "#E0A63C", tint: "#f6efdc" },
|
// pour une base créée sans migration, sans jamais écraser un libellé existant.
|
||||||
{ accent: "#6FB0C6", tint: "#e7f0f3" },
|
const categoryData = categoryRows();
|
||||||
{ accent: "#9a6638", tint: "#f4ebda" },
|
|
||||||
{ accent: "#2C6FB3", tint: "#eaf0f6" },
|
|
||||||
{ accent: "#5a7a5a", tint: "#eef0ec" },
|
|
||||||
{ accent: "#7a6f9c", tint: "#efe9f3" },
|
|
||||||
{ accent: "#c98a2e", tint: "#f7efe0" },
|
|
||||||
{ accent: "#3f8aa3", tint: "#e6eff2" },
|
|
||||||
];
|
|
||||||
// NB: les slugs existants (alimentation, boulangerie, restauration, mode-beaute,
|
|
||||||
// artisanat, services, sante) sont conservés pour ne pas casser les liens existants.
|
|
||||||
const categoryList: { slug: string; label: string }[] = [
|
|
||||||
// Bouche & alimentation
|
|
||||||
{ slug: "alimentation", label: "Alimentation / Épicerie" },
|
|
||||||
{ slug: "boulangerie", label: "Boulangerie – Pâtisserie" },
|
|
||||||
{ slug: "boucherie-charcuterie", label: "Boucherie – Charcuterie" },
|
|
||||||
{ slug: "poissonnerie", label: "Poissonnerie" },
|
|
||||||
{ slug: "primeur", label: "Primeur – Fruits & Légumes" },
|
|
||||||
{ slug: "fromagerie", label: "Fromagerie – Crèmerie" },
|
|
||||||
{ slug: "cave", label: "Cave – Vins & Spiritueux" },
|
|
||||||
{ slug: "chocolaterie", label: "Chocolaterie – Confiserie" },
|
|
||||||
{ slug: "traiteur", label: "Traiteur" },
|
|
||||||
{ slug: "restauration", label: "Restauration" },
|
|
||||||
{ slug: "restauration-rapide", label: "Restauration rapide" },
|
|
||||||
{ slug: "cafe-bar", label: "Café – Bar – Brasserie" },
|
|
||||||
// Mode, beauté & soin
|
|
||||||
{ slug: "mode-beaute", label: "Mode & Beauté" },
|
|
||||||
{ slug: "mode-vetements", label: "Mode & Vêtements" },
|
|
||||||
{ slug: "chaussures-maroquinerie", label: "Chaussures – Maroquinerie" },
|
|
||||||
{ slug: "bijouterie", label: "Bijouterie – Horlogerie" },
|
|
||||||
{ slug: "coiffure", label: "Coiffure" },
|
|
||||||
{ slug: "esthetique", label: "Esthétique – Institut" },
|
|
||||||
{ slug: "parfumerie", label: "Parfumerie" },
|
|
||||||
{ slug: "bien-etre", label: "Bien-être – Spa – Massage" },
|
|
||||||
{ slug: "optique", label: "Optique" },
|
|
||||||
{ slug: "tatouage", label: "Tatouage – Piercing" },
|
|
||||||
// Maison, déco & artisanat / bâtiment
|
|
||||||
{ slug: "artisanat", label: "Artisanat" },
|
|
||||||
{ slug: "decoration", label: "Décoration – Ameublement" },
|
|
||||||
{ slug: "fleuriste", label: "Fleuriste" },
|
|
||||||
{ slug: "bricolage-jardinage", label: "Bricolage – Jardinage" },
|
|
||||||
{ slug: "batiment", label: "Bâtiment – Rénovation" },
|
|
||||||
{ slug: "plomberie", label: "Plomberie – Chauffage" },
|
|
||||||
{ slug: "electricite", label: "Électricité" },
|
|
||||||
{ slug: "menuiserie", label: "Menuiserie – Ébénisterie" },
|
|
||||||
{ slug: "peinture", label: "Peinture – Décoration intérieure" },
|
|
||||||
{ slug: "paysagiste", label: "Paysagiste – Espaces verts" },
|
|
||||||
// Auto & mobilité
|
|
||||||
{ slug: "automobile", label: "Automobile – Garage" },
|
|
||||||
{ slug: "carrosserie", label: "Carrosserie" },
|
|
||||||
{ slug: "cycles-motos", label: "Cycles – Motos" },
|
|
||||||
// Santé
|
|
||||||
{ slug: "sante", label: "Santé – Pharmacie" },
|
|
||||||
{ slug: "medical", label: "Médical – Paramédical" },
|
|
||||||
{ slug: "audioprothese", label: "Audioprothèse" },
|
|
||||||
// Services aux particuliers & entreprises
|
|
||||||
{ slug: "services", label: "Services" },
|
|
||||||
{ slug: "banque-assurance", label: "Banque – Assurance" },
|
|
||||||
{ slug: "immobilier", label: "Immobilier" },
|
|
||||||
{ slug: "comptabilite", label: "Comptabilité – Gestion" },
|
|
||||||
{ slug: "juridique", label: "Juridique – Notaire" },
|
|
||||||
{ slug: "communication", label: "Communication – Web – Marketing" },
|
|
||||||
{ slug: "informatique", label: "Informatique – Téléphonie" },
|
|
||||||
{ slug: "nettoyage", label: "Nettoyage – Entretien" },
|
|
||||||
{ slug: "transport", label: "Transport – Logistique" },
|
|
||||||
{ slug: "securite", label: "Sécurité" },
|
|
||||||
{ slug: "coworking", label: "Coworking – Bureaux" },
|
|
||||||
{ slug: "interim-rh", label: "Intérim – Ressources humaines" },
|
|
||||||
// Loisirs, culture & formation
|
|
||||||
{ slug: "sport-loisirs", label: "Sport – Loisirs" },
|
|
||||||
{ slug: "fitness", label: "Salle de sport – Fitness" },
|
|
||||||
{ slug: "culture-arts", label: "Culture – Arts" },
|
|
||||||
{ slug: "education-formation", label: "Éducation – Formation" },
|
|
||||||
{ slug: "tourisme-hotellerie", label: "Tourisme – Hôtellerie" },
|
|
||||||
{ slug: "evenementiel", label: "Événementiel" },
|
|
||||||
{ slug: "animalerie", label: "Animalerie – Services animaliers" },
|
|
||||||
// Entreprise & production
|
|
||||||
{ slug: "industrie", label: "Industrie – Production" },
|
|
||||||
{ slug: "agriculture", label: "Agriculture – Producteur local" },
|
|
||||||
];
|
|
||||||
const categoryData = categoryList.map((c, i) => ({
|
|
||||||
...c,
|
|
||||||
...PALETTE[i % PALETTE.length],
|
|
||||||
sort: i + 1,
|
|
||||||
}));
|
|
||||||
|
|
||||||
for (const c of categoryData) {
|
for (const c of categoryData) {
|
||||||
await db.insert(categories).values(c).onConflictDoNothing({ target: categories.slug });
|
await db.insert(categories).values(c).onConflictDoNothing({ target: categories.slug });
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* Boucles de fond : libération des publications programmées et vidage de la
|
||||||
|
* file d'envoi. Fichier séparé de `instrumentation.ts` : il touche la base et
|
||||||
|
* `nodemailer`, et ne doit donc jamais entrer dans le bundle edge du
|
||||||
|
* middleware — c'est cet import-là que l'`IgnorePlugin` de `next.config.mjs`
|
||||||
|
* coupe.
|
||||||
|
*/
|
||||||
|
import { releaseDuePromotions } from "@/lib/promo-publish";
|
||||||
|
import { processOutbox } from "@/lib/mail-outbox";
|
||||||
|
|
||||||
|
const PROMO_INTERVAL_MS = 60_000;
|
||||||
|
// Plus court que la minute des promotions : un mot de passe ou une invitation
|
||||||
|
// mis en file ne doit pas attendre une minute pleine.
|
||||||
|
const MAIL_INTERVAL_MS = 20_000;
|
||||||
|
|
||||||
|
async function promoTick() {
|
||||||
|
try {
|
||||||
|
const n = await releaseDuePromotions();
|
||||||
|
if (n > 0) console.log(`[promotions] ${n} publication(s) programmée(s) mise(s) en ligne`);
|
||||||
|
} catch (error) {
|
||||||
|
// `releaseDuePromotions` avale déjà ses erreurs ; ce filet couvre le cas
|
||||||
|
// d'une base injoignable au démarrage, qui ne doit pas tuer le serveur.
|
||||||
|
console.error("[promotions] libérateur :", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mailTick() {
|
||||||
|
try {
|
||||||
|
const { sent, failed } = await processOutbox();
|
||||||
|
if (sent || failed) console.log(`[mail] ${sent} envoyé(s), ${failed} en échec`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[mail] file d'attente :", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Un premier passage au démarrage rattrape les échéances tombées pendant un
|
||||||
|
// redéploiement ou une coupure.
|
||||||
|
if (process.env.PROMO_SCHEDULER !== "off") {
|
||||||
|
void promoTick();
|
||||||
|
setInterval(promoTick, PROMO_INTERVAL_MS).unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.MAIL_WORKER !== "off") {
|
||||||
|
void mailTick();
|
||||||
|
setInterval(mailTick, MAIL_INTERVAL_MS).unref();
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* Démarrage du serveur : boucles de fond.
|
||||||
|
*
|
||||||
|
* Le déploiement est un conteneur unique et permanent (`docker-compose`), il
|
||||||
|
* n'y a donc ni cron ni file d'attente. Une boucle minute suffit : elle relit
|
||||||
|
* les promotions `scheduled` dont l'échéance est passée, les met en ligne et
|
||||||
|
* lance la diffusion réseaux. La minute est aussi la précision annoncée dans
|
||||||
|
* le formulaire.
|
||||||
|
*
|
||||||
|
* Sûr à plusieurs instances : la bascule passe par un `UPDATE … RETURNING`
|
||||||
|
* filtré sur le statut (voir `releaseDuePromotions`), une seule instance
|
||||||
|
* récupère chaque promotion.
|
||||||
|
*/
|
||||||
|
export async function register() {
|
||||||
|
// `register()` est aussi appelé pour le runtime edge, où il n'y a ni base
|
||||||
|
// ni `setInterval` durable : le module Node est chargé à la demande pour ne
|
||||||
|
// pas entrer dans ce bundle.
|
||||||
|
if (process.env.NEXT_RUNTIME !== "nodejs") return;
|
||||||
|
// Chaque boucle a son propre interrupteur, appliqué à l'intérieur du module :
|
||||||
|
// couper le libérateur de promotions ici couperait aussi l'envoi des
|
||||||
|
// e-mails, qui n'a rien à voir avec lui.
|
||||||
|
if (process.env.PROMO_SCHEDULER === "off" && process.env.MAIL_WORKER === "off") return;
|
||||||
|
await import("./instrumentation-node");
|
||||||
|
}
|
||||||
Loaded 100 of 148 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user