mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-22 14:48:04 +02:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd16e05b47 | ||
|
|
e9b6fdfd91 | ||
|
|
503adcb20e | ||
|
|
50b8732080 | ||
|
|
4b12dc5164 | ||
|
|
353d38bebf | ||
|
|
a52e13e623 | ||
|
|
cf8cf5c8e2 | ||
|
|
d99ee7ec07 | ||
|
|
e980ab6e04 | ||
|
|
02c5378a47 | ||
|
|
81b99ed5fb | ||
|
|
17511a73c3 | ||
|
|
23d1d14a49 | ||
|
|
8b46cd6316 | ||
|
|
bfc82b3b3b | ||
|
|
c8417d8560 | ||
|
|
f9d0d6c825 |
No files matched your search
@@ -41,14 +41,77 @@ docker compose up --build # full stack
|
||||
- **Data reads** for the public site live in `src/lib/queries.ts`.
|
||||
- After changing `src/db/schema.ts`, run `npm run db:generate` and commit the new
|
||||
file under `drizzle/`.
|
||||
- **Catégories** : le référentiel vit dans `src/db/categories.ts` (slugs stables,
|
||||
jamais renommés). Il est inséré par la migration `0012_referentiel_categories`
|
||||
(`ON CONFLICT (slug)` : seul `sort` est réaligné, un libellé renommé depuis le
|
||||
backend est conservé) et rejoué par le seed. Pour ajouter une catégorie, on
|
||||
l'ajoute au référentiel **et** on génère une nouvelle migration `--custom`
|
||||
du même modèle ; `tests/categories.test.ts` vérifie la cohérence.
|
||||
|
||||
## Promotions
|
||||
|
||||
Statuts : `pending` → `live` → `suspended` ⇄ `live` (+ `rejected` / `expired`).
|
||||
Statuts : `pending` → (`scheduled` →) `live` → `suspended` ⇄ `live`
|
||||
(+ `rejected` / `expired`).
|
||||
`promotions.suspended_by` retient qui a suspendu (`member` ou `staff`) : une
|
||||
suspension par le staff ne peut être levée que par le staff. Les lectures
|
||||
publiques filtrent sur `status = 'live'`, donc une promo suspendue disparaît du
|
||||
site sans traitement supplémentaire.
|
||||
publiques filtrent sur le statut `live`, donc une promo suspendue — ou encore
|
||||
programmée — disparaît du site sans traitement supplémentaire.
|
||||
|
||||
**Période de validité** : `promotions.starts_on` / `ends_on` (colonnes `date`,
|
||||
les deux facultatives et indépendantes). `src/lib/promo-validity.ts` est **pur**
|
||||
et verrouillé par `tests/promo-validity.test.ts` : `formatValidity()` rend
|
||||
« Valable du 1er au 15 mars 2027 » / « Valable jusqu'au … » / « Valable à partir
|
||||
du … », `formatValidityShort()` la variante sans « Valable » pour les lignes de
|
||||
méta, `isRangeInvalid()` garde le formulaire et l'action. Sans date, rien n'est
|
||||
affiché ; l'ancien texte libre `valid_until` (jamais écrit par l'application)
|
||||
ne sert plus que de repli d'affichage. La même phrase part dans le message
|
||||
Facebook / LinkedIn (`buildPromoMessage`) : un seul formateur pour le site,
|
||||
le backoffice et les réseaux.
|
||||
|
||||
La liste publique vit sur **`/promotions`** (toutes les offres en cours) ;
|
||||
l'accueil n'en montre que les six dernières et renvoie vers elle. Les deux
|
||||
listes partagent `src/components/PromoCard.tsx` : un seul rendu, pas deux
|
||||
cartes à maintenir. `getLivePromotions(limit)` accepte `null` pour « tout »,
|
||||
la limite restant appliquée en SQL. Les liens « Promotions » de l'en-tête, du
|
||||
pied de page et des publications réseaux pointent sur cette page, plus sur
|
||||
l'ancre `/#promotions`.
|
||||
|
||||
Une promotion n'est **affichée que pendant sa période** : les lectures de
|
||||
`src/lib/queries.ts` passent par `VISIBLE_PROMO` (statut `live` **et** fenêtre
|
||||
de dates, journée calculée en `Europe/Paris` — un conteneur en UTC retirerait
|
||||
sinon une offre deux heures trop tôt). Le statut n'est pas modifié : hors
|
||||
période, le backoffice affiche `visibilityNote()` (« pas encore affichée » /
|
||||
« période terminée »), et l'offre revient d'elle-même si les dates changent.
|
||||
|
||||
**Publication programmée** : `promotions.publish_at` (`timestamptz`, vide =
|
||||
mise en ligne immédiate). L'adhérent propose une date dans son formulaire, le
|
||||
modérateur la garde, la déplace ou la vide dans « Valider » — comme les cases
|
||||
réseaux, c'est le dernier moment où elle est ajustable. Une promotion validée
|
||||
avec une échéance future passe au statut `scheduled` : invisible du site et
|
||||
**rien n'est diffusé**. `releaseDuePromotions()` (`src/lib/promo-publish.ts`)
|
||||
fait la bascule `scheduled → live` par un `UPDATE … RETURNING` filtré sur le
|
||||
statut — atomique, donc sans double publication même à plusieurs instances —
|
||||
puis appelle `publishPromoShares()`. Le bouton « Publier maintenant » de la
|
||||
modération court-circuite l'attente.
|
||||
|
||||
`src/lib/promo-schedule.ts` est **pur** (`tests/promo-schedule.test.ts`) :
|
||||
il convertit la saisie `datetime-local` depuis/vers l'heure de l'association
|
||||
(`Europe/Paris`, changements d'heure compris) et refuse une saisie illisible
|
||||
plutôt que de la transformer en publication immédiate.
|
||||
|
||||
Le déclencheur est `src/instrumentation.ts` : une boucle d'une minute démarrée
|
||||
avec le serveur, avec un premier passage au démarrage pour rattraper les
|
||||
échéances tombées pendant un redéploiement. `PROMO_SCHEDULER=off` la désactive.
|
||||
Le travail vit dans `src/instrumentation-node.ts` parce que Next compile aussi
|
||||
`instrumentation.ts` pour le runtime edge du middleware : `next.config.mjs`
|
||||
l'écarte de ce bundle (`IgnorePlugin`), sans quoi webpack tente d'y embarquer
|
||||
`pg` et ses dépendances Node.
|
||||
|
||||
`src/lib/promo-publish.ts` porte `publishPromoShares()` — et non plus
|
||||
`backend/actions.ts` — parce qu'il a deux appelants : une action serveur et la
|
||||
boucle de libération, qui n'a pas de requête et ne peut donc pas appeler
|
||||
`revalidatePath()`. `src/lib/activity-log.ts` porte `logActivity()` pour la
|
||||
même raison.
|
||||
|
||||
## Réseaux sociaux
|
||||
|
||||
@@ -68,24 +131,45 @@ site sans traitement supplémentaire.
|
||||
anti-CSRF passe par un cookie httpOnly ; aucun jeton ne transite par une URL.
|
||||
- Facebook : le jeton de page n'expire pas. LinkedIn : 60 jours, rafraîchissement
|
||||
programmatique réservé à certains partenaires, d'où le bandeau de reconnexion.
|
||||
- « N'expire pas » ≠ « ne meurt jamais » : `checkTokenHealth()` interroge la
|
||||
plateforme et persiste le verdict dans `social_accounts.last_check_*`.
|
||||
L'écran Réseaux contrôle à chaque affichage ; le tableau de bord passe par
|
||||
`tokenHealthCached()` (6 h) et croise le résultat avec la dernière tentative
|
||||
de publication en échec. Une date d'expiration seule ne suffit pas à alerter.
|
||||
- Les images de promo sont stockées en data-URI : l'upload se fait donc en
|
||||
binaire (multipart pour Facebook, Images API en 3 étapes pour LinkedIn), pas
|
||||
par URL.
|
||||
- La diffusion est déclenchée **par la validation**, pas par un bouton :
|
||||
- La diffusion est déclenchée **par la validation** (ou par l'échéance d'une
|
||||
publication programmée), pas par un bouton :
|
||||
`promotions.share_facebook` / `share_linkedin` sont choisis par l'adhérent,
|
||||
ajustables par le modérateur dans le formulaire « Valider », puis figés
|
||||
(`status !== 'pending'`).
|
||||
- `publishPromoShares()` dans `backend/actions.ts` est le seul point de
|
||||
- `publishPromoShares()` dans `src/lib/promo-publish.ts` est le seul point de
|
||||
publication. Elle ne lève jamais et ignore tout réseau ayant déjà une ligne
|
||||
`social_posts` en `posted` : c'est la garde anti-republication, qui couvre
|
||||
aussi le cycle suspension → remise en ligne.
|
||||
- `retryPromoShare` ne sert qu'au rattrapage d'un échec sur un réseau déjà
|
||||
choisi ; il ne peut pas élargir la diffusion.
|
||||
- L'URL publique du site : `siteUrl()` lit le réglage `site_public_url` puis
|
||||
`NEXT_PUBLIC_SITE_URL` / `AUTH_URL` ; `publicBaseUrl()` y ajoute un repli sur
|
||||
l'origine de la requête (`src/lib/site-url.ts`, en-têtes `X-Forwarded-*`).
|
||||
L'adresse de retour OAuth et les liens des publications passent par
|
||||
`publicBaseUrl()` : la connexion marche sans réglage tant que l'admin passe
|
||||
par l'adresse publique.
|
||||
- La CSP (`src/middleware.ts`) ne pose `upgrade-insecure-requests` qu'en HTTPS,
|
||||
sinon un test en HTTP par IP voit toutes ses navigations basculer vers un
|
||||
`https://` inexistant.
|
||||
- Les URLs publiques des pages FB/LinkedIn sont des `site_settings`
|
||||
(`association_facebook`, `association_linkedin`), éditables dans Paramètres.
|
||||
|
||||
## Sécurité
|
||||
|
||||
- **Erreurs des server actions** : un `throw` dans une action est masqué par
|
||||
Next en production (message remplacé par un digest) et fait tomber la page
|
||||
sur « Application error ». Les échecs **attendus** — e-mail déjà pris, champ
|
||||
manquant — sont donc **renvoyés** (`ActionError = { error: string }`) et
|
||||
affichés par le formulaire, qui conserve la saisie. Le `throw` reste réservé
|
||||
aux violations d'accès, qui n'ont pas à s'expliquer à l'utilisateur.
|
||||
- Le journal d'activité agrège des saisies de tiers, dont le formulaire de
|
||||
contact **public** : il est filtré à l'écriture (`sanitizeActivityMessage`) et
|
||||
rendu en éléments React (`activityNodes`), jamais en HTML brut.
|
||||
@@ -131,6 +215,41 @@ site sans traitement supplémentaire.
|
||||
`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>`.
|
||||
- **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.
|
||||
- La catégorie d'une promotion est un **type de produit ou de service**
|
||||
(`src/lib/promo-categories.ts`, groupes pour `<optgroup>`), pas le métier de
|
||||
l'adhérent ; `defaultPromoCategory(slug)` pré-sélectionne depuis le métier.
|
||||
|
||||
- `members.email` est l'e-mail **administratif** (identifiant de connexion à la
|
||||
création, échanges avec l'association) ; `members.contact_email` est l'e-mail
|
||||
**public** de la fiche, saisi par l'adhérent ou le staff. La fiche et le
|
||||
JSON-LD affichent `contact_email || email` : ne montrez jamais `email` seul.
|
||||
- `VitrineImage` rend la couverture en `cover`, sinon le logo en `contain`
|
||||
(~60 % d'un cadre à hauteur fixe) sur un fond du logo flouté, sinon un
|
||||
placeholder rayé. Ne pas l'entourer d'un conteneur sans hauteur : les
|
||||
`max-height` en % ne seraient plus résolus et le logo déborderait.
|
||||
|
||||
## Roles
|
||||
|
||||
|
||||
@@ -87,12 +87,12 @@ vivent dans le volume `caddy_data` et se renouvellent seuls. Une fois le DNS
|
||||
posé, vérifier la délivrance avec
|
||||
`docker logs pleinr-caddy 2>&1 | grep -i "certificate obtained"`.
|
||||
|
||||
> **Vérification par l'IP** : en HTTP sur une adresse IP, la directive
|
||||
> `upgrade-insecure-requests` de la CSP (`src/middleware.ts`) peut empêcher le
|
||||
> navigateur de charger styles et scripts. Pour un test fidèle avant le DNS,
|
||||
> passer par un tunnel SSH — `ssh -L 8080:localhost:80 utilisateur@IP` — puis
|
||||
> ouvrir `http://localhost:8080` : `localhost` est une origine de confiance, la
|
||||
> directive ne s'y applique pas.
|
||||
> **Vérification par l'IP** : la directive `upgrade-insecure-requests` de la
|
||||
> CSP (`src/middleware.ts`) n'est posée que sur les pages servies en HTTPS. Un
|
||||
> test en HTTP par l'IP ou par le port direct fonctionne donc sans tunnel. En
|
||||
> revanche, Facebook et LinkedIn n'acceptent qu'une adresse de retour OAuth en
|
||||
> HTTPS sur un nom de domaine : la connexion des réseaux sociaux ne peut être
|
||||
> validée qu'une fois le DNS posé.
|
||||
|
||||
Mise à jour : `git pull && docker compose up -d --build`.
|
||||
|
||||
|
||||
@@ -0,0 +1,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;
|
||||
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,41 @@
|
||||
"when": 1788512273181,
|
||||
"tag": "0011_drop_temp_password",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "7",
|
||||
"when": 1788675288708,
|
||||
"tag": "0012_referentiel_categories",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "7",
|
||||
"when": 1788680191881,
|
||||
"tag": "0013_contact_email",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "7",
|
||||
"when": 1788691242685,
|
||||
"tag": "0014_lean_marvel_zombies",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "7",
|
||||
"when": 1788691741621,
|
||||
"tag": "0015_curious_winter_soldier",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "7",
|
||||
"when": 1788692576315,
|
||||
"tag": "0016_tiresome_korg",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -8,6 +8,20 @@ const nextConfig = {
|
||||
// Les images d'entête/logo sont envoyées en data-URL via server action.
|
||||
serverActions: { bodySizeLimit: "4mb" },
|
||||
},
|
||||
// `instrumentation.ts` est compilé pour les deux runtimes dès qu'un
|
||||
// middleware existe. Son chargement du libérateur de publications est déjà
|
||||
// conditionné à `NEXT_RUNTIME === "nodejs"`, mais webpack suit quand même
|
||||
// l'import et tente d'embarquer `pg` (donc `fs`, `path`, `stream`) dans le
|
||||
// bundle edge, où ces modules n'existent pas. On coupe la branche à la
|
||||
// source : côté edge, le module Node n'est tout simplement pas résolu.
|
||||
webpack: (config, { nextRuntime, webpack }) => {
|
||||
if (nextRuntime === "edge") {
|
||||
config.plugins.push(
|
||||
new webpack.IgnorePlugin({ resourceRegExp: /^\.\/instrumentation-node$/ })
|
||||
);
|
||||
}
|
||||
return config;
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { notFound, permanentRedirect } from "next/navigation";
|
||||
import type { Metadata } from "next";
|
||||
import { Sparkle } from "@/components/Sparkle";
|
||||
import { SiteHeader } from "@/components/SiteHeader";
|
||||
@@ -15,8 +15,10 @@ import {
|
||||
memberPath,
|
||||
metaDescription,
|
||||
pageMetadata,
|
||||
parseMemberParam,
|
||||
} from "@/lib/seo";
|
||||
import { publicBaseUrl } from "@/lib/seo-server";
|
||||
import { autoTags, formatTags, parseTags } from "@/lib/tags";
|
||||
import {
|
||||
formatSlots,
|
||||
getOpenStatus,
|
||||
@@ -24,6 +26,7 @@ import {
|
||||
parseMemberHours,
|
||||
websiteLabel,
|
||||
} from "@/lib/member-profile";
|
||||
import { formatValidity } from "@/lib/promo-validity";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -108,7 +111,7 @@ export async function generateMetadata({
|
||||
params: Promise<{ id: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { id } = await params;
|
||||
const memberId = Number(id);
|
||||
const memberId = parseMemberParam(id);
|
||||
const member = memberId ? await getPublicMember(memberId) : null;
|
||||
if (!member || member.status !== "active") {
|
||||
return { title: "Adhérent introuvable", robots: NOINDEX };
|
||||
@@ -119,7 +122,7 @@ export async function generateMetadata({
|
||||
return pageMetadata({
|
||||
title: memberTitle(member),
|
||||
description: metaDescription(member.description, fallback),
|
||||
path: memberPath(member.id),
|
||||
path: memberPath(member),
|
||||
ogType: "profile",
|
||||
});
|
||||
}
|
||||
@@ -130,12 +133,17 @@ export default async function FicheAdherentPage({
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const memberId = Number(id);
|
||||
const memberId = parseMemberParam(id);
|
||||
if (!memberId) notFound();
|
||||
|
||||
const member = await getPublicMember(memberId);
|
||||
if (!member || member.status !== "active") notFound();
|
||||
|
||||
// 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] = await Promise.all([getMemberLivePromotions(memberId), publicBaseUrl()]);
|
||||
const accent = member.accent ?? "#E0A63C";
|
||||
|
||||
@@ -146,11 +154,22 @@ export default async function FicheAdherentPage({
|
||||
[member.address, member.postalCode, member.city].filter(Boolean).join(", ")
|
||||
);
|
||||
const paragraphs = (member.description ?? "").split("\n").map((s) => s.trim()).filter(Boolean);
|
||||
const tags = (member.tags ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
||||
// Fiche jamais ré-enregistrée depuis l'arrivée des tags automatiques : on
|
||||
// affiche les suggestions du métier plutôt qu'une rubrique vide.
|
||||
const tags = parseTags(
|
||||
autoTags(member.tags, {
|
||||
categorySlug: member.categorySlug,
|
||||
categoryLabel: member.categoryLabel,
|
||||
city: member.city,
|
||||
description: member.description,
|
||||
})
|
||||
);
|
||||
const hours = parseMemberHours(member.hours);
|
||||
const openStatus = getOpenStatus(hours);
|
||||
const normalizedWebsite = normalizeWebsite(member.website);
|
||||
const shortWebsite = websiteLabel(member.website);
|
||||
// E-mail public : celui saisi pour la fiche, sinon l'e-mail du compte.
|
||||
const contactEmail = (member.contactEmail ?? "").trim() || member.email;
|
||||
const phoneHref = member.phone ? `tel:${member.phone.replace(/[^\d+]/g, "")}` : null;
|
||||
|
||||
const sectionCard: React.CSSProperties = {
|
||||
@@ -174,9 +193,9 @@ export default async function FicheAdherentPage({
|
||||
breadcrumbJsonLd(baseUrl, [
|
||||
{ name: "Accueil", path: "/" },
|
||||
{ name: "Annuaire", path: "/annuaire" },
|
||||
{ name: member.name, path: memberPath(member.id) },
|
||||
{ name: member.name, path: canonical },
|
||||
]),
|
||||
localBusinessJsonLd(baseUrl, member, hours, normalizedWebsite),
|
||||
localBusinessJsonLd(baseUrl, { ...member, email: contactEmail, tags: formatTags(tags) }, hours, normalizedWebsite),
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -346,8 +365,8 @@ export default async function FicheAdherentPage({
|
||||
{phoneHref && member.phone && (
|
||||
<ContactAction href={phoneHref} icon="phone" label="Appeler" detail={member.phone} />
|
||||
)}
|
||||
{member.email && (
|
||||
<ContactAction href={`mailto:${member.email}`} icon="mail" label="Envoyer un e-mail" detail={member.email} />
|
||||
{contactEmail && (
|
||||
<ContactAction href={`mailto:${contactEmail}`} icon="mail" label="Envoyer un e-mail" detail={contactEmail} />
|
||||
)}
|
||||
{normalizedWebsite && shortWebsite && (
|
||||
<ContactAction href={normalizedWebsite} icon="website" label="Visiter le site" detail={shortWebsite} external />
|
||||
@@ -361,7 +380,7 @@ export default async function FicheAdherentPage({
|
||||
external
|
||||
/>
|
||||
)}
|
||||
{!phoneHref && !member.email && !normalizedWebsite && !fullAddress && (
|
||||
{!phoneHref && !contactEmail && !normalizedWebsite && !fullAddress && (
|
||||
<div className="practical-empty">Coordonnées non communiquées.</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -419,8 +438,8 @@ export default async function FicheAdherentPage({
|
||||
<div style={{ padding: "14px 15px 15px" }}>
|
||||
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 16, margin: "0 0 5px", color: "#26201a" }}>{p.title}</h3>
|
||||
<p style={{ margin: "0 0 11px", fontSize: 13, color: "#8c8068", lineHeight: 1.5 }}>{p.text}</p>
|
||||
{p.validUntil && (
|
||||
<div style={{ borderTop: "1px solid #f0e8d6", paddingTop: 10, fontSize: 12, color: "#a99c82" }}>{p.validUntil}</div>
|
||||
{formatValidity(p) && (
|
||||
<div style={{ borderTop: "1px solid #f0e8d6", paddingTop: 10, fontSize: 12, color: "#a99c82" }}>{formatValidity(p)}</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
@@ -429,18 +448,18 @@ export default async function FicheAdherentPage({
|
||||
</section>
|
||||
|
||||
{/* contact CTA */}
|
||||
{(member.email || phoneHref || normalizedWebsite) && (
|
||||
{(contactEmail || phoneHref || normalizedWebsite) && (
|
||||
<section style={{ background: "linear-gradient(120deg,#13324F,#1d4a72)", borderRadius: 18, padding: 22, color: "#fff" }}>
|
||||
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 17, margin: "0 0 6px" }}>Une question pour ce commerçant ?</h3>
|
||||
<p style={{ margin: "0 0 14px", fontSize: 13.5, color: "#bcd3e6" }}>Contactez {member.name} directement.</p>
|
||||
<a
|
||||
href={member.email ? `mailto:${member.email}` : phoneHref ?? normalizedWebsite ?? undefined}
|
||||
target={!member.email && !phoneHref ? "_blank" : undefined}
|
||||
rel={!member.email && !phoneHref ? "noopener noreferrer" : undefined}
|
||||
href={contactEmail ? `mailto:${contactEmail}` : phoneHref ?? normalizedWebsite ?? undefined}
|
||||
target={!contactEmail && !phoneHref ? "_blank" : undefined}
|
||||
rel={!contactEmail && !phoneHref ? "noopener noreferrer" : undefined}
|
||||
className="font-display"
|
||||
style={{ textDecoration: "none", display: "block", textAlign: "center", background: "#E0A63C", color: "#33291D", fontWeight: 700, fontSize: 14.5, padding: 12, borderRadius: 11 }}
|
||||
>
|
||||
{member.email ? "Envoyer un message" : phoneHref ? "Appeler maintenant" : "Visiter le site"}
|
||||
{contactEmail ? "Envoyer un message" : phoneHref ? "Appeler maintenant" : "Visiter le site"}
|
||||
</a>
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -1,28 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { VitrineImage } from "@/components/VitrineImage";
|
||||
import { MemberCard, type MemberCardData } from "@/components/MemberCard";
|
||||
|
||||
export type DirectoryMember = {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
city: string | null;
|
||||
address: string | null;
|
||||
coverUrl: string | null;
|
||||
logoUrl: string | null;
|
||||
categoryLabel: string | null;
|
||||
accent: string | null;
|
||||
export type DirectoryMember = MemberCardData & {
|
||||
hasPromo: boolean;
|
||||
promoBadge: string | null;
|
||||
};
|
||||
|
||||
const STRIPE_WARM =
|
||||
"repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)";
|
||||
const STRIPE_COOL =
|
||||
"repeating-linear-gradient(45deg,#eef0ec,#eef0ec 12px,#e2e8e6 12px,#e2e8e6 24px)";
|
||||
|
||||
export function AnnuaireClient({
|
||||
members,
|
||||
categories,
|
||||
@@ -190,46 +175,9 @@ export function AnnuaireClient({
|
||||
{/* grid / empty */}
|
||||
{list.length > 0 ? (
|
||||
<div className="grid grid-3" style={{ gap: 20 }}>
|
||||
{list.map((m, i) => {
|
||||
return (
|
||||
<Link
|
||||
key={m.id}
|
||||
href={`/adherents/${m.id}`}
|
||||
className="lift-card"
|
||||
style={{ textDecoration: "none", color: "inherit", background: "#fff", border: "1px solid #e6dcc6", borderRadius: 18, overflow: "hidden", display: "flex", flexDirection: "column" }}
|
||||
>
|
||||
<VitrineImage
|
||||
coverUrl={m.coverUrl}
|
||||
logoUrl={m.logoUrl}
|
||||
height={150}
|
||||
stripe={i % 2 === 0 ? STRIPE_WARM : STRIPE_COOL}
|
||||
>
|
||||
{m.categoryLabel && (
|
||||
<span style={{ position: "absolute", top: 12, left: 12, background: m.accent ?? "#9a6638", color: "#fff", borderRadius: 999, padding: "5px 12px", fontSize: 11, fontWeight: 700 }}>
|
||||
{m.categoryLabel}
|
||||
</span>
|
||||
)}
|
||||
{m.hasPromo && m.promoBadge && (
|
||||
<span className="font-display" style={{ position: "absolute", top: 0, right: 0, background: "#d8472b", color: "#fff", fontWeight: 800, fontSize: 12.5, padding: "6px 12px", borderRadius: "0 0 0 14px" }}>
|
||||
{m.promoBadge}
|
||||
</span>
|
||||
)}
|
||||
</VitrineImage>
|
||||
<div style={{ padding: "16px 18px 18px", display: "flex", flexDirection: "column", flex: 1 }}>
|
||||
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 18, margin: 0, color: "#26201a" }}>
|
||||
{m.name}
|
||||
</h3>
|
||||
<p style={{ margin: "6px 0 12px", fontSize: 13, color: "#8c8068", lineHeight: 1.5, flex: 1 }}>
|
||||
{m.description}
|
||||
</p>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 7, fontSize: 12.5, color: "#6c6150", borderTop: "1px solid #f0e8d6", paddingTop: 11 }}>
|
||||
<span style={{ width: 7, height: 7, borderRadius: "50%", background: m.accent ?? "#E0A63C" }} />
|
||||
{[m.address, m.city].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{list.map((m, i) => (
|
||||
<MemberCard key={m.id} m={m} index={i} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ background: "#fff", border: "1px dashed #d8cdb4", borderRadius: 18, padding: 54, textAlign: "center" }}>
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
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,
|
||||
} from "@/lib/queries";
|
||||
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] = await Promise.all([
|
||||
getActiveMembersByCategory(category.id),
|
||||
getLiveBadgesByMember(),
|
||||
getCategoriesInUse(),
|
||||
publicBaseUrl(),
|
||||
]);
|
||||
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} />
|
||||
))}
|
||||
</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,8 +6,10 @@ import { MembershipModalButton } from "@/components/MembershipModalButton";
|
||||
import {
|
||||
getActiveMembersWithCategory,
|
||||
getAllCategories,
|
||||
getCategoriesInUse,
|
||||
getLiveBadgesByMember,
|
||||
} from "@/lib/queries";
|
||||
import { CategoryLinks } from "@/components/CategoryLinks";
|
||||
import { JsonLd } from "@/components/JsonLd";
|
||||
import { breadcrumbJsonLd, memberListJsonLd, pageMetadata } from "@/lib/seo";
|
||||
import { publicBaseUrl } from "@/lib/seo-server";
|
||||
@@ -30,11 +32,12 @@ export default async function AnnuairePage({
|
||||
searchParams: Promise<{ q?: string }>;
|
||||
}) {
|
||||
const { q } = await searchParams;
|
||||
const [rawMembers, categories, badges, baseUrl] = await Promise.all([
|
||||
const [rawMembers, categories, badges, baseUrl, categoriesInUse] = await Promise.all([
|
||||
getActiveMembersWithCategory(),
|
||||
getAllCategories(),
|
||||
getLiveBadgesByMember(),
|
||||
publicBaseUrl(),
|
||||
getCategoriesInUse(),
|
||||
]);
|
||||
|
||||
const badgeByMember = new Map<number, string | null>();
|
||||
@@ -98,6 +101,19 @@ export default async function AnnuairePage({
|
||||
|
||||
<AnnuaireClient members={members} categories={categories} initialQuery={q ?? ""} />
|
||||
|
||||
{/* Pages métier : une URL indexable par catégorie, en plus du filtre. */}
|
||||
{categoriesInUse.length > 0 && (
|
||||
<section style={{ marginTop: 34, background: "#fff", border: "1px solid #e6dcc6", borderRadius: 18, padding: "22px 24px" }}>
|
||||
<h2 className="font-display" style={{ fontWeight: 700, fontSize: 19, margin: "0 0 6px", color: "#26201a" }}>
|
||||
Parcourir par métier
|
||||
</h2>
|
||||
<p style={{ margin: "0 0 14px", fontSize: 14, color: "#6c6150" }}>
|
||||
Tous les commerçants, artisans et entreprises du Bassin de Pompey, classés par activité.
|
||||
</p>
|
||||
<CategoryLinks categories={categoriesInUse} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* join CTA */}
|
||||
<section
|
||||
style={{
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
authorizeUrl,
|
||||
getDecryptedAppSecret,
|
||||
getSocialAccount,
|
||||
siteUrl,
|
||||
publicBaseUrl,
|
||||
SOCIAL_NETWORKS,
|
||||
type SocialNetwork,
|
||||
} from "@/lib/social-accounts";
|
||||
@@ -34,11 +34,13 @@ export async function GET(
|
||||
return back(request, "Réseau inconnu.");
|
||||
}
|
||||
|
||||
const base = await siteUrl();
|
||||
// Réglage enregistré, sinon l'adresse par laquelle l'administrateur est
|
||||
// arrivé : c'est celle où le réseau nous renverra.
|
||||
const base = await publicBaseUrl();
|
||||
if (!base) {
|
||||
return back(
|
||||
request,
|
||||
"Renseignez d'abord l'URL publique du site : elle sert à construire l'adresse de retour."
|
||||
"Impossible de déterminer l'adresse publique du site : renseignez-la dans la section « URL publique du site »."
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,8 @@ const TITLES: Record<string, [string, string]> = {
|
||||
"/backend/administrateurs": ["Administrateurs", "Gérer les accès à l'administration"],
|
||||
"/backend/categories": ["Catégories", "Gérer les métiers de l'annuaire"],
|
||||
"/backend/parametres": ["Paramètres du site", "Configurer l'association et les mentions légales"],
|
||||
"/backend/espace": ["Mon espace adhérent", "Publiez et suivez vos promotions"],
|
||||
"/backend/espace": ["Mon profil", "Votre fiche publique, vos inscriptions et votre droit à l'image"],
|
||||
"/backend/espace/promotions": ["Mes promotions", "Proposez une offre et suivez celles en ligne"],
|
||||
"/backend/changer-mot-de-passe": ["Mot de passe", "Sécurisez votre compte"],
|
||||
};
|
||||
|
||||
@@ -85,7 +86,10 @@ const SECTIONS: NavSection[] = [
|
||||
},
|
||||
{
|
||||
label: "Côté adhérent",
|
||||
items: [{ href: "/backend/espace", label: "Mon espace", icon: "space" }],
|
||||
items: [
|
||||
{ href: "/backend/espace", label: "Mon profil", icon: "space" },
|
||||
{ href: "/backend/espace/promotions", label: "Mes promotions", icon: "promos" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
+158
-139
@@ -4,13 +4,15 @@ import { randomInt } from "node:crypto";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { and, eq, ne, sql } from "drizzle-orm";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { signOut } from "@/auth";
|
||||
import { getSession } from "@/lib/session";
|
||||
import { db } from "@/db";
|
||||
import { CATEGORY_PALETTE } from "@/db/categories";
|
||||
import { slugify } from "@/lib/slug";
|
||||
import { autoTags } from "@/lib/tags";
|
||||
import {
|
||||
activityLog,
|
||||
categories,
|
||||
contactMessages,
|
||||
imageConsents,
|
||||
@@ -22,14 +24,10 @@ import {
|
||||
pastMeetings,
|
||||
promotions,
|
||||
siteSettings,
|
||||
socialPosts,
|
||||
users,
|
||||
} from "@/db/schema";
|
||||
import { sanitizeActivityMessage } from "@/lib/activity";
|
||||
import { can, LABEL_TO_ROLE } from "@/lib/rbac";
|
||||
import {
|
||||
isNetworkConfigured,
|
||||
publishPromoToNetwork,
|
||||
SOCIAL_LABELS,
|
||||
SOCIAL_NETWORKS,
|
||||
type SocialNetwork,
|
||||
@@ -40,34 +38,14 @@ import {
|
||||
selectTarget,
|
||||
} from "@/lib/social-accounts";
|
||||
import { normalizeWebsite } from "@/lib/member-profile";
|
||||
import { isRangeInvalid } from "@/lib/promo-validity";
|
||||
import { formatSchedule, isDue, parseScheduleInput } from "@/lib/promo-schedule";
|
||||
import { publishPromoShares, requestedNetworks } from "@/lib/promo-publish";
|
||||
import { logActivity } from "@/lib/activity-log";
|
||||
import { SITE_SETTING_DEFAULTS } from "@/lib/site-settings";
|
||||
import type { AppRole } from "@/types/next-auth";
|
||||
|
||||
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" },
|
||||
];
|
||||
|
||||
function slugify(input: string): string {
|
||||
return input
|
||||
.normalize("NFD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 70);
|
||||
}
|
||||
|
||||
async function logActivity(message: string, dot = "#2C6FB3") {
|
||||
// Le message agrège des saisies de tiers : on ne laisse passer que <strong>.
|
||||
await db.insert(activityLog).values({ message: sanitizeActivityMessage(message), dot });
|
||||
}
|
||||
|
||||
// Mot de passe temporaire lisible (sans caractères ambigus).
|
||||
// `randomInt` et non `Math.random()` : ce dernier n'est pas cryptographique et
|
||||
@@ -130,20 +108,22 @@ function asImageDataUri(formData: FormData, key: string): string | null {
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Date `YYYY-MM-DD` facultative issue d'un `<input type="date">`. */
|
||||
function asOptionalDate(formData: FormData, key: string): string | null {
|
||||
const value = asString(formData, key);
|
||||
if (!value) return null;
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) throw new Error("Date invalide.");
|
||||
return value;
|
||||
}
|
||||
|
||||
function revalidatePromoPaths(memberId?: number | null) {
|
||||
revalidatePath("/backend/promotions");
|
||||
revalidatePath("/backend/espace");
|
||||
revalidatePath("/backend/espace/promotions");
|
||||
revalidatePath("/backend");
|
||||
revalidatePath("/");
|
||||
revalidatePath("/annuaire");
|
||||
if (memberId) revalidatePath(`/adherents/${memberId}`);
|
||||
}
|
||||
|
||||
/** Réseaux demandés pour une promo, dans l'ordre d'affichage. */
|
||||
function requestedNetworks(promo: { shareFacebook: boolean; shareLinkedin: boolean }): SocialNetwork[] {
|
||||
return SOCIAL_NETWORKS.filter((n) =>
|
||||
n === "facebook" ? promo.shareFacebook : promo.shareLinkedin
|
||||
);
|
||||
if (memberId) revalidatePath("/adherents/[id]", "page");
|
||||
}
|
||||
|
||||
function readShareTargets(formData: FormData) {
|
||||
@@ -153,83 +133,6 @@ function readShareTargets(formData: FormData) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Publie une promotion sur les réseaux demandés. Ne lève jamais : un réseau
|
||||
* indisponible ne doit pas faire échouer la mise en ligne, l'échec est
|
||||
* enregistré dans `social_posts` et rattrapable depuis le backoffice.
|
||||
*
|
||||
* Un réseau déjà publié avec succès est ignoré : c'est ce qui empêche toute
|
||||
* republication, y compris sur un cycle suspension → remise en ligne.
|
||||
*/
|
||||
async function publishPromoShares(
|
||||
promoId: number,
|
||||
networks: SocialNetwork[],
|
||||
userId: number | null
|
||||
) {
|
||||
if (networks.length === 0) return;
|
||||
|
||||
const [promo] = await db
|
||||
.select({
|
||||
title: promotions.title,
|
||||
text: promotions.text,
|
||||
badge: promotions.badge,
|
||||
validUntil: promotions.validUntil,
|
||||
imageUrl: promotions.imageUrl,
|
||||
memberId: promotions.memberId,
|
||||
memberName: members.name,
|
||||
})
|
||||
.from(promotions)
|
||||
.leftJoin(members, eq(promotions.memberId, members.id))
|
||||
.where(eq(promotions.id, promoId));
|
||||
if (!promo) return;
|
||||
|
||||
const done = await db
|
||||
.select({ network: socialPosts.network })
|
||||
.from(socialPosts)
|
||||
.where(and(eq(socialPosts.promotionId, promoId), eq(socialPosts.status, "posted")));
|
||||
const alreadyPosted = new Set(done.map((d) => d.network));
|
||||
|
||||
for (const network of networks) {
|
||||
if (alreadyPosted.has(network)) continue;
|
||||
|
||||
if (!(await isNetworkConfigured(network))) {
|
||||
await db.insert(socialPosts).values({
|
||||
promotionId: promoId,
|
||||
network,
|
||||
status: "failed",
|
||||
error: `${SOCIAL_LABELS[network]} n'est pas configuré sur ce serveur.`,
|
||||
postedById: userId,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await publishPromoToNetwork(network, promo);
|
||||
await db.insert(socialPosts).values({
|
||||
promotionId: promoId,
|
||||
network,
|
||||
status: "posted",
|
||||
externalId: result.externalId,
|
||||
url: result.url,
|
||||
postedById: userId,
|
||||
});
|
||||
await logActivity(
|
||||
`Promotion « ${promo.title} » publiée sur ${SOCIAL_LABELS[network]}`,
|
||||
"#2C6FB3"
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Erreur inconnue";
|
||||
await db.insert(socialPosts).values({
|
||||
promotionId: promoId,
|
||||
network,
|
||||
status: "failed",
|
||||
error: message.slice(0, 2000),
|
||||
postedById: userId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Promotion moderation ----
|
||||
export async function moderatePromo(formData: FormData) {
|
||||
const { role, userId, name } = await requireRole();
|
||||
@@ -246,20 +149,36 @@ export async function moderatePromo(formData: FormData) {
|
||||
// Le modérateur peut ajuster les réseaux demandés par l'adhérent, mais
|
||||
// uniquement ici : après validation le choix est figé.
|
||||
const targets = readShareTargets(formData);
|
||||
// Le champ est pré-rempli avec la date demandée par l'adhérent : vidé, la
|
||||
// promotion part tout de suite ; une échéance déjà passée aussi.
|
||||
const publishAt = parseScheduleInput(String(formData.get("publishAt") ?? ""));
|
||||
const immediate = isDue(publishAt);
|
||||
|
||||
await db
|
||||
.update(promotions)
|
||||
.set({
|
||||
status: "live",
|
||||
status: immediate ? "live" : "scheduled",
|
||||
publishAt: immediate ? null : publishAt,
|
||||
...targets,
|
||||
suspendedBy: null,
|
||||
suspendedById: null,
|
||||
suspendedAt: null,
|
||||
})
|
||||
.where(eq(promotions.id, id));
|
||||
await logActivity(`Promotion « ${promo.title} » validée et mise en ligne`, "#1f8a5b");
|
||||
// Mise en ligne committée avant l'appel réseau : si un réseau répond mal,
|
||||
// la promo est quand même publiée sur le site.
|
||||
await publishPromoShares(id, requestedNetworks(targets), userId);
|
||||
|
||||
if (immediate) {
|
||||
await logActivity(`Promotion « ${promo.title} » validée et mise en ligne`, "#1f8a5b");
|
||||
// Mise en ligne committée avant l'appel réseau : si un réseau répond mal,
|
||||
// la promo est quand même publiée sur le site.
|
||||
await publishPromoShares(id, requestedNetworks(targets), userId);
|
||||
} else {
|
||||
// Rien n'est diffusé maintenant : `releaseDuePromotions()` s'en chargera
|
||||
// à l'échéance, site et réseaux d'un seul coup.
|
||||
await logActivity(
|
||||
`Promotion « ${promo.title} » validée, publication programmée ${formatSchedule(publishAt)}`,
|
||||
"#9a6638"
|
||||
);
|
||||
}
|
||||
} else if (action === "suspend") {
|
||||
// Suspension par l'association : la promotion sort du site public et
|
||||
// l'adhérent ne peut pas la remettre en ligne lui-même.
|
||||
@@ -271,6 +190,15 @@ export async function moderatePromo(formData: FormData) {
|
||||
`Promotion « ${promo.title} » suspendue par <strong>${name}</strong>`,
|
||||
"#d8472b"
|
||||
);
|
||||
} else if (action === "publish-now") {
|
||||
// Raccourci sur une promo programmée : on n'attend pas l'échéance.
|
||||
if (promo.status !== "scheduled") return;
|
||||
await db
|
||||
.update(promotions)
|
||||
.set({ status: "live", publishAt: null })
|
||||
.where(eq(promotions.id, id));
|
||||
await logActivity(`Promotion « ${promo.title} » publiée avant l'heure programmée`, "#1f8a5b");
|
||||
await publishPromoShares(id, requestedNetworks(promo), userId);
|
||||
} else if (action === "restore") {
|
||||
await db
|
||||
.update(promotions)
|
||||
@@ -349,6 +277,7 @@ export async function retryPromoShare(formData: FormData) {
|
||||
await publishPromoShares(id, [network], userId);
|
||||
revalidatePath("/backend/promotions");
|
||||
revalidatePath("/backend/espace");
|
||||
revalidatePath("/backend/espace/promotions");
|
||||
}
|
||||
|
||||
// ---- Member space: choisir les réseaux tant que la promo est en attente ----
|
||||
@@ -370,6 +299,7 @@ export async function setOwnPromoShareTargets(formData: FormData) {
|
||||
|
||||
await db.update(promotions).set(readShareTargets(formData)).where(eq(promotions.id, id));
|
||||
revalidatePath("/backend/espace");
|
||||
revalidatePath("/backend/espace/promotions");
|
||||
revalidatePath("/backend/promotions");
|
||||
}
|
||||
|
||||
@@ -383,7 +313,15 @@ export async function publishPromo(formData: FormData) {
|
||||
const text = String(formData.get("text") ?? "").slice(0, 240);
|
||||
const category = String(formData.get("category") ?? "");
|
||||
const badge = String(formData.get("badge") ?? "").trim() || null;
|
||||
const startsOn = asOptionalDate(formData, "startsOn");
|
||||
const endsOn = asOptionalDate(formData, "endsOn");
|
||||
if (isRangeInvalid({ startsOn, endsOn })) {
|
||||
throw new Error("La date de fin doit être postérieure à la date de début.");
|
||||
}
|
||||
const imageUrl = asImageDataUri(formData, "imageUrl");
|
||||
// Date souhaitée de mise en ligne. Elle n'engage rien : le modérateur la voit
|
||||
// et peut la garder, la déplacer ou la vider dans le formulaire « Valider ».
|
||||
const publishAt = parseScheduleInput(String(formData.get("publishAt") ?? ""));
|
||||
|
||||
// Réseaux souhaités : rien n'est publié ici, la diffusion attend la validation.
|
||||
await db.insert(promotions).values({
|
||||
@@ -394,12 +332,16 @@ export async function publishPromo(formData: FormData) {
|
||||
imageUrl,
|
||||
memberId: memberId ?? null,
|
||||
status: "pending",
|
||||
startsOn,
|
||||
endsOn,
|
||||
publishAt,
|
||||
...readShareTargets(formData),
|
||||
});
|
||||
|
||||
await logActivity(`<strong>${name}</strong> a soumis une promotion « ${title} »`, "#E0A63C");
|
||||
|
||||
revalidatePath("/backend/espace");
|
||||
revalidatePath("/backend/espace/promotions");
|
||||
revalidatePath("/backend");
|
||||
revalidatePath("/backend/promotions");
|
||||
}
|
||||
@@ -417,14 +359,48 @@ export type IssuedCredentials = { email: string; tempPassword: string };
|
||||
|
||||
export type CreatedMemberAccount = IssuedCredentials & { memberId: number };
|
||||
|
||||
export async function addMember(formData: FormData): Promise<CreatedMemberAccount | undefined> {
|
||||
/**
|
||||
* Échec **attendu** d'une action : e-mail déjà pris, champ manquant…
|
||||
*
|
||||
* Il est renvoyé et non `throw` : une exception levée dans une server action
|
||||
* est masquée par Next en production (message remplacé par un digest) et fait
|
||||
* tomber la page entière sur « Application error ». Le `throw` reste réservé
|
||||
* aux violations d'accès, qui ne doivent pas s'expliquer à l'utilisateur.
|
||||
*/
|
||||
export type ActionError = { error: string };
|
||||
|
||||
/**
|
||||
* Tags à enregistrer : la saisie si elle existe, sinon les suggestions déduites
|
||||
* du métier, de la commune et de la description (`autoTags`).
|
||||
*/
|
||||
async function resolveMemberTags(
|
||||
formTags: FormDataEntryValue | null,
|
||||
input: { categoryId: number | null; city: string | null; description: string | null }
|
||||
): Promise<string | null> {
|
||||
const [category] = input.categoryId
|
||||
? await db
|
||||
.select({ slug: categories.slug, label: categories.label })
|
||||
.from(categories)
|
||||
.where(eq(categories.id, input.categoryId))
|
||||
: [];
|
||||
return autoTags(String(formTags ?? ""), {
|
||||
categorySlug: category?.slug,
|
||||
categoryLabel: category?.label,
|
||||
city: input.city,
|
||||
description: input.description,
|
||||
});
|
||||
}
|
||||
|
||||
export async function addMember(
|
||||
formData: FormData
|
||||
): Promise<CreatedMemberAccount | ActionError | undefined> {
|
||||
const { role } = await requireRole();
|
||||
if (!can(role, "manageMembers")) throw new Error("Accès refusé");
|
||||
|
||||
const name = String(formData.get("name") ?? "").trim();
|
||||
if (!name) return undefined;
|
||||
const email = String(formData.get("email") ?? "").trim().toLowerCase();
|
||||
if (!email) throw new Error("L'e-mail est requis pour créer le compte de l'adhérent.");
|
||||
if (!email) return { error: "L'e-mail est requis pour créer le compte de l'adhérent." };
|
||||
const categoryId = formData.get("categoryId") ? Number(formData.get("categoryId")) : null;
|
||||
const city = String(formData.get("city") ?? "").trim() || null;
|
||||
const status = (String(formData.get("status") ?? "pending") as "active" | "pending");
|
||||
@@ -432,12 +408,13 @@ export async function addMember(formData: FormData): Promise<CreatedMemberAccoun
|
||||
// Un seul compte par e-mail.
|
||||
const existing = await db.select({ id: users.id }).from(users).where(eq(users.email, email));
|
||||
if (existing.length > 0) {
|
||||
throw new Error("Un compte existe déjà avec cet e-mail.");
|
||||
return { error: `Un compte existe déjà avec l'e-mail ${email}.` };
|
||||
}
|
||||
|
||||
const tags = await resolveMemberTags(null, { categoryId, city, description: null });
|
||||
const [newMember] = await db
|
||||
.insert(members)
|
||||
.values({ name, email, categoryId, city, status })
|
||||
.values({ name, email, categoryId, city, status, tags })
|
||||
.returning({ id: members.id });
|
||||
|
||||
// Compte de connexion adhérent avec mot de passe temporaire à changer.
|
||||
@@ -537,29 +514,35 @@ export async function updateMember(formData: FormData) {
|
||||
const id = Number(formData.get("id"));
|
||||
if (!id) return;
|
||||
|
||||
const categoryId = formData.get("categoryId") ? Number(formData.get("categoryId")) : null;
|
||||
const city = String(formData.get("city") ?? "").trim() || null;
|
||||
const description = String(formData.get("description") ?? "").trim() || null;
|
||||
const tags = await resolveMemberTags(formData.get("tags"), { categoryId, city, description });
|
||||
|
||||
await db
|
||||
.update(members)
|
||||
.set({
|
||||
name: String(formData.get("name") ?? "").trim(),
|
||||
email: String(formData.get("email") ?? "").trim(),
|
||||
categoryId: formData.get("categoryId") ? Number(formData.get("categoryId")) : null,
|
||||
city: String(formData.get("city") ?? "").trim() || null,
|
||||
contactEmail: String(formData.get("contactEmail") ?? "").trim().toLowerCase() || null,
|
||||
categoryId,
|
||||
city,
|
||||
address: String(formData.get("address") ?? "").trim() || null,
|
||||
description: String(formData.get("description") ?? "").trim() || null,
|
||||
description,
|
||||
postalCode: String(formData.get("postalCode") ?? "").trim() || null,
|
||||
phone: String(formData.get("phone") ?? "").trim() || null,
|
||||
website: normalizeWebsite(String(formData.get("website") ?? "")),
|
||||
memberSince: formData.get("memberSince") ? Number(formData.get("memberSince")) : null,
|
||||
coverUrl: String(formData.get("coverUrl") ?? "").trim() || null,
|
||||
logoUrl: String(formData.get("logoUrl") ?? "").trim() || null,
|
||||
tags: String(formData.get("tags") ?? "").trim() || null,
|
||||
tags,
|
||||
hours: String(formData.get("hours") ?? "").trim() || null,
|
||||
status: String(formData.get("status") ?? "pending") as "active" | "pending",
|
||||
})
|
||||
.where(eq(members.id, id));
|
||||
|
||||
revalidatePath("/backend/adherents");
|
||||
revalidatePath(`/adherents/${id}`);
|
||||
revalidatePath("/adherents/[id]", "page");
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
@@ -580,7 +563,9 @@ export async function deleteMember(formData: FormData) {
|
||||
}
|
||||
|
||||
// ---- Admins ----
|
||||
export async function inviteAdmin(formData: FormData): Promise<IssuedCredentials | undefined> {
|
||||
export async function inviteAdmin(
|
||||
formData: FormData
|
||||
): Promise<IssuedCredentials | ActionError | undefined> {
|
||||
const { role } = await requireRole();
|
||||
if (!can(role, "manageAdmins")) throw new Error("Accès refusé");
|
||||
|
||||
@@ -592,7 +577,7 @@ export async function inviteAdmin(formData: FormData): Promise<IssuedCredentials
|
||||
const newRole: AppRole = LABEL_TO_ROLE[roleLabel] ?? "editor";
|
||||
|
||||
const existing = await db.select().from(users).where(eq(users.email, email));
|
||||
if (existing.length > 0) throw new Error("Un compte existe déjà avec cet e-mail.");
|
||||
if (existing.length > 0) return { error: `Un compte existe déjà avec l'e-mail ${email}.` };
|
||||
|
||||
// Mot de passe temporaire montré une seule fois à l'inviteur, à changer à
|
||||
// la première connexion. Auparavant il n'était ni conservé ni affiché :
|
||||
@@ -847,6 +832,7 @@ function revalidateMeetingPaths(meetingId?: number) {
|
||||
revalidatePath("/backend/inscriptions");
|
||||
revalidatePath("/backend/rencontres-passees");
|
||||
revalidatePath("/backend/espace");
|
||||
revalidatePath("/backend/espace/promotions");
|
||||
revalidatePath("/rencontres-passees");
|
||||
if (meetingId) revalidatePath(`/inscription/${meetingId}`);
|
||||
}
|
||||
@@ -1039,6 +1025,7 @@ export async function saveImageConsent(formData: FormData) {
|
||||
|
||||
revalidatePath("/backend");
|
||||
revalidatePath("/backend/espace");
|
||||
revalidatePath("/backend/espace/promotions");
|
||||
redirect("/backend/espace");
|
||||
}
|
||||
|
||||
@@ -1092,18 +1079,30 @@ export async function updateOwnProfile(formData: FormData) {
|
||||
const name = String(formData.get("name") ?? "").trim();
|
||||
if (!name) return;
|
||||
|
||||
// La catégorie n'est pas modifiable par l'adhérent : on la relit en base pour
|
||||
// en déduire les tags si le champ est laissé vide.
|
||||
const [current] = await db.select({ categoryId: members.categoryId }).from(members).where(eq(members.id, memberId));
|
||||
const city = String(formData.get("city") ?? "").trim() || null;
|
||||
const description = String(formData.get("description") ?? "").trim() || null;
|
||||
const tags = await resolveMemberTags(formData.get("tags"), {
|
||||
categoryId: current?.categoryId ?? null,
|
||||
city,
|
||||
description,
|
||||
});
|
||||
|
||||
await db
|
||||
.update(members)
|
||||
.set({
|
||||
name,
|
||||
description: String(formData.get("description") ?? "").trim() || null,
|
||||
contactEmail: String(formData.get("contactEmail") ?? "").trim().toLowerCase() || null,
|
||||
description,
|
||||
address: String(formData.get("address") ?? "").trim() || null,
|
||||
postalCode: String(formData.get("postalCode") ?? "").trim() || null,
|
||||
city: String(formData.get("city") ?? "").trim() || null,
|
||||
city,
|
||||
phone: String(formData.get("phone") ?? "").trim() || null,
|
||||
website: normalizeWebsite(String(formData.get("website") ?? "")),
|
||||
hours: String(formData.get("hours") ?? "").trim() || null,
|
||||
tags: String(formData.get("tags") ?? "").trim() || null,
|
||||
tags,
|
||||
// Images restreintes aux data-URI : un adhérent ne peut pas faire pointer
|
||||
// sa fiche publique vers une ressource externe qu'il contrôle.
|
||||
coverUrl: asImageDataUri(formData, "coverUrl"),
|
||||
@@ -1113,7 +1112,8 @@ export async function updateOwnProfile(formData: FormData) {
|
||||
.where(eq(members.id, memberId));
|
||||
|
||||
revalidatePath("/backend/espace");
|
||||
revalidatePath(`/adherents/${memberId}`);
|
||||
revalidatePath("/backend/espace/promotions");
|
||||
revalidatePath("/adherents/[id]", "page");
|
||||
revalidatePath("/annuaire");
|
||||
revalidatePath("/");
|
||||
}
|
||||
@@ -1121,7 +1121,9 @@ export async function updateOwnProfile(formData: FormData) {
|
||||
// Approuve une demande d'adhésion ET crée directement l'adhérent + son compte
|
||||
// de connexion. Renvoie l'id du nouvel adhérent et ses identifiants, à
|
||||
// afficher une seule fois.
|
||||
export async function approveMembershipRequest(formData: FormData): Promise<CreatedMemberAccount | undefined> {
|
||||
export async function approveMembershipRequest(
|
||||
formData: FormData
|
||||
): Promise<CreatedMemberAccount | ActionError | undefined> {
|
||||
const { role } = await requireRole();
|
||||
if (!can(role, "manageMembers")) throw new Error("Accès refusé");
|
||||
|
||||
@@ -1131,14 +1133,28 @@ export async function approveMembershipRequest(formData: FormData): Promise<Crea
|
||||
const [req] = await db.select().from(membershipRequests).where(eq(membershipRequests.id, id));
|
||||
if (!req) throw new Error("Demande introuvable.");
|
||||
|
||||
// Le bouton disparaît dès qu'une demande est traitée, mais un onglet resté
|
||||
// ouvert peut encore poster : on refuse ici plutôt que de créer un doublon.
|
||||
if (req.status !== "new") {
|
||||
return {
|
||||
error:
|
||||
req.status === "approved"
|
||||
? "Cette demande a déjà été approuvée."
|
||||
: "Cette demande a été rejetée : rouvrez-la avant de l'approuver.",
|
||||
};
|
||||
}
|
||||
|
||||
const email = (req.email ?? "").trim().toLowerCase();
|
||||
if (!email) {
|
||||
throw new Error("Cette demande n'a pas d'e-mail : impossible de créer le compte. Créez l'adhérent manuellement.");
|
||||
return {
|
||||
error:
|
||||
"Cette demande n'a pas d'e-mail : impossible de créer le compte. Créez l'adhérent manuellement.",
|
||||
};
|
||||
}
|
||||
|
||||
const existing = await db.select({ id: users.id }).from(users).where(eq(users.email, email));
|
||||
if (existing.length > 0) {
|
||||
throw new Error("Un compte existe déjà avec cet e-mail.");
|
||||
return { error: `Un compte existe déjà avec l'e-mail ${email}.` };
|
||||
}
|
||||
|
||||
const [newMember] = await db
|
||||
@@ -1173,10 +1189,12 @@ export async function setRequestStatus(formData: FormData) {
|
||||
const id = Number(formData.get("id"));
|
||||
const status = String(formData.get("status") ?? "");
|
||||
if (!id || !["new", "approved", "rejected"].includes(status)) return;
|
||||
// Une demande approuvée a créé un compte : elle ne se rejoue pas depuis un
|
||||
// onglet resté ouvert sur l'ancien état.
|
||||
await db
|
||||
.update(membershipRequests)
|
||||
.set({ status: status as "new" | "approved" | "rejected" })
|
||||
.where(eq(membershipRequests.id, id));
|
||||
.where(and(eq(membershipRequests.id, id), ne(membershipRequests.status, "approved")));
|
||||
revalidatePath("/backend/demandes");
|
||||
revalidatePath("/backend");
|
||||
}
|
||||
@@ -1280,6 +1298,7 @@ export async function disconnectSocial(formData: FormData) {
|
||||
revalidatePath("/backend/reseaux");
|
||||
revalidatePath("/backend/promotions");
|
||||
revalidatePath("/backend/espace");
|
||||
revalidatePath("/backend/espace/promotions");
|
||||
}
|
||||
|
||||
// ---- Sign out ----
|
||||
|
||||
@@ -14,6 +14,11 @@ export function AddMemberPanel({
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [created, setCreated] = useState<CreatedMemberAccount | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// React vide un formulaire non contrôlé après chaque action : sur un refus,
|
||||
// on réinjecte la saisie plutôt que de la faire retaper.
|
||||
const [draft, setDraft] = useState<Record<string, string>>({});
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
@@ -38,8 +43,18 @@ export function AddMemberPanel({
|
||||
|
||||
{open && (
|
||||
<form
|
||||
key={attempt}
|
||||
action={async (fd) => {
|
||||
setError(null);
|
||||
const result = await addMember(fd);
|
||||
// Échec de saisie : le formulaire reste ouvert avec la raison.
|
||||
if (result && "error" in result) {
|
||||
setDraft(Object.fromEntries([...fd.entries()].map(([k, v]) => [k, String(v)])));
|
||||
setAttempt((n) => n + 1);
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
setDraft({});
|
||||
setOpen(false);
|
||||
// On reste sur la page : le mot de passe temporaire n'est affiché
|
||||
// qu'ici, une seule fois, et n'est conservé nulle part.
|
||||
@@ -48,18 +63,23 @@ export function AddMemberPanel({
|
||||
}}
|
||||
style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 16, padding: 22, marginTop: 14 }}
|
||||
>
|
||||
{error && (
|
||||
<div role="alert" style={{ background: "#fdecea", border: "1px solid #f1c4bd", borderRadius: 10, padding: "10px 14px", fontSize: 13, color: "#a3372e", marginBottom: 16 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-2" style={{ gap: 16 }}>
|
||||
<div>
|
||||
<label className="field-label">Nom de l'adhérent</label>
|
||||
<input name="name" required placeholder="ex : Au Bon Pain" className="field" />
|
||||
<input name="name" required placeholder="ex : Au Bon Pain" className="field" defaultValue={draft.name ?? ""} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">E-mail (identifiant de connexion)</label>
|
||||
<input name="email" type="email" required placeholder="contact@exemple.fr" className="field" />
|
||||
<input name="email" type="email" required placeholder="contact@exemple.fr" className="field" defaultValue={draft.email ?? ""} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Catégorie</label>
|
||||
<select name="categoryId" className="field" defaultValue="">
|
||||
<select name="categoryId" className="field" defaultValue={draft.categoryId ?? ""}>
|
||||
<option value="">—</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
@@ -70,7 +90,7 @@ export function AddMemberPanel({
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Commune</label>
|
||||
<select name="city" className="field" defaultValue="">
|
||||
<select name="city" className="field" defaultValue={draft.city ?? ""}>
|
||||
<option value="">—</option>
|
||||
{BASSIN_POMPEY_COMMUNES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
@@ -81,7 +101,7 @@ export function AddMemberPanel({
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Statut</label>
|
||||
<select name="status" className="field" defaultValue="pending">
|
||||
<select name="status" className="field" defaultValue={draft.status ?? "pending"}>
|
||||
<option value="pending">En attente</option>
|
||||
<option value="active">Actif</option>
|
||||
</select>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { categories, members, users } from "@/db/schema";
|
||||
import { can } from "@/lib/rbac";
|
||||
import { ImageField } from "@/components/ImageField";
|
||||
import { HoursEditor } from "@/components/HoursEditor";
|
||||
import { TagsField } from "@/components/TagsField";
|
||||
import { communeOptions } from "@/lib/communes";
|
||||
import { deleteMember, updateMember } from "../../actions";
|
||||
import { ResetPasswordButton } from "../ResetPasswordButton";
|
||||
@@ -31,7 +32,7 @@ export default async function EditMemberPage({
|
||||
if (!member) notFound();
|
||||
|
||||
const cats = await db
|
||||
.select({ id: categories.id, label: categories.label })
|
||||
.select({ id: categories.id, slug: categories.slug, label: categories.label })
|
||||
.from(categories)
|
||||
.orderBy(asc(categories.sort));
|
||||
|
||||
@@ -72,9 +73,14 @@ export default async function EditMemberPage({
|
||||
<input name="name" required defaultValue={member.name} className="field" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">E-mail</label>
|
||||
<label className="field-label">E-mail du compte (identifiant, échanges avec l'association)</label>
|
||||
<input name="email" type="email" defaultValue={member.email} className="field" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">E-mail de contact (affiché sur la fiche)</label>
|
||||
<input name="contactEmail" type="email" defaultValue={member.contactEmail ?? ""} className="field" placeholder={member.email} />
|
||||
<p className="field-hint">Laissé vide, la fiche publique affiche l'e-mail du compte.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Catégorie</label>
|
||||
<select name="categoryId" className="field" defaultValue={member.categoryId ?? ""}>
|
||||
@@ -134,11 +140,15 @@ export default async function EditMemberPage({
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<label className="field-label">Description (une ligne vide sépare les paragraphes)</label>
|
||||
<textarea name="description" rows={4} defaultValue={member.description ?? ""} className="field" style={{ resize: "vertical" }} />
|
||||
<p className="field-hint">
|
||||
Ce texte est lu par Google : indiquez votre métier, votre commune et vos spécialités en trois à cinq
|
||||
phrases (ex. « Boulangerie artisanale à Frouard : pain au levain, viennoiseries, pâtisseries maison »).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<label className="field-label">Tags / spécialités (séparés par des virgules)</label>
|
||||
<input name="tags" defaultValue={member.tags ?? ""} className="field" placeholder="Levain naturel, Produits locaux, Fait maison" />
|
||||
<TagsField defaultValue={member.tags} categories={cats} />
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 16 }}>
|
||||
|
||||
@@ -25,11 +25,16 @@ export function InviteAdminForm() {
|
||||
setError(null);
|
||||
try {
|
||||
const result = await inviteAdmin(fd);
|
||||
if (result && "error" in result) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
setIssued(result ?? null);
|
||||
router.refresh();
|
||||
} catch {
|
||||
// Next masque le détail des erreurs serveur en production.
|
||||
setError("Impossible de créer ce compte : vérifiez que l'e-mail n'est pas déjà utilisé.");
|
||||
// Filet pour l'imprévu : les échecs de saisie, eux, sont renvoyés
|
||||
// par l'action et affichés tels quels ci-dessus.
|
||||
setError("Impossible de créer ce compte. Réessayez dans un instant.");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
|
||||
@@ -10,10 +10,22 @@ import { approveMembershipRequest, type CreatedMemberAccount } from "../actions"
|
||||
* Approuve une demande et affiche une seule fois les identifiants du compte
|
||||
* créé. On reste sur la page : une redirection ferait perdre le mot de passe,
|
||||
* qui n'est conservé nulle part.
|
||||
*
|
||||
* Le bouton ne s'affiche que pour une demande encore à traiter, mais c'est ce
|
||||
* composant — et non la page — qui en décide : après l'approbation,
|
||||
* `router.refresh()` renvoie la demande en « approuvée », et un masquage côté
|
||||
* serveur démonterait le bloc d'identifiants avant qu'il ait été lu.
|
||||
*/
|
||||
export function ApproveRequestForm({ requestId }: { requestId: number }) {
|
||||
export function ApproveRequestForm({
|
||||
requestId,
|
||||
status,
|
||||
}: {
|
||||
requestId: number;
|
||||
status: string;
|
||||
}) {
|
||||
const [created, setCreated] = useState<CreatedMemberAccount | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
if (created) {
|
||||
@@ -27,13 +39,20 @@ export function ApproveRequestForm({ requestId }: { requestId: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
if (status !== "new") return null;
|
||||
|
||||
return (
|
||||
<form
|
||||
action={async (fd) => {
|
||||
setPending(true);
|
||||
try {
|
||||
const result = await approveMembershipRequest(fd);
|
||||
if (result) {
|
||||
// Échec de saisie (e-mail déjà pris, demande sans e-mail) : la
|
||||
// raison s'affiche à côté du bouton, la page reste en place.
|
||||
if (result && "error" in result) {
|
||||
setError(result.error);
|
||||
} else if (result) {
|
||||
setError(null);
|
||||
setCreated(result);
|
||||
router.refresh();
|
||||
}
|
||||
@@ -43,6 +62,11 @@ export function ApproveRequestForm({ requestId }: { requestId: number }) {
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="id" value={requestId} />
|
||||
{error && (
|
||||
<div role="alert" style={{ background: "#fdecea", border: "1px solid #f1c4bd", borderRadius: 9, padding: "8px 11px", fontSize: 12.5, color: "#a3372e", marginBottom: 8, maxWidth: 280, lineHeight: 1.5 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
|
||||
@@ -72,13 +72,25 @@ export default async function DemandesPage() {
|
||||
</div>
|
||||
</div>
|
||||
<StatusPill map={REQ_STATUS} status={r.status} />
|
||||
{/* Une demande traitée ne garde que la sortie qui a du sens :
|
||||
« Rejeter » disparaît une fois l'adhérent créé, et un rejet
|
||||
reste réversible tant qu'aucun compte n'existe. */}
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<ApproveRequestForm requestId={r.id} />
|
||||
<form action={setRequestStatus}>
|
||||
<input type="hidden" name="id" value={r.id} />
|
||||
<input type="hidden" name="status" value="rejected" />
|
||||
<ActionBtn color="#d8472b">Rejeter</ActionBtn>
|
||||
</form>
|
||||
<ApproveRequestForm requestId={r.id} status={r.status} />
|
||||
{r.status === "new" && (
|
||||
<form action={setRequestStatus}>
|
||||
<input type="hidden" name="id" value={r.id} />
|
||||
<input type="hidden" name="status" value="rejected" />
|
||||
<ActionBtn color="#d8472b">Rejeter</ActionBtn>
|
||||
</form>
|
||||
)}
|
||||
{r.status === "rejected" && (
|
||||
<form action={setRequestStatus}>
|
||||
<input type="hidden" name="id" value={r.id} />
|
||||
<input type="hidden" name="status" value="new" />
|
||||
<ActionBtn color="#6c6150">Rouvrir</ActionBtn>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{r.message && (
|
||||
@@ -109,17 +121,30 @@ export default async function DemandesPage() {
|
||||
</div>
|
||||
</div>
|
||||
<StatusPill map={MSG_STATUS} status={m.status} />
|
||||
{/* Chaque bouton fait avancer le message d'un cran : on ne
|
||||
propose plus « Marquer lu » sur un message déjà lu ni
|
||||
« Archiver » sur un message archivé. */}
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<form action={setContactStatus}>
|
||||
<input type="hidden" name="id" value={m.id} />
|
||||
<input type="hidden" name="status" value="read" />
|
||||
<ActionBtn color="#2C6FB3">Marquer lu</ActionBtn>
|
||||
</form>
|
||||
<form action={setContactStatus}>
|
||||
<input type="hidden" name="id" value={m.id} />
|
||||
<input type="hidden" name="status" value="archived" />
|
||||
<ActionBtn color="#a99c82">Archiver</ActionBtn>
|
||||
</form>
|
||||
{m.status === "new" && (
|
||||
<form action={setContactStatus}>
|
||||
<input type="hidden" name="id" value={m.id} />
|
||||
<input type="hidden" name="status" value="read" />
|
||||
<ActionBtn color="#2C6FB3">Marquer lu</ActionBtn>
|
||||
</form>
|
||||
)}
|
||||
{m.status !== "archived" ? (
|
||||
<form action={setContactStatus}>
|
||||
<input type="hidden" name="id" value={m.id} />
|
||||
<input type="hidden" name="status" value="archived" />
|
||||
<ActionBtn color="#a99c82">Archiver</ActionBtn>
|
||||
</form>
|
||||
) : (
|
||||
<form action={setContactStatus}>
|
||||
<input type="hidden" name="id" value={m.id} />
|
||||
<input type="hidden" name="status" value="read" />
|
||||
<ActionBtn color="#6c6150">Désarchiver</ActionBtn>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p style={{ margin: "10px 0 0", fontSize: 13.5, color: "#5a5040", whiteSpace: "pre-wrap" }}>{m.message}</p>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import Link from "next/link";
|
||||
|
||||
function initialsOf(name: string) {
|
||||
return name.split(/\s+/).map((w) => w[0]).join("").slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
const TABS = [
|
||||
{ key: "profil", href: "/backend/espace", label: "Mon profil" },
|
||||
{ key: "promotions", href: "/backend/espace/promotions", label: "Mes promotions" },
|
||||
] as const;
|
||||
|
||||
export type EspaceTab = (typeof TABS)[number]["key"];
|
||||
|
||||
/**
|
||||
* Bandeau de l'espace adhérent et onglets Profil / Promotions : deux pages
|
||||
* distinctes pour que la fiche publique et la vie des promotions ne se
|
||||
* mélangent pas dans une seule longue page.
|
||||
*/
|
||||
export function EspaceHeader({
|
||||
memberName,
|
||||
subtitle,
|
||||
active,
|
||||
publicPath,
|
||||
promoBadge,
|
||||
}: {
|
||||
memberName: string;
|
||||
subtitle: string;
|
||||
active: EspaceTab;
|
||||
publicPath?: string | null;
|
||||
/** Nombre de promotions en ligne, affiché sur l'onglet. */
|
||||
promoBadge?: number;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ marginBottom: 22 }}>
|
||||
<div style={{ background: "#13324F", borderRadius: "16px 16px 0 0", padding: "20px 24px", display: "flex", alignItems: "center", gap: 15, flexWrap: "wrap" }}>
|
||||
<span className="font-display" style={{ width: 46, height: 46, borderRadius: 12, background: "#E0A63C", color: "#33291D", display: "flex", alignItems: "center", justifyContent: "center", fontWeight: 800, fontSize: 16, flexShrink: 0 }}>
|
||||
{initialsOf(memberName)}
|
||||
</span>
|
||||
<div style={{ flex: 1, minWidth: 200 }}>
|
||||
<div className="font-display" style={{ fontWeight: 700, fontSize: 18, color: "#fff" }}>{memberName}</div>
|
||||
<div style={{ fontSize: 13, color: "#9bb6cd" }}>{subtitle}</div>
|
||||
</div>
|
||||
{publicPath && (
|
||||
<Link href={publicPath} target="_blank" style={{ color: "#fff", border: "1px solid rgba(255,255,255,0.35)", borderRadius: 10, padding: "8px 14px", fontSize: 13, fontWeight: 700, textDecoration: "none", whiteSpace: "nowrap" }}>
|
||||
Voir ma fiche publique ↗
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<nav className="espace-tabs" aria-label="Sections de l'espace adhérent">
|
||||
{TABS.map((tab) => {
|
||||
const isActive = tab.key === active;
|
||||
return (
|
||||
<Link key={tab.key} href={tab.href} className={`espace-tab${isActive ? " active" : ""}`} aria-current={isActive ? "page" : undefined}>
|
||||
{tab.label}
|
||||
{tab.key === "promotions" && promoBadge ? <span className="espace-tab__badge">{promoBadge}</span> : null}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,8 +4,12 @@ import { useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { publishPromo } from "../actions";
|
||||
import { PromoImage } from "@/components/PromoImage";
|
||||
import { MemberAvatar } from "@/components/MemberAvatar";
|
||||
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
|
||||
import type { SocialNetwork } from "@/lib/social";
|
||||
import type { PromoCategoryGroup } from "@/lib/promo-categories";
|
||||
import { formatValidity, isRangeInvalid } from "@/lib/promo-validity";
|
||||
import { formatSchedule, parseScheduleInput } from "@/lib/promo-schedule";
|
||||
|
||||
const STRIPE_WARM =
|
||||
"repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)";
|
||||
@@ -15,21 +19,64 @@ const NETWORK_LABELS: Record<SocialNetwork, string> = {
|
||||
linkedin: "LinkedIn",
|
||||
};
|
||||
|
||||
/** Une ligne du récapitulatif de dates, à droite du formulaire. */
|
||||
function SummaryRow({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
last = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
tone: "set" | "muted" | "error";
|
||||
last?: boolean;
|
||||
}) {
|
||||
const color = tone === "error" ? "#d8472b" : tone === "set" ? "#3c3322" : "#a99c82";
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
justifyContent: "space-between",
|
||||
gap: 14,
|
||||
padding: "12px 0",
|
||||
borderBottom: last ? "none" : "1px solid #f0e8d6",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 12.5, color: "#9a8d72", fontWeight: 700, whiteSpace: "nowrap" }}>{label}</span>
|
||||
<span style={{ fontSize: 13, color, fontWeight: tone === "muted" ? 500 : 700, textAlign: "right" }}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MemberSpaceForm({
|
||||
memberName,
|
||||
categories,
|
||||
memberLogoUrl,
|
||||
categoryGroups,
|
||||
defaultCategory,
|
||||
networks = [],
|
||||
}: {
|
||||
memberName: string;
|
||||
categories: string[];
|
||||
/** Logo de l'adhérent, affiché dans l'angle de l'aperçu comme sur le site. */
|
||||
memberLogoUrl?: string | null;
|
||||
/** Types de produits / services, groupés pour le `<select>`. */
|
||||
categoryGroups: PromoCategoryGroup[];
|
||||
/** Pré-sélection déduite du métier de l'adhérent. */
|
||||
defaultCategory?: string;
|
||||
networks?: SocialNetwork[];
|
||||
}) {
|
||||
const CATEGORIES = categories.length > 0 ? categories : ["Autre"];
|
||||
const allCategories = categoryGroups.flatMap((g) => g.items);
|
||||
const initialCategory = defaultCategory && allCategories.includes(defaultCategory) ? defaultCategory : allCategories[0] ?? "Autre";
|
||||
const router = useRouter();
|
||||
const [title, setTitle] = useState("");
|
||||
const [text, setText] = useState("");
|
||||
const [cat, setCat] = useState(CATEGORIES[0]);
|
||||
const [cat, setCat] = useState(initialCategory);
|
||||
const [badge, setBadge] = useState("");
|
||||
const [startsOn, setStartsOn] = useState("");
|
||||
const [endsOn, setEndsOn] = useState("");
|
||||
const [publishAt, setPublishAt] = useState("");
|
||||
const [imgData, setImgData] = useState("");
|
||||
const [shares, setShares] = useState<SocialNetwork[]>([]);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
@@ -56,6 +103,9 @@ export function MemberSpaceForm({
|
||||
setTitle("");
|
||||
setText("");
|
||||
setBadge("");
|
||||
setStartsOn("");
|
||||
setEndsOn("");
|
||||
setPublishAt("");
|
||||
setImgData("");
|
||||
setShares([]);
|
||||
setSubmitted(false);
|
||||
@@ -70,6 +120,9 @@ export function MemberSpaceForm({
|
||||
fd.set("text", text);
|
||||
fd.set("category", cat);
|
||||
fd.set("badge", badge);
|
||||
fd.set("startsOn", startsOn);
|
||||
fd.set("endsOn", endsOn);
|
||||
fd.set("publishAt", publishAt);
|
||||
fd.set("imageUrl", imgData);
|
||||
if (shares.includes("facebook")) fd.set("shareFacebook", "on");
|
||||
if (shares.includes("linkedin")) fd.set("shareLinkedin", "on");
|
||||
@@ -83,6 +136,17 @@ export function MemberSpaceForm({
|
||||
|
||||
const hasImg = !!imgData;
|
||||
const hasBadge = !!badge.trim();
|
||||
const validity = formatValidity({ startsOn, endsOn });
|
||||
// Bornes incohérentes : on le dit avant l'envoi plutôt qu'après un refus.
|
||||
const badRange = isRangeInvalid({ startsOn, endsOn });
|
||||
// Aperçu de la programmation. Le champ donne une heure locale : on la relit
|
||||
// avec le même formateur que le backoffice pour éviter toute surprise.
|
||||
let scheduleHint: string | null = null;
|
||||
try {
|
||||
scheduleHint = formatSchedule(parseScheduleInput(publishAt));
|
||||
} catch {
|
||||
scheduleHint = null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-2" style={{ alignItems: "start" }}>
|
||||
@@ -144,10 +208,16 @@ export function MemberSpaceForm({
|
||||
|
||||
<div style={{ display: "flex", gap: 12, marginBottom: 16 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label className="field-label">Catégorie</label>
|
||||
<label className="field-label">Type de produit ou de service</label>
|
||||
<select value={cat} onChange={(e) => setCat(e.target.value)} className="field">
|
||||
{CATEGORIES.map((c) => (
|
||||
<option key={c}>{c}</option>
|
||||
{categoryGroups.map((group) => (
|
||||
<optgroup key={group.label} label={group.label}>
|
||||
{group.items.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
@@ -157,6 +227,56 @@ export function MemberSpaceForm({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label className="field-label">Durée de validité · facultatif</label>
|
||||
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
|
||||
<div style={{ flex: "1 1 150px" }}>
|
||||
<input
|
||||
type="date"
|
||||
value={startsOn}
|
||||
onChange={(e) => { setStartsOn(e.target.value); setSubmitted(false); }}
|
||||
className="field"
|
||||
aria-label="Début de la promotion"
|
||||
/>
|
||||
<div style={{ fontSize: 11.5, color: "#a99c82", marginTop: 4 }}>Début</div>
|
||||
</div>
|
||||
<div style={{ flex: "1 1 150px" }}>
|
||||
<input
|
||||
type="date"
|
||||
value={endsOn}
|
||||
min={startsOn || undefined}
|
||||
onChange={(e) => { setEndsOn(e.target.value); setSubmitted(false); }}
|
||||
className="field"
|
||||
aria-label="Fin de la promotion"
|
||||
/>
|
||||
<div style={{ fontSize: 11.5, color: "#a99c82", marginTop: 4 }}>Fin</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: badRange ? "#d8472b" : "#8c8068", marginTop: 8, lineHeight: 1.5 }}>
|
||||
{badRange
|
||||
? "La date de fin est antérieure à la date de début."
|
||||
: validity
|
||||
? `Affiché sur le site et dans la publication : « ${validity} ». L'offre n'apparaît que pendant cette période.`
|
||||
: "Laissez vide si votre offre n'a pas de date limite : elle restera affichée sans échéance."}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label className="field-label">Programmer la publication · facultatif</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={publishAt}
|
||||
onChange={(e) => { setPublishAt(e.target.value); setSubmitted(false); }}
|
||||
className="field"
|
||||
aria-label="Date et heure de publication souhaitées"
|
||||
/>
|
||||
<div style={{ fontSize: 12, color: "#8c8068", marginTop: 8, lineHeight: 1.5 }}>
|
||||
{scheduleHint
|
||||
? `Mise en ligne demandée ${scheduleHint} — site et réseaux en même temps. L'association reste libre de la déplacer.`
|
||||
: "Laissez vide pour une mise en ligne dès la validation par l'association."}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="field-label">Texte de la promotion</label>
|
||||
<textarea
|
||||
value={text}
|
||||
@@ -218,7 +338,7 @@ export function MemberSpaceForm({
|
||||
<div style={{ display: "flex", gap: 10 }}>
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={pending}
|
||||
disabled={pending || badRange}
|
||||
className="font-display"
|
||||
style={{ flex: 1, border: "none", background: "#9a6638", color: "#fff", fontWeight: 700, fontSize: 15, padding: 14, borderRadius: 11, cursor: pending ? "default" : "pointer", opacity: pending ? 0.7 : 1 }}
|
||||
>
|
||||
@@ -243,10 +363,31 @@ export function MemberSpaceForm({
|
||||
|
||||
{/* LIVE PREVIEW */}
|
||||
<div>
|
||||
{/* Récapitulatif des dates : quand l'offre paraît, combien de temps
|
||||
elle reste. Deux informations que la carte ne montre pas. */}
|
||||
<div style={{ fontSize: 12.5, letterSpacing: "0.08em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 700, marginBottom: 12 }}>
|
||||
Dates de votre offre
|
||||
</div>
|
||||
<div style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 14, padding: "4px 16px", marginBottom: 26 }}>
|
||||
<SummaryRow
|
||||
label="Durée de validité"
|
||||
value={badRange ? "Dates incohérentes" : validity ?? "Sans date limite"}
|
||||
tone={badRange ? "error" : validity ? "set" : "muted"}
|
||||
/>
|
||||
<SummaryRow
|
||||
label="Publication"
|
||||
value={scheduleHint ? `Programmée ${scheduleHint}` : "Dès la validation par l'association"}
|
||||
tone={scheduleHint ? "set" : "muted"}
|
||||
last
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 12.5, letterSpacing: "0.08em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 700, marginBottom: 12 }}>
|
||||
Aperçu en direct
|
||||
</div>
|
||||
<article style={{ background: "#fff", border: "1px solid #ece3d0", borderRadius: 18, overflow: "hidden", boxShadow: "0 18px 40px -26px rgba(40,30,15,0.5)" }}>
|
||||
{/* Largeur bornée : la carte doit ressembler à celle du site, pas
|
||||
occuper toute la colonne (le visuel carré la rendait démesurée). */}
|
||||
<article style={{ maxWidth: 320, background: "#fff", border: "1px solid #ece3d0", borderRadius: 18, overflow: "hidden", boxShadow: "0 18px 40px -26px rgba(40,30,15,0.5)" }}>
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
@@ -281,8 +422,14 @@ export function MemberSpaceForm({
|
||||
{text.trim() ||
|
||||
"Le texte de votre offre apparaîtra ici au fur et à mesure que vous le rédigez."}
|
||||
</p>
|
||||
{validity && (
|
||||
<div style={{ fontSize: 12, color: "#a99c82", marginBottom: 12 }}>{validity}</div>
|
||||
)}
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", borderTop: "1px solid #f0e8d6", paddingTop: 12 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: "#3c3322" }}>{memberName}</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 9, minWidth: 0 }}>
|
||||
<MemberAvatar name={memberName} logoUrl={memberLogoUrl} size={28} />
|
||||
<span style={{ fontSize: 13, fontWeight: 700, color: "#3c3322" }}>{memberName}</span>
|
||||
</div>
|
||||
<span style={{ color: "#2C6FB3", fontWeight: 700, fontSize: 13 }}>Voir l'offre →</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+35
-240
@@ -1,36 +1,21 @@
|
||||
import Link from "next/link";
|
||||
import { and, asc, desc, eq, gte, inArray, sql } from "drizzle-orm";
|
||||
import { and, asc, desc, eq, gte, sql } from "drizzle-orm";
|
||||
import { getSession } from "@/lib/session";
|
||||
import { db } from "@/db";
|
||||
import { categories, imageConsents, meetingRegistrations, meetings, members, promotions, socialPosts, type Member } from "@/db/schema";
|
||||
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
|
||||
import { configuredNetworks, SOCIAL_LABELS, type SocialNetwork } from "@/lib/social";
|
||||
import { categories, imageConsents, meetingRegistrations, meetings, members, promotions } from "@/db/schema";
|
||||
import type { Member } from "@/db/schema";
|
||||
import { memberPath } from "@/lib/seo";
|
||||
import { ImageField } from "@/components/ImageField";
|
||||
import { ImageConsentForm } from "@/components/ImageConsentForm";
|
||||
import { HoursEditor } from "@/components/HoursEditor";
|
||||
import { TagsField } from "@/components/TagsField";
|
||||
import { communeOptions } from "@/lib/communes";
|
||||
import {
|
||||
saveImageConsent,
|
||||
setOwnPromoShareTargets,
|
||||
setOwnPromoSuspension,
|
||||
updateOwnProfile,
|
||||
} from "../actions";
|
||||
import { MemberSpaceForm } from "./MemberSpaceForm";
|
||||
import { saveImageConsent, updateOwnProfile } from "../actions";
|
||||
import { EspaceHeader } from "./EspaceHeader";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const PROMO_STATUS: Record<string, { label: string; bg: string; color: string }> = {
|
||||
live: { label: "En ligne", bg: "#e6f4ec", color: "#1f8a5b" },
|
||||
pending: { label: "En attente", bg: "#fbeede", color: "#9a6638" },
|
||||
rejected: { label: "Refusée", bg: "#fbe9e6", color: "#d8472b" },
|
||||
suspended: { label: "Suspendue", bg: "#fbe9e6", color: "#d8472b" },
|
||||
expired: { label: "Expirée", bg: "#f1efe7", color: "#a99c82" },
|
||||
};
|
||||
|
||||
function initialsOf(name: string) {
|
||||
return name.split(/\s+/).map((w) => w[0]).join("").slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
/** Espace adhérent, onglet Profil : fiche publique, inscriptions, droit à l'image. */
|
||||
export default async function EspacePage() {
|
||||
const session = await getSession();
|
||||
const memberId = session?.user.memberId ?? null;
|
||||
@@ -39,20 +24,10 @@ export default async function EspacePage() {
|
||||
let memberName = fallbackName;
|
||||
let subtitle = "Espace adhérent";
|
||||
let profile: Member | null = null;
|
||||
let myPromos: {
|
||||
id: number;
|
||||
title: string;
|
||||
status: string;
|
||||
createdAt: Date;
|
||||
imageUrl: string | null;
|
||||
suspendedBy: "member" | "staff" | null;
|
||||
shareFacebook: boolean;
|
||||
shareLinkedin: boolean;
|
||||
}[] = [];
|
||||
// Dernière tentative de publication par (promo, réseau).
|
||||
let lastShare = new Map<string, { status: string; url: string | null; error: string | null }>();
|
||||
let livePromoCount = 0;
|
||||
let myRegistrations: { meetingId: number; title: string; startsAt: Date; location: string | null; participants: number; confirmed: boolean }[] = [];
|
||||
let latestConsent: { decision: string; createdAt: Date } | null = null;
|
||||
let profileCategory: { slug: string; label: string } | null = null;
|
||||
|
||||
if (memberId) {
|
||||
const [m] = await db.select().from(members).where(eq(members.id, memberId));
|
||||
@@ -60,43 +35,16 @@ export default async function EspacePage() {
|
||||
profile = m;
|
||||
memberName = m.name;
|
||||
const [cat] = m.categoryId
|
||||
? await db.select({ label: categories.label }).from(categories).where(eq(categories.id, m.categoryId))
|
||||
? await db.select({ slug: categories.slug, label: categories.label }).from(categories).where(eq(categories.id, m.categoryId))
|
||||
: [];
|
||||
profileCategory = cat ?? null;
|
||||
subtitle = ["Espace adhérent", cat?.label, m.city].filter(Boolean).join(" · ");
|
||||
}
|
||||
myPromos = await db
|
||||
.select({
|
||||
id: promotions.id,
|
||||
title: promotions.title,
|
||||
status: promotions.status,
|
||||
createdAt: promotions.createdAt,
|
||||
imageUrl: promotions.imageUrl,
|
||||
suspendedBy: promotions.suspendedBy,
|
||||
shareFacebook: promotions.shareFacebook,
|
||||
shareLinkedin: promotions.shareLinkedin,
|
||||
})
|
||||
const [live] = await db
|
||||
.select({ total: sql<number>`count(*)` })
|
||||
.from(promotions)
|
||||
.where(eq(promotions.memberId, memberId))
|
||||
.orderBy(desc(promotions.createdAt));
|
||||
if (myPromos.length > 0) {
|
||||
const rows = await db
|
||||
.select({
|
||||
promotionId: socialPosts.promotionId,
|
||||
network: socialPosts.network,
|
||||
status: socialPosts.status,
|
||||
url: socialPosts.url,
|
||||
error: socialPosts.error,
|
||||
})
|
||||
.from(socialPosts)
|
||||
.where(inArray(socialPosts.promotionId, myPromos.map((p) => p.id)))
|
||||
.orderBy(desc(socialPosts.createdAt));
|
||||
// Trié du plus récent au plus ancien : la première occurrence gagne.
|
||||
lastShare = new Map();
|
||||
for (const row of rows) {
|
||||
const key = `${row.promotionId}:${row.network}`;
|
||||
if (!lastShare.has(key)) lastShare.set(key, row);
|
||||
}
|
||||
}
|
||||
.where(and(eq(promotions.memberId, memberId), eq(promotions.status, "live")));
|
||||
livePromoCount = Number(live?.total ?? 0);
|
||||
myRegistrations = await db
|
||||
.select({
|
||||
meetingId: meetings.id,
|
||||
@@ -120,13 +68,6 @@ export default async function EspacePage() {
|
||||
latestConsent = consent ?? null;
|
||||
}
|
||||
|
||||
const catRows = await db
|
||||
.select({ label: categories.label })
|
||||
.from(categories)
|
||||
.orderBy(asc(categories.sort));
|
||||
const categoryLabels = catRows.map((c) => c.label);
|
||||
const networks = await configuredNetworks();
|
||||
|
||||
function fmtDate(d: Date) {
|
||||
return new Date(d).toLocaleDateString("fr-FR", { day: "numeric", month: "long" });
|
||||
}
|
||||
@@ -140,17 +81,13 @@ export default async function EspacePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ background: "#13324F", borderRadius: 16, padding: "20px 24px", marginBottom: 22, display: "flex", alignItems: "center", gap: 15 }}>
|
||||
<span className="font-display" style={{ width: 46, height: 46, borderRadius: 12, background: "#E0A63C", color: "#33291D", display: "flex", alignItems: "center", justifyContent: "center", fontWeight: 800, fontSize: 16, flexShrink: 0 }}>
|
||||
{initialsOf(memberName)}
|
||||
</span>
|
||||
<div>
|
||||
<div className="font-display" style={{ fontWeight: 700, fontSize: 18, color: "#fff" }}>
|
||||
{memberName}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: "#9bb6cd" }}>{subtitle}</div>
|
||||
</div>
|
||||
</div>
|
||||
<EspaceHeader
|
||||
memberName={memberName}
|
||||
subtitle={subtitle}
|
||||
active="profil"
|
||||
publicPath={profile && profile.status === "active" ? memberPath(profile) : null}
|
||||
promoBadge={livePromoCount}
|
||||
/>
|
||||
|
||||
{memberId && (
|
||||
<section style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 24, marginBottom: 28 }}>
|
||||
@@ -194,6 +131,13 @@ export default async function EspacePage() {
|
||||
<label className="field-label">Nom</label>
|
||||
<input name="name" required defaultValue={profile.name} className="field" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">E-mail de contact (affiché sur la fiche)</label>
|
||||
<input name="contactEmail" type="email" defaultValue={profile.contactEmail ?? ""} className="field" placeholder={profile.email} />
|
||||
<p className="field-hint">
|
||||
Indépendant de votre identifiant de connexion. Laissé vide, la fiche affiche l'e-mail de votre compte.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Commune</label>
|
||||
<select name="city" className="field" defaultValue={profile.city ?? ""}>
|
||||
@@ -231,11 +175,15 @@ export default async function EspacePage() {
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<label className="field-label">Description (une ligne vide sépare les paragraphes)</label>
|
||||
<textarea name="description" rows={4} defaultValue={profile.description ?? ""} className="field" style={{ resize: "vertical" }} />
|
||||
<p className="field-hint">
|
||||
Ce texte est lu par Google : indiquez votre métier, votre commune et vos spécialités en trois à cinq
|
||||
phrases (ex. « Boulangerie artisanale à Frouard : pain au levain, viennoiseries, pâtisseries maison »).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<label className="field-label">Tags / spécialités (séparés par des virgules)</label>
|
||||
<input name="tags" defaultValue={profile.tags ?? ""} className="field" />
|
||||
<TagsField defaultValue={profile.tags} fixedCategory={profileCategory} />
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 16 }}>
|
||||
@@ -280,159 +228,6 @@ export default async function EspacePage() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
<MemberSpaceForm memberName={memberName} categories={categoryLabels} networks={networks} />
|
||||
|
||||
<div style={{ marginTop: 28 }}>
|
||||
<div style={{ fontSize: 12.5, letterSpacing: "0.08em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 700, marginBottom: 4 }}>
|
||||
Mes promotions
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: "#8c8068", marginBottom: 12 }}>
|
||||
Vous pouvez suspendre une promotion en ligne à tout moment : elle disparaît du site et
|
||||
vous la remettez en ligne quand vous voulez.
|
||||
</div>
|
||||
{myPromos.length === 0 && (
|
||||
<div style={{ fontSize: 13.5, color: "#a99c82" }}>Vous n'avez pas encore publié de promotion.</div>
|
||||
)}
|
||||
{myPromos.map((mp) => {
|
||||
const st = PROMO_STATUS[mp.status] ?? PROMO_STATUS.expired;
|
||||
const suspendedByStaff = mp.status === "suspended" && mp.suspendedBy === "staff";
|
||||
return (
|
||||
<div key={mp.id} style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 12, padding: "12px 14px", marginBottom: 10 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 13 }}>
|
||||
<span
|
||||
style={{
|
||||
width: 42,
|
||||
height: 42,
|
||||
borderRadius: 9,
|
||||
background: mp.imageUrl
|
||||
? `url(${mp.imageUrl})`
|
||||
: "repeating-linear-gradient(45deg,#efe9da,#efe9da 7px,#e6ddc9 7px,#e6ddc9 14px)",
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
flexShrink: 0,
|
||||
opacity: mp.status === "suspended" ? 0.5 : 1,
|
||||
}}
|
||||
/>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: "#26201a", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
{mp.title}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "#a99c82" }}>Publié le {fmtDate(mp.createdAt)}</div>
|
||||
</div>
|
||||
<span style={{ display: "inline-block", fontSize: 11.5, fontWeight: 700, padding: "4px 11px", borderRadius: 999, background: st.bg, color: st.color, flexShrink: 0 }}>
|
||||
{st.label}
|
||||
</span>
|
||||
{mp.status === "live" && (
|
||||
<form action={setOwnPromoSuspension}>
|
||||
<input type="hidden" name="id" value={mp.id} />
|
||||
<input type="hidden" name="action" value="suspend" />
|
||||
<button type="submit" style={{ border: "1px solid #e0c3bb", background: "#fff", color: "#d8472b", fontWeight: 700, fontSize: 12.5, padding: "7px 13px", borderRadius: 9, cursor: "pointer", whiteSpace: "nowrap" }}>
|
||||
Suspendre
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
{mp.status === "suspended" && !suspendedByStaff && (
|
||||
<form action={setOwnPromoSuspension}>
|
||||
<input type="hidden" name="id" value={mp.id} />
|
||||
<input type="hidden" name="action" value="restore" />
|
||||
<button type="submit" style={{ border: "none", background: "#1f8a5b", color: "#fff", fontWeight: 700, fontSize: 12.5, padding: "8px 14px", borderRadius: 9, cursor: "pointer", whiteSpace: "nowrap" }}>
|
||||
Remettre en ligne
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
{suspendedByStaff && (
|
||||
<div style={{ marginTop: 10, background: "#fbe9e6", border: "1px solid #f2d5cf", color: "#a8503c", borderRadius: 9, padding: "9px 11px", fontSize: 12.5, lineHeight: 1.5 }}>
|
||||
Cette promotion a été suspendue par l'association. Contactez-la pour la
|
||||
remettre en ligne.
|
||||
</div>
|
||||
)}
|
||||
{networks.length > 0 && (
|
||||
<PromoShareState
|
||||
promo={mp}
|
||||
networks={networks}
|
||||
lastShare={lastShare}
|
||||
editable={mp.status === "pending"}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* État de diffusion d'une promo côté adhérent : modifiable tant qu'elle est en
|
||||
* attente, purement informatif dès qu'elle est validée.
|
||||
*/
|
||||
function PromoShareState({
|
||||
promo,
|
||||
networks,
|
||||
lastShare,
|
||||
editable,
|
||||
}: {
|
||||
promo: { id: number; shareFacebook: boolean; shareLinkedin: boolean };
|
||||
networks: SocialNetwork[];
|
||||
lastShare: Map<string, { status: string; url: string | null; error: string | null }>;
|
||||
editable: boolean;
|
||||
}) {
|
||||
const wanted = (n: SocialNetwork) => (n === "facebook" ? promo.shareFacebook : promo.shareLinkedin);
|
||||
const chosen = networks.filter(wanted);
|
||||
|
||||
if (editable) {
|
||||
return (
|
||||
<form action={setOwnPromoShareTargets} style={{ marginTop: 11, borderTop: "1px solid #f0e8d6", paddingTop: 11 }}>
|
||||
<input type="hidden" name="id" value={promo.id} />
|
||||
<div style={{ fontSize: 11.5, color: "#8c8068", marginBottom: 8, lineHeight: 1.5 }}>
|
||||
Diffusion prévue après validation — modifiable tant que la promotion est en attente.
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 10, flexWrap: "wrap", alignItems: "center" }}>
|
||||
{networks.map((network) => (
|
||||
<label key={network} style={{ display: "inline-flex", alignItems: "center", gap: 7, fontSize: 12.5, fontWeight: 700, color: SOCIAL_BRAND[network], cursor: "pointer" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
name={network === "facebook" ? "shareFacebook" : "shareLinkedin"}
|
||||
defaultChecked={wanted(network)}
|
||||
style={{ accentColor: SOCIAL_BRAND[network], margin: 0 }}
|
||||
/>
|
||||
<SocialIcon network={network} size={15} />
|
||||
{SOCIAL_LABELS[network]}
|
||||
</label>
|
||||
))}
|
||||
<button type="submit" style={{ border: "1px solid #d8cdb4", background: "#fff", color: "#6c6150", fontWeight: 700, fontSize: 12, padding: "6px 12px", borderRadius: 8, cursor: "pointer" }}>
|
||||
Mettre à jour
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
if (chosen.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 11, borderTop: "1px solid #f0e8d6", paddingTop: 11, display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
{chosen.map((network) => {
|
||||
const last = lastShare.get(`${promo.id}:${network}`);
|
||||
const posted = last?.status === "posted";
|
||||
const failed = last?.status === "failed";
|
||||
const detail = posted ? "publié" : failed ? "échec de publication" : "en attente";
|
||||
const color = posted ? "#1f8a5b" : failed ? "#d8472b" : "#9a6638";
|
||||
const chip = (
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 7, background: "#faf7ef", border: "1px solid #f0e8d6", borderRadius: 999, padding: "5px 12px", fontSize: 11.5, fontWeight: 700, color }}>
|
||||
<SocialIcon network={network} size={13} />
|
||||
{SOCIAL_LABELS[network]} · {detail}
|
||||
</span>
|
||||
);
|
||||
return posted && last?.url ? (
|
||||
<a key={network} href={last.url} target="_blank" rel="noopener noreferrer" style={{ textDecoration: "none" }}>
|
||||
{chip}
|
||||
</a>
|
||||
) : (
|
||||
<span key={network}>{chip}</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
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 { 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;
|
||||
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} />
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
+57
-15
@@ -3,10 +3,17 @@ import { redirect } from "next/navigation";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { getSession } from "@/lib/session";
|
||||
import { db } from "@/db";
|
||||
import { activityLog, contactMessages, members, membershipRequests, promotions } from "@/db/schema";
|
||||
import { activityLog, contactMessages, members, membershipRequests, promotions, socialPosts } from "@/db/schema";
|
||||
import { activityNodes } from "@/lib/activity";
|
||||
import { can, isStaff } from "@/lib/rbac";
|
||||
import { expiryStatus, getSocialAccounts, SOCIAL_LABELS } from "@/lib/social-accounts";
|
||||
import { countVisiblePromotions } from "@/lib/queries";
|
||||
import {
|
||||
expiryStatus,
|
||||
getSocialAccounts,
|
||||
SOCIAL_LABELS,
|
||||
tokenHealthCached,
|
||||
type SocialNetwork,
|
||||
} from "@/lib/social-accounts";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -42,7 +49,7 @@ export default async function DashboardPage() {
|
||||
|
||||
const [activeMembers, livePromos, pendingPromos, requests, newMessages, activity] = await Promise.all([
|
||||
db.select({ id: members.id }).from(members).where(eq(members.status, "active")),
|
||||
db.select({ id: promotions.id }).from(promotions).where(eq(promotions.status, "live")),
|
||||
countVisiblePromotions(),
|
||||
db.select({ id: promotions.id }).from(promotions).where(eq(promotions.status, "pending")),
|
||||
db.select({ id: membershipRequests.id }).from(membershipRequests).where(eq(membershipRequests.status, "new")),
|
||||
db.select({ id: contactMessages.id }).from(contactMessages).where(eq(contactMessages.status, "new")),
|
||||
@@ -51,14 +58,47 @@ export default async function DashboardPage() {
|
||||
|
||||
const pendingCount = pendingPromos.length;
|
||||
|
||||
// Jetons réseaux à renouveler : sans alerte, on ne découvre l'expiration
|
||||
// qu'au moment où une publication échoue.
|
||||
const expiringNetworks = can(session?.user.role, "manageSettings")
|
||||
? (await getSocialAccounts())
|
||||
.filter((a) => a.accessToken && a.targetId)
|
||||
.map((a) => ({ network: a.network, status: expiryStatus(a.expiresAt) }))
|
||||
.filter((a) => a.status === "soon" || a.status === "expired")
|
||||
: [];
|
||||
// Jetons réseaux à renouveler. Deux signaux, car une date d'expiration ne
|
||||
// suffit pas : un jeton de page Facebook n'expire jamais mais peut être
|
||||
// révoqué, et cela ne se voit qu'à la première publication en échec.
|
||||
const expiringNetworks: { network: SocialNetwork; status: string }[] = [];
|
||||
if (can(session?.user.role, "manageSettings")) {
|
||||
const connected = (await getSocialAccounts()).filter((a) => a.accessToken && a.targetId);
|
||||
|
||||
// Dernière tentative de publication par réseau : une erreur d'autorisation
|
||||
// signale un jeton mort, sans avoir à interroger la plateforme ici.
|
||||
const attempts = connected.length
|
||||
? await db
|
||||
.select({
|
||||
network: socialPosts.network,
|
||||
status: socialPosts.status,
|
||||
error: socialPosts.error,
|
||||
})
|
||||
.from(socialPosts)
|
||||
.orderBy(desc(socialPosts.createdAt))
|
||||
.limit(40)
|
||||
: [];
|
||||
const lastAttempt = new Map<string, (typeof attempts)[number]>();
|
||||
for (const a of attempts) if (!lastAttempt.has(a.network)) lastAttempt.set(a.network, a);
|
||||
|
||||
for (const account of connected) {
|
||||
const last = lastAttempt.get(account.network);
|
||||
const authFailure =
|
||||
last?.status === "failed" &&
|
||||
/oauth|token|401|expired|invalid|permission/i.test(last.error ?? "");
|
||||
// Contrôle direct, mis en cache 6 h : c'est ce qui permet de prévenir
|
||||
// avant la première publication en échec, sans appeler la plateforme à
|
||||
// chaque affichage du tableau de bord.
|
||||
const probe = authFailure ? null : await tokenHealthCached(account.network);
|
||||
const expiry = expiryStatus(account.expiresAt);
|
||||
|
||||
if (authFailure || probe?.ok === false) {
|
||||
expiringNetworks.push({ network: account.network, status: "broken" });
|
||||
} else if (expiry === "soon" || expiry === "expired") {
|
||||
expiringNetworks.push({ network: account.network, status: expiry });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function timeAgo(date: Date) {
|
||||
const diff = Date.now() - new Date(date).getTime();
|
||||
@@ -90,9 +130,11 @@ export default async function DashboardPage() {
|
||||
{expiringNetworks.map((n) => (
|
||||
<div key={n.network}>
|
||||
<strong>{SOCIAL_LABELS[n.network]}</strong>{" "}
|
||||
{n.status === "expired"
|
||||
? "— le jeton a expiré, les publications échoueront."
|
||||
: "— le jeton expire dans moins de 7 jours."}{" "}
|
||||
{n.status === "broken"
|
||||
? "— la dernière publication a été refusée : le jeton ne fonctionne plus."
|
||||
: n.status === "expired"
|
||||
? "— le jeton a expiré, les publications échoueront."
|
||||
: "— le jeton expire dans moins de 7 jours."}{" "}
|
||||
Reconnecter le compte →
|
||||
</div>
|
||||
))}
|
||||
@@ -101,7 +143,7 @@ export default async function DashboardPage() {
|
||||
|
||||
<div className="grid grid-4" style={{ marginBottom: 26 }}>
|
||||
<StatCard label="Adhérents actifs" value={activeMembers.length} hint="sur le réseau" valueColor="#13324F" hintColor="#1f8a5b" />
|
||||
<StatCard label="Promotions en ligne" value={livePromos.length} hint="visibles sur le site" />
|
||||
<StatCard label="Promotions en ligne" value={livePromos} hint="visibles sur le site" />
|
||||
<StatCard label="À modérer" value={pendingCount} hint="en attente de validation" valueColor="#9a6638" hintColor="#9a6638" />
|
||||
<Link href="/backend/demandes" style={{ textDecoration: "none" }}>
|
||||
<StatCard
|
||||
|
||||
@@ -7,7 +7,10 @@ import { can } from "@/lib/rbac";
|
||||
import { configuredNetworks, SOCIAL_LABELS } from "@/lib/social";
|
||||
import { moderatePromo, retryPromoShare } from "../actions";
|
||||
import { PromoImage } from "@/components/PromoImage";
|
||||
import { MemberAvatar } from "@/components/MemberAvatar";
|
||||
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
|
||||
import { formatValidity, visibilityNote } from "@/lib/promo-validity";
|
||||
import { formatSchedule, scheduleLabel, toScheduleInput } from "@/lib/promo-schedule";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -20,6 +23,7 @@ const STATUS_CHIP: Record<string, { label: string; bg: string; color: string }>
|
||||
pending: { label: "En attente", bg: "#fbeede", color: "#9a6638" },
|
||||
live: { label: "En ligne", bg: "#e6f4ec", color: "#1f8a5b" },
|
||||
suspended: { label: "Suspendue", bg: "#fbe9e6", color: "#d8472b" },
|
||||
scheduled: { label: "Programmée", bg: "#eaf0f6", color: "#2C6FB3" },
|
||||
};
|
||||
|
||||
function fmtDate(d: Date) {
|
||||
@@ -41,23 +45,30 @@ export default async function PromotionsPage() {
|
||||
text: promotions.text,
|
||||
category: promotions.category,
|
||||
status: promotions.status,
|
||||
publishAt: promotions.publishAt,
|
||||
validUntil: promotions.validUntil,
|
||||
startsOn: promotions.startsOn,
|
||||
endsOn: promotions.endsOn,
|
||||
imageUrl: promotions.imageUrl,
|
||||
suspendedBy: promotions.suspendedBy,
|
||||
suspendedAt: promotions.suspendedAt,
|
||||
shareFacebook: promotions.shareFacebook,
|
||||
shareLinkedin: promotions.shareLinkedin,
|
||||
memberName: members.name,
|
||||
memberLogoUrl: members.logoUrl,
|
||||
})
|
||||
.from(promotions)
|
||||
.leftJoin(members, eq(promotions.memberId, members.id))
|
||||
.where(inArray(promotions.status, ["pending", "live", "suspended"]))
|
||||
.where(inArray(promotions.status, ["pending", "live", "suspended", "scheduled"]))
|
||||
.orderBy(desc(promotions.createdAt));
|
||||
|
||||
const pending = rows.filter((r) => r.status === "pending");
|
||||
const live = rows.filter((r) => r.status === "live");
|
||||
const suspended = rows.filter((r) => r.status === "suspended");
|
||||
// En attente d'abord, puis en ligne, puis suspendues.
|
||||
const ordered = [...pending, ...live, ...suspended];
|
||||
const scheduled = rows.filter((r) => r.status === "scheduled");
|
||||
// En attente d'abord, puis les programmées (elles attendent une décision de
|
||||
// calendrier), puis les offres en ligne et enfin les suspendues.
|
||||
const ordered = [...pending, ...scheduled, ...live, ...suspended];
|
||||
|
||||
const shares = ordered.length
|
||||
? await db
|
||||
@@ -90,6 +101,9 @@ export default async function PromotionsPage() {
|
||||
<span style={{ background: "#13324F", color: "#fff", fontSize: 13, fontWeight: 600, padding: "8px 16px", borderRadius: 999 }}>
|
||||
En attente · {pending.length}
|
||||
</span>
|
||||
<span style={{ background: "#fff", border: "1px solid #e6dcc6", color: "#6c6150", fontSize: 13, fontWeight: 600, padding: "8px 16px", borderRadius: 999 }}>
|
||||
Programmées · {scheduled.length}
|
||||
</span>
|
||||
<span style={{ background: "#fff", border: "1px solid #e6dcc6", color: "#6c6150", fontSize: 13, fontWeight: 600, padding: "8px 16px", borderRadius: 999 }}>
|
||||
En ligne · {live.length}
|
||||
</span>
|
||||
@@ -115,6 +129,7 @@ export default async function PromotionsPage() {
|
||||
{ordered.map((p) => {
|
||||
const isPending = p.status === "pending";
|
||||
const isSuspended = p.status === "suspended";
|
||||
const isScheduled = p.status === "scheduled";
|
||||
const chip = STATUS_CHIP[p.status] ?? STATUS_CHIP.pending;
|
||||
const chosenNetworks = networks.filter((n) =>
|
||||
n === "facebook" ? p.shareFacebook : p.shareLinkedin
|
||||
@@ -169,13 +184,34 @@ export default async function PromotionsPage() {
|
||||
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 15.5, margin: "0 0 4px", color: "#26201a" }}>
|
||||
{p.title}
|
||||
</h3>
|
||||
<div style={{ fontSize: 12.5, color: "#9a6638", fontWeight: 600, marginBottom: 8 }}>
|
||||
{p.memberName}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 8 }}>
|
||||
<MemberAvatar name={p.memberName ?? ""} logoUrl={p.memberLogoUrl} size={24} />
|
||||
<span style={{ fontSize: 12.5, color: "#9a6638", fontWeight: 600 }}>{p.memberName}</span>
|
||||
</div>
|
||||
<p style={{ margin: "0 0 12px", fontSize: 12.5, color: "#8c8068", lineHeight: 1.45, flex: 1 }}>
|
||||
{p.text}
|
||||
</p>
|
||||
|
||||
{/* Le modérateur voit la période de validité avant de valider :
|
||||
c'est elle qui partira dans le message réseaux sociaux. */}
|
||||
{formatValidity(p) && (
|
||||
<div style={{ fontSize: 12, color: "#9a8d72", fontWeight: 600, margin: "0 0 12px" }}>
|
||||
{formatValidity(p)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{p.status === "live" && visibilityNote(p) && (
|
||||
<div style={{ background: "#faf7ef", border: "1px solid #f0e8d6", color: "#9a8d72", borderRadius: 10, padding: "9px 11px", fontSize: 12, lineHeight: 1.45, marginBottom: 11 }}>
|
||||
{visibilityNote(p)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isScheduled && (
|
||||
<div style={{ background: "#eaf0f6", border: "1px solid #d3e0ee", color: "#2C6FB3", borderRadius: 10, padding: "9px 11px", fontSize: 12, lineHeight: 1.45, marginBottom: 11, fontWeight: 600 }}>
|
||||
{scheduleLabel(p.publishAt)}. Rien n'est encore visible ni diffusé.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSuspended && (
|
||||
<div style={{ background: "#fbe9e6", border: "1px solid #f2d5cf", color: "#a8503c", borderRadius: 10, padding: "9px 11px", fontSize: 12, lineHeight: 1.45, marginBottom: 11 }}>
|
||||
Suspendue par {p.suspendedBy === "member" ? "l'adhérent" : "l'association"}
|
||||
@@ -214,6 +250,24 @@ export default async function PromotionsPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Date demandée par l'adhérent, modifiable ici. Vidée,
|
||||
la promotion part immédiatement. */}
|
||||
<div style={{ background: "#faf7ef", border: "1px solid #f0e8d6", borderRadius: 10, padding: "10px 11px", marginBottom: 8 }}>
|
||||
<label style={{ display: "block", fontSize: 10.5, letterSpacing: "0.08em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 800, marginBottom: 7 }}>
|
||||
Publication
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
name="publishAt"
|
||||
defaultValue={toScheduleInput(p.publishAt)}
|
||||
style={{ width: "100%", boxSizing: "border-box", border: "1px solid #e6dcc6", borderRadius: 8, padding: "7px 9px", fontSize: 12.5, fontFamily: "inherit", color: "#3c3322", background: "#fff" }}
|
||||
/>
|
||||
<div style={{ fontSize: 11, color: "#a99c82", marginTop: 7, lineHeight: 1.45 }}>
|
||||
{p.publishAt
|
||||
? `Demandé par l'adhérent ${formatSchedule(p.publishAt)}. Videz le champ pour publier tout de suite.`
|
||||
: "Vide : mise en ligne immédiate. Renseignez une date pour programmer."}
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" style={{ width: "100%", border: "none", background: "#1f8a5b", color: "#fff", fontWeight: 700, fontSize: 13, padding: 10, borderRadius: 9, cursor: "pointer" }}>
|
||||
Valider
|
||||
</button>
|
||||
@@ -230,6 +284,16 @@ export default async function PromotionsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isScheduled && (
|
||||
<form action={moderatePromo} style={{ marginBottom: 8 }}>
|
||||
<input type="hidden" name="id" value={p.id} />
|
||||
<input type="hidden" name="action" value="publish-now" />
|
||||
<button type="submit" style={{ width: "100%", border: "none", background: "#2C6FB3", color: "#fff", fontWeight: 700, fontSize: 13, padding: 10, borderRadius: 9, cursor: "pointer" }}>
|
||||
Publier maintenant
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{!isPending && (
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<form action={moderatePromo} style={{ flex: 1 }}>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { headers } from "next/headers";
|
||||
import { originFromHeaders } from "@/lib/site-url";
|
||||
import { redirect } from "next/navigation";
|
||||
import type { CSSProperties } from "react";
|
||||
import { getSession } from "@/lib/session";
|
||||
import { can } from "@/lib/rbac";
|
||||
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
|
||||
import {
|
||||
checkTokenHealth,
|
||||
expiryStatus,
|
||||
getSocialAccounts,
|
||||
listStoredTargets,
|
||||
@@ -13,6 +15,7 @@ import {
|
||||
SOCIAL_LABELS,
|
||||
SOCIAL_NETWORKS,
|
||||
type SocialTarget,
|
||||
type TokenHealth,
|
||||
} from "@/lib/social-accounts";
|
||||
import { disconnectSocial, saveSitePublicUrl, saveSocialApp, selectSocialTarget } from "../actions";
|
||||
|
||||
@@ -20,7 +23,14 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
const HELP: Record<
|
||||
(typeof SOCIAL_NETWORKS)[number],
|
||||
{ portal: string; steps: string[]; caution?: string }
|
||||
{
|
||||
portal: string;
|
||||
steps: string[];
|
||||
caution?: string;
|
||||
tools?: { label: string; href: string }[];
|
||||
/** Les deux liens utiles au moment de renouveler un jeton. */
|
||||
renew: { note: string; links: { label: string; hint: string; href: string }[] };
|
||||
}
|
||||
> = {
|
||||
facebook: {
|
||||
portal: "https://developers.facebook.com/apps",
|
||||
@@ -29,6 +39,29 @@ const HELP: Record<
|
||||
"Ajoutez le produit « Connexion Facebook » puis collez l'URL de redirection ci-dessous dans « URI de redirection OAuth valides ».",
|
||||
"Laissez l'application en mode développement et ajoutez le compte de l'association comme administrateur : publier sur votre propre page ne demande alors aucune revue Meta.",
|
||||
],
|
||||
renew: {
|
||||
note:
|
||||
"Le bouton refait tout l'échange : jeton court, puis jeton de longue durée, puis jeton de page. " +
|
||||
"Vous n'avez aucun jeton à copier à la main — les deux liens ci-dessous ne servent qu'à vérifier.",
|
||||
links: [
|
||||
{
|
||||
label: "Débogueur de jeton",
|
||||
hint: "coller un jeton pour voir sa validité et sa date d'expiration",
|
||||
href: "https://developers.facebook.com/tools/debug/accesstoken/",
|
||||
},
|
||||
{
|
||||
label: "Outil de jetons d'accès",
|
||||
hint: "lister les jetons de vos pages et en générer un de longue durée",
|
||||
href: "https://developers.facebook.com/tools/accesstoken/",
|
||||
},
|
||||
],
|
||||
},
|
||||
tools: [
|
||||
{ label: "Débogueur de jeton (vérifier un jeton et sa date d'expiration)", href: "https://developers.facebook.com/tools/debug/accesstoken/" },
|
||||
{ label: "Outil de jetons d'accès (voir les jetons de vos pages)", href: "https://developers.facebook.com/tools/accesstoken/" },
|
||||
{ label: "Explorateur d'API Graph (tester un appel à la main)", href: "https://developers.facebook.com/tools/explorer/" },
|
||||
{ label: "Documentation : jetons de longue durée", href: "https://developers.facebook.com/docs/facebook-login/guides/access-tokens/get-long-lived" },
|
||||
],
|
||||
},
|
||||
linkedin: {
|
||||
portal: "https://www.linkedin.com/developers/apps",
|
||||
@@ -38,7 +71,28 @@ const HELP: Record<
|
||||
"Onglet Produits : demandez « Community Management API », indispensable pour publier au nom de la page.",
|
||||
],
|
||||
caution:
|
||||
"LinkedIn soumet cette demande à une revue (page vérifiée, nom légal, adresse, politique de confidentialité) et ses jetons expirent au bout de 60 jours : il faudra recliquer sur Reconnecter environ tous les deux mois.",
|
||||
"LinkedIn soumet cette demande à une revue (page vérifiée, nom légal, adresse, politique de confidentialité) et ses jetons expirent au bout de 60 jours : il faudra recliquer sur « Renouveler le jeton » environ tous les deux mois.",
|
||||
renew: {
|
||||
note:
|
||||
"Les jetons LinkedIn expirent au bout de 60 jours : le bouton relance l'autorisation et " +
|
||||
"repart pour deux mois. Les deux liens ci-dessous permettent de contrôler l'échéance.",
|
||||
links: [
|
||||
{
|
||||
label: "Jetons de votre application",
|
||||
hint: "voir la durée de validité restante des jetons émis",
|
||||
href: "https://www.linkedin.com/developers/tools/oauth",
|
||||
},
|
||||
{
|
||||
label: "Documentation : durée de vie des jetons",
|
||||
hint: "règles de rafraîchissement et conditions d'accès",
|
||||
href: "https://learn.microsoft.com/linkedin/shared/authentication/programmatic-refresh-tokens",
|
||||
},
|
||||
],
|
||||
},
|
||||
tools: [
|
||||
{ label: "Jetons de votre application (durée de validité)", href: "https://www.linkedin.com/developers/tools/oauth" },
|
||||
{ label: "Documentation : durée de vie et rafraîchissement des jetons", href: "https://learn.microsoft.com/linkedin/shared/authentication/programmatic-refresh-tokens" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -61,10 +115,7 @@ export default async function ReseauxPage({
|
||||
|
||||
// Adresse par laquelle l'administrateur consulte cette page : proposée par
|
||||
// défaut pour lui éviter de la recopier à la main.
|
||||
const requestHeaders = await headers();
|
||||
const host = requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host") ?? "";
|
||||
const protocol = requestHeaders.get("x-forwarded-proto") ?? (host.startsWith("localhost") ? "http" : "https");
|
||||
const detected = host ? `${protocol}://${host}` : "";
|
||||
const detected = originFromHeaders(await headers());
|
||||
|
||||
// Calculées ici : le rendu des cartes n'est pas asynchrone.
|
||||
const redirectUris = new Map<string, string>();
|
||||
@@ -72,6 +123,16 @@ export default async function ReseauxPage({
|
||||
redirectUris.set(network, await redirectUri(network));
|
||||
}
|
||||
|
||||
// État réel du jeton, demandé à la plateforme à chaque affichage : c'est le
|
||||
// seul moyen de savoir qu'un jeton « sans expiration » a été révoqué.
|
||||
const health = new Map<string, TokenHealth | null>();
|
||||
for (const network of SOCIAL_NETWORKS) {
|
||||
const account = byNetwork.get(network);
|
||||
if (account?.accessToken && account.targetId) {
|
||||
health.set(network, await checkTokenHealth(network));
|
||||
}
|
||||
}
|
||||
|
||||
// Cibles à proposer quand le compte administre plusieurs pages.
|
||||
const targets = new Map<string, SocialTarget[]>();
|
||||
let targetError: string | null = null;
|
||||
@@ -124,8 +185,9 @@ export default async function ReseauxPage({
|
||||
</form>
|
||||
{!base && detected && (
|
||||
<div style={{ marginTop: 10, fontSize: 12.5, color: "#9a6638" }}>
|
||||
Adresse détectée depuis votre navigation : <strong>{detected}</strong>. Vérifiez-la puis
|
||||
enregistrez.
|
||||
Aucune adresse enregistrée : celle de votre navigation, <strong>{detected}</strong>, est
|
||||
utilisée par défaut. Facebook et LinkedIn n'acceptent qu'une adresse de retour
|
||||
en <code>https</code> sur un nom de domaine : vérifiez-la puis enregistrez.
|
||||
</div>
|
||||
)}
|
||||
{!base && !detected && (
|
||||
@@ -141,6 +203,11 @@ export default async function ReseauxPage({
|
||||
const status = expiryStatus(account?.expiresAt);
|
||||
const isConnected = !!account?.accessToken && !!account.targetId;
|
||||
const candidates = targets.get(network) ?? [];
|
||||
const tokenHealth = health.get(network) ?? null;
|
||||
const broken = tokenHealth?.ok === false;
|
||||
// Le bloc de renouvellement passe en ambre quand il y a urgence :
|
||||
// jeton mort, expiré, ou sur le point de l'être.
|
||||
const needsRenewal = broken || status === "expired" || status === "soon";
|
||||
|
||||
return (
|
||||
<section key={network} style={panel}>
|
||||
@@ -163,7 +230,7 @@ export default async function ReseauxPage({
|
||||
<h2 className="font-display" style={{ ...title, margin: 0, flex: 1 }}>
|
||||
{SOCIAL_LABELS[network]}
|
||||
</h2>
|
||||
<StatusChip connected={isConnected} status={status} />
|
||||
<StatusChip connected={isConnected} status={broken ? "expired" : status} />
|
||||
</div>
|
||||
|
||||
{isConnected && (
|
||||
@@ -173,15 +240,30 @@ export default async function ReseauxPage({
|
||||
{account?.expiresAt && status !== "never" && ` Jeton valable jusqu'au ${fmtDate(account.expiresAt)}.`}
|
||||
{account?.connectedAt && ` Connecté le ${fmtDate(account.connectedAt)}.`}
|
||||
<br />
|
||||
Pour changer de page, relancez une connexion.
|
||||
{tokenHealth?.ok && (
|
||||
<span style={{ color: "#1f8a5b", fontWeight: 600 }}>
|
||||
✓ Jeton vérifié à l'instant auprès de {SOCIAL_LABELS[network]} : il fonctionne.{" "}
|
||||
</span>
|
||||
)}
|
||||
Pour changer de page ou renouveler l'accès, utilisez le bouton ci-dessous.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{broken && (
|
||||
<Banner tone="error">
|
||||
Le jeton {SOCIAL_LABELS[network]} ne fonctionne plus : les publications échoueront
|
||||
tant que vous n'aurez pas cliqué sur « Renouveler le jeton ».
|
||||
<div style={{ marginTop: 6, fontSize: 12, opacity: 0.85 }}>
|
||||
Réponse de la plateforme : {tokenHealth.reason.slice(0, 220)}
|
||||
</div>
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
{(status === "soon" || status === "expired") && isConnected && (
|
||||
<Banner tone={status === "expired" ? "error" : "warn"}>
|
||||
{status === "expired"
|
||||
? `Le jeton ${SOCIAL_LABELS[network]} a expiré : les publications échoueront tant que vous n'aurez pas reconnecté le compte.`
|
||||
: `Le jeton ${SOCIAL_LABELS[network]} expire bientôt. Un clic sur Reconnecter suffit à le renouveler.`}
|
||||
? `Le jeton ${SOCIAL_LABELS[network]} a expiré : les publications échoueront tant que vous ne l'aurez pas renouvelé.`
|
||||
: `Le jeton ${SOCIAL_LABELS[network]} expire bientôt. Un clic sur « Renouveler le jeton » suffit.`}
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
@@ -234,24 +316,91 @@ export default async function ReseauxPage({
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div style={{ display: "flex", gap: 10, flexWrap: "wrap", borderTop: "1px solid #f0e8d6", paddingTop: 14 }}>
|
||||
<a
|
||||
href={`/api/social/${network}/connect`}
|
||||
className="font-display"
|
||||
{/* Renouvellement : l'action et ses deux liens de contrôle, visibles
|
||||
sans avoir à déplier l'aide de configuration. */}
|
||||
{isConnected && (
|
||||
<div
|
||||
style={{
|
||||
textDecoration: "none",
|
||||
border: "none",
|
||||
background: account?.appId ? SOCIAL_BRAND[network] : "#d8cdb4",
|
||||
color: "#fff",
|
||||
fontWeight: 700,
|
||||
fontSize: 14,
|
||||
padding: "12px 20px",
|
||||
borderRadius: 10,
|
||||
pointerEvents: account?.appId ? undefined : "none",
|
||||
background: needsRenewal ? "#fbeede" : "#faf7ef",
|
||||
border: `1px solid ${needsRenewal ? "#ecd8b8" : "#f0e8d6"}`,
|
||||
borderRadius: 12,
|
||||
padding: 15,
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
{isConnected ? "Reconnecter" : "Connecter"}
|
||||
</a>
|
||||
<div className="field-label" style={{ marginBottom: 4 }}>
|
||||
Renouveler le jeton
|
||||
</div>
|
||||
<div style={{ fontSize: 12.5, color: "#8c8068", lineHeight: 1.6, marginBottom: 12 }}>
|
||||
{help.renew.note}
|
||||
</div>
|
||||
<a
|
||||
href={`/api/social/${network}/connect`}
|
||||
className="font-display"
|
||||
style={{
|
||||
display: "inline-block",
|
||||
textDecoration: "none",
|
||||
background: SOCIAL_BRAND[network],
|
||||
color: "#fff",
|
||||
fontWeight: 700,
|
||||
fontSize: 13.5,
|
||||
padding: "11px 18px",
|
||||
borderRadius: 10,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
Renouveler le jeton {SOCIAL_LABELS[network]}
|
||||
</a>
|
||||
<div style={{ display: "grid", gap: 8 }}>
|
||||
{help.renew.links.map((link) => (
|
||||
<a
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
gap: 8,
|
||||
textDecoration: "none",
|
||||
fontSize: 13,
|
||||
lineHeight: 1.5,
|
||||
color: "#2C6FB3",
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{link.label} ↗
|
||||
<span style={{ color: "#8c8068", fontWeight: 500, fontSize: 12.5 }}>
|
||||
{link.hint}
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", gap: 10, flexWrap: "wrap", borderTop: "1px solid #f0e8d6", paddingTop: 14 }}>
|
||||
{/* Une fois connecté, l'action vit dans le bloc de renouvellement
|
||||
ci-dessus : deux boutons identiques côte à côte n'aidaient personne. */}
|
||||
{!isConnected && (
|
||||
<a
|
||||
href={`/api/social/${network}/connect`}
|
||||
className="font-display"
|
||||
style={{
|
||||
textDecoration: "none",
|
||||
border: "none",
|
||||
background: account?.appId ? SOCIAL_BRAND[network] : "#d8cdb4",
|
||||
color: "#fff",
|
||||
fontWeight: 700,
|
||||
fontSize: 14,
|
||||
padding: "12px 20px",
|
||||
borderRadius: 10,
|
||||
pointerEvents: account?.appId ? undefined : "none",
|
||||
}}
|
||||
>
|
||||
Connecter
|
||||
</a>
|
||||
)}
|
||||
{isConnected && (
|
||||
<form action={disconnectSocial}>
|
||||
<input type="hidden" name="network" value={network} />
|
||||
@@ -277,7 +426,28 @@ export default async function ReseauxPage({
|
||||
{help.portal}
|
||||
</a>
|
||||
</div>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
{help.tools && (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<div className="field-label">Outils pour inspecter un jeton</div>
|
||||
<ul style={{ margin: "6px 0 0", paddingLeft: 20, fontSize: 13, lineHeight: 1.8 }}>
|
||||
{help.tools.map((tool) => (
|
||||
<li key={tool.href}>
|
||||
<a href={tool.href} target="_blank" rel="noopener noreferrer" style={{ color: "#2C6FB3", fontWeight: 600 }}>
|
||||
{tool.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div style={{ marginTop: 8, fontSize: 12.5, color: "#8c8068", lineHeight: 1.6 }}>
|
||||
Ces outils servent à contrôler ou dépanner : le bouton
|
||||
<strong> Connecter</strong> fait déjà l'échange complet — jeton court,
|
||||
puis jeton de longue durée, puis jeton de page — vous n'avez aucun jeton à
|
||||
copier à la main.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<div className="field-label">URL de redirection à déclarer</div>
|
||||
<code style={{ display: "block", background: "#faf7ef", border: "1px solid #f0e8d6", borderRadius: 9, padding: "10px 12px", fontSize: 12.5, color: "#3c3322", overflowWrap: "anywhere" }}>
|
||||
{base ? redirectUris.get(network) : "— renseignez d'abord l'URL publique du site ci-dessus —"}
|
||||
|
||||
+97
-6
@@ -379,15 +379,15 @@ button {
|
||||
/* Boutons sociaux libellés : plus lisibles que deux icônes isolées, et ils
|
||||
donnent enfin du contenu à la quatrième colonne. */
|
||||
.footer-social {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* Sans cela les boutons s'étirent sur toute la colonne et deviennent des
|
||||
pavés vides. */
|
||||
align-items: flex-start;
|
||||
/* Grille à une colonne dimensionnée sur son contenu : tous les boutons
|
||||
prennent la largeur du plus long au lieu de s'étirer sur la colonne ou de
|
||||
former un escalier. */
|
||||
display: inline-grid;
|
||||
grid-template-columns: minmax(150px, max-content);
|
||||
gap: 9px;
|
||||
}
|
||||
.footer-social a {
|
||||
display: inline-flex;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 13px;
|
||||
@@ -948,6 +948,97 @@ img {
|
||||
color: var(--muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
/* Onglets de l'espace adhérent (Profil / Promotions), sous le bandeau. */
|
||||
.espace-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
background: #fff;
|
||||
border: 1px solid #e6dcc6;
|
||||
border-top: none;
|
||||
border-radius: 0 0 16px 16px;
|
||||
padding: 6px;
|
||||
}
|
||||
.espace-tab {
|
||||
flex: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 11px 14px;
|
||||
border-radius: 11px;
|
||||
font-family: "Bricolage Grotesque", "Public Sans", system-ui, sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 14.5px;
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
.espace-tab:hover {
|
||||
background: #faf7ef;
|
||||
color: var(--brown);
|
||||
}
|
||||
.espace-tab.active {
|
||||
background: var(--gold);
|
||||
color: #33291D;
|
||||
}
|
||||
.espace-tab__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 22px;
|
||||
height: 22px;
|
||||
padding: 0 7px;
|
||||
border-radius: 999px;
|
||||
background: #1f8a5b;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.espace-tab.active .espace-tab__badge {
|
||||
background: #33291D;
|
||||
color: #fff;
|
||||
}
|
||||
/* Suggestions de tags : pastilles cliquables sous le champ. */
|
||||
.tag-suggestions {
|
||||
display: flex;
|
||||
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 ---- */
|
||||
.email-workbench {
|
||||
|
||||
+10
-101
@@ -7,9 +7,9 @@ import { SiteFooter } from "@/components/SiteFooter";
|
||||
import { MembershipModalButton } from "@/components/MembershipModalButton";
|
||||
import { MembersCarousel } from "@/components/MembersCarousel";
|
||||
import { StarField } from "@/components/StarField";
|
||||
import { PromoImage } from "@/components/PromoImage";
|
||||
import { PromoCard } from "@/components/PromoCard";
|
||||
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
|
||||
import { getLivePromotions, getRotatingActiveMembers } from "@/lib/queries";
|
||||
import { getActiveMemberCount, getLivePromotions, getRotatingActiveMembers } from "@/lib/queries";
|
||||
import { getSiteSettings, socialLinks } from "@/lib/site-settings";
|
||||
import { SITE_DESCRIPTION, SITE_TITLE, pageMetadata } from "@/lib/seo";
|
||||
|
||||
@@ -26,19 +26,12 @@ export const metadata: Metadata = {
|
||||
title: { absolute: SITE_TITLE },
|
||||
};
|
||||
|
||||
const STRIPE_WARM =
|
||||
"repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)";
|
||||
|
||||
function badgeColor(badge: string | null) {
|
||||
if (!badge) return "#1f8a5b";
|
||||
return badge.includes("%") ? "#d8472b" : "#1f8a5b";
|
||||
}
|
||||
|
||||
export default async function AccueilPage() {
|
||||
const [promos, rotatingMembers, settings] = await Promise.all([
|
||||
const [promos, rotatingMembers, settings, memberCount] = await Promise.all([
|
||||
getLivePromotions(6),
|
||||
getRotatingActiveMembers(),
|
||||
getSiteSettings(),
|
||||
getActiveMemberCount(),
|
||||
]);
|
||||
const socials = socialLinks(settings);
|
||||
|
||||
@@ -225,99 +218,14 @@ export default async function AccueilPage() {
|
||||
Offres, réductions et nouveautés publiées par les commerçants du réseau.
|
||||
</p>
|
||||
</div>
|
||||
<a href="#promotions" className="link-arrow" style={{ textDecoration: "none", color: "#9a6638", fontWeight: 700, fontSize: 14.5, whiteSpace: "nowrap" }}>
|
||||
<Link href="/promotions" className="link-arrow" style={{ textDecoration: "none", color: "#9a6638", fontWeight: 700, fontSize: 14.5, whiteSpace: "nowrap" }}>
|
||||
Toutes les promos →
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Reveal className="grid grid-3 reveal-stagger" style={{ marginTop: 26 }}>
|
||||
{promos.map((p) => (
|
||||
<Link
|
||||
key={p.id}
|
||||
href={p.memberId ? `/adherents/${p.memberId}` : "#"}
|
||||
className="lift-card"
|
||||
style={{
|
||||
textDecoration: "none",
|
||||
color: "inherit",
|
||||
background: "#fff",
|
||||
border: "1px solid #ece3d0",
|
||||
borderRadius: 18,
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
aspectRatio: "1 / 1",
|
||||
background: STRIPE_WARM,
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{p.imageUrl && <PromoImage src={p.imageUrl} alt={p.title ?? ""} />}
|
||||
{!p.imageUrl && (
|
||||
<span style={{ fontSize: 10.5, letterSpacing: "0.12em", color: "#a99c82", textTransform: "uppercase" }}>
|
||||
photo de l'offre
|
||||
</span>
|
||||
)}
|
||||
{p.badge && (
|
||||
<span
|
||||
className="font-display"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
background: badgeColor(p.badge),
|
||||
color: "#fff",
|
||||
fontWeight: 800,
|
||||
fontSize: 18,
|
||||
padding: "8px 16px",
|
||||
borderRadius: "0 0 0 16px",
|
||||
}}
|
||||
>
|
||||
{p.badge}
|
||||
</span>
|
||||
)}
|
||||
{p.category && (
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 12,
|
||||
left: 12,
|
||||
background: "#9a6638",
|
||||
color: "#fff",
|
||||
borderRadius: 999,
|
||||
padding: "5px 12px",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{p.category}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ padding: "17px 18px 18px", display: "flex", flexDirection: "column", flex: 1 }}>
|
||||
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 18, margin: "0 0 5px", color: "#26201a" }}>
|
||||
{p.title}
|
||||
</h3>
|
||||
<p style={{ margin: "0 0 14px", fontSize: 13.5, color: "#8c8068", lineHeight: 1.5, flex: 1 }}>
|
||||
{p.text}
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
borderTop: "1px solid #f0e8d6",
|
||||
paddingTop: 12,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: "#3c3322" }}>{p.memberName}</div>
|
||||
<div style={{ fontSize: 11.5, color: "#a99c82" }}>{p.validUntil}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<PromoCard key={p.id} promo={p} />
|
||||
))}
|
||||
</Reveal>
|
||||
</section>
|
||||
@@ -450,8 +358,9 @@ export default async function AccueilPage() {
|
||||
Faites partie de l'aventure Plein R
|
||||
</h2>
|
||||
<p style={{ margin: "0 auto 22px", fontSize: 15.5, color: "#f4e6d3", maxWidth: 480 }}>
|
||||
Rejoignez plus de 120 commerçants et entreprises qui font réseau, se rencontrent et réussissent
|
||||
ensemble.
|
||||
{memberCount > 0
|
||||
? `Rejoignez les ${memberCount} commerçants et entreprises adhérents qui font réseau, se rencontrent et réussissent ensemble.`
|
||||
: "Rejoignez les commerçants et entreprises qui font réseau, se rencontrent et réussissent ensemble."}
|
||||
</p>
|
||||
<MembershipModalButton
|
||||
label="Devenir adhérent"
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Sparkle } from "@/components/Sparkle";
|
||||
import { SiteHeader } from "@/components/SiteHeader";
|
||||
import { SiteFooter } from "@/components/SiteFooter";
|
||||
import { MembershipModalButton } from "@/components/MembershipModalButton";
|
||||
import { PromoCard } from "@/components/PromoCard";
|
||||
import { JsonLd } from "@/components/JsonLd";
|
||||
import { getLivePromotions } from "@/lib/queries";
|
||||
import { breadcrumbJsonLd, memberListJsonLd, pageMetadata } from "@/lib/seo";
|
||||
import { publicBaseUrl } from "@/lib/seo-server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const metadata: Metadata = pageMetadata({
|
||||
title: "Bons plans et promotions du Bassin de Pompey",
|
||||
description:
|
||||
"Toutes les offres en cours chez les commerçants, artisans et entreprises adhérents Plein R : " +
|
||||
"réductions, nouveautés et bons plans à Pompey, Frouard, Liverdun, Champigneulles…",
|
||||
path: "/promotions",
|
||||
});
|
||||
|
||||
export default async function PromotionsPage() {
|
||||
// `null` : toutes les offres en cours, là où l'accueil n'en montre que six.
|
||||
const [promos, baseUrl] = await Promise.all([getLivePromotions(null), publicBaseUrl()]);
|
||||
|
||||
// Une offre pointe vers la fiche de son commerçant : la liste structurée
|
||||
// renvoie donc vers les adhérents, pas vers des URLs de promotion.
|
||||
const listed = promos
|
||||
.filter((p) => p.memberId != null)
|
||||
.map((p) => ({ id: p.memberId as number, name: p.memberName ?? "", city: p.memberCity }));
|
||||
|
||||
return (
|
||||
<div style={{ background: "#F6F2E8", minHeight: "100vh", fontFamily: "'Public Sans',sans-serif", color: "#33291D" }}>
|
||||
<SiteHeader active="promotions" logo />
|
||||
<JsonLd
|
||||
data={[
|
||||
breadcrumbJsonLd(baseUrl, [
|
||||
{ name: "Accueil", path: "/" },
|
||||
{ name: "Promotions", path: "/promotions" },
|
||||
]),
|
||||
memberListJsonLd(baseUrl, "Promotions en cours chez les adhérents Plein R", listed),
|
||||
]}
|
||||
/>
|
||||
|
||||
<main className="container" style={{ paddingBottom: 56 }}>
|
||||
{/* title block */}
|
||||
<section style={{ position: "relative", padding: "14px 0 22px", overflow: "hidden" }}>
|
||||
<Sparkle color="#E0A63C" size={18} style={{ top: 22, right: 30 }} duration={3.2} />
|
||||
<Sparkle color="#6FB0C6" size={12} style={{ top: 60, right: 90 }} duration={2.7} delay={0.5} />
|
||||
<div
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 9,
|
||||
background: "#fff",
|
||||
border: "1px solid #e6dcc6",
|
||||
color: "#9a6638",
|
||||
fontSize: 12,
|
||||
letterSpacing: "0.12em",
|
||||
textTransform: "uppercase",
|
||||
fontWeight: 800,
|
||||
padding: "6px 13px",
|
||||
borderRadius: 999,
|
||||
marginBottom: 14,
|
||||
}}
|
||||
>
|
||||
<span style={{ width: 7, height: 7, borderRadius: "50%", background: "#E0A63C" }} />
|
||||
{promos.length} offre{promos.length > 1 ? "s" : ""} en cours
|
||||
</div>
|
||||
<h1 className="font-display" style={{ fontWeight: 800, fontSize: "clamp(26px, 6vw, 40px)", letterSpacing: "-0.02em", margin: "0 0 8px", color: "#26201a" }}>
|
||||
Les bons plans de nos adhérents
|
||||
</h1>
|
||||
<p style={{ margin: 0, fontSize: 16.5, color: "#6c6150", maxWidth: 560 }}>
|
||||
Toutes les offres en cours chez les commerçants, artisans et entreprises du réseau.
|
||||
Chaque promotion renvoie vers la fiche de son commerce.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{promos.length === 0 ? (
|
||||
<section
|
||||
style={{
|
||||
background: "#fff",
|
||||
border: "1px solid #e6dcc6",
|
||||
borderRadius: 18,
|
||||
padding: "38px 24px",
|
||||
textAlign: "center",
|
||||
color: "#8c8068",
|
||||
fontSize: 15,
|
||||
lineHeight: 1.7,
|
||||
}}
|
||||
>
|
||||
Aucune promotion en cours pour le moment.
|
||||
<br />
|
||||
<Link href="/annuaire" style={{ color: "#9a6638", fontWeight: 700 }}>
|
||||
Parcourir l'annuaire des adhérents →
|
||||
</Link>
|
||||
</section>
|
||||
) : (
|
||||
<div className="grid grid-3" style={{ gap: 20 }}>
|
||||
{promos.map((p) => (
|
||||
<PromoCard key={p.id} promo={p} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* join CTA */}
|
||||
<section
|
||||
style={{
|
||||
marginTop: 34,
|
||||
position: "relative",
|
||||
background: "linear-gradient(120deg,#13324F,#1d4a72)",
|
||||
borderRadius: 20,
|
||||
padding: "34px 40px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 28,
|
||||
overflow: "hidden",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Sparkle color="#E0A63C" size={18} style={{ top: 20, right: 40 }} duration={3.2} />
|
||||
<div>
|
||||
<div style={{ fontSize: 12.5, letterSpacing: "0.12em", textTransform: "uppercase", color: "#9fc6e6", fontWeight: 700, marginBottom: 8 }}>
|
||||
Vous êtes commerçant du Bassin de Pompey ?
|
||||
</div>
|
||||
<h2 className="font-display" style={{ fontWeight: 800, fontSize: 26, margin: 0, color: "#fff" }}>
|
||||
Publiez vos offres sur Plein R
|
||||
</h2>
|
||||
</div>
|
||||
<MembershipModalButton
|
||||
label="Adhérer à l'association"
|
||||
className="font-display"
|
||||
style={{ border: "none", background: "#E0A63C", color: "#33291D", fontWeight: 700, fontSize: 15.5, padding: "14px 26px", borderRadius: 12, whiteSpace: "nowrap" }}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+22
-9
@@ -2,7 +2,8 @@ import type { MetadataRoute } from "next";
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { members } from "@/db/schema";
|
||||
import { STATIC_ROUTES, absoluteUrl, memberPath } from "@/lib/seo";
|
||||
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";
|
||||
@@ -19,22 +20,34 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
priority: route.priority,
|
||||
}));
|
||||
|
||||
let rows: { id: number }[] = [];
|
||||
let rows: { id: number; name: string; city: string | null }[] = [];
|
||||
let categoriesInUse: { slug: string }[] = [];
|
||||
try {
|
||||
rows = await db
|
||||
.select({ id: members.id })
|
||||
.from(members)
|
||||
.where(eq(members.status, "active"))
|
||||
.orderBy(asc(members.id));
|
||||
[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.id)),
|
||||
url: absoluteUrl(base, memberPath(m)),
|
||||
changeFrequency: "weekly",
|
||||
priority: 0.7,
|
||||
}));
|
||||
|
||||
return [...statics, ...fiches];
|
||||
return [...statics, ...metiers, ...fiches];
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,93 @@
|
||||
import Link from "next/link";
|
||||
import { VitrineImage } from "@/components/VitrineImage";
|
||||
import { memberPath } from "@/lib/seo";
|
||||
|
||||
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.
|
||||
*/
|
||||
export function MemberCard({ m, index = 0 }: { m: MemberCardData; index?: number }) {
|
||||
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>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { VitrineImage } from "./VitrineImage";
|
||||
import { memberPath } from "@/lib/seo";
|
||||
|
||||
export type CarouselMember = {
|
||||
id: number;
|
||||
@@ -24,7 +25,7 @@ const FADE_MS = 450;
|
||||
function Card({ m, visible, index }: { m: CarouselMember; visible: boolean; index: number }) {
|
||||
return (
|
||||
<Link
|
||||
href={`/adherents/${m.id}`}
|
||||
href={memberPath(m)}
|
||||
className="lift-card"
|
||||
style={{
|
||||
background: "#fff",
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Carte d'une promotion sur le site public.
|
||||
*
|
||||
* Un seul rendu pour l'accueil (les 6 dernières) et la page `/promotions`
|
||||
* (toutes les offres en cours) : les deux listes doivent rester identiques.
|
||||
*/
|
||||
|
||||
import Link from "next/link";
|
||||
import { PromoImage } from "@/components/PromoImage";
|
||||
import { MemberAvatar } from "@/components/MemberAvatar";
|
||||
import { formatValidity } from "@/lib/promo-validity";
|
||||
import { memberPath } from "@/lib/seo";
|
||||
|
||||
const STRIPE_WARM =
|
||||
"repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)";
|
||||
|
||||
/** Rouge pour une remise chiffrée, vert pour une offre en nature. */
|
||||
function badgeColor(badge: string | null) {
|
||||
if (!badge) return "#1f8a5b";
|
||||
return badge.includes("%") ? "#d8472b" : "#1f8a5b";
|
||||
}
|
||||
|
||||
export type PublicPromo = {
|
||||
id: number;
|
||||
title: string;
|
||||
text: string | null;
|
||||
category: string | null;
|
||||
badge: string | null;
|
||||
imageUrl: string | null;
|
||||
validUntil: string | null;
|
||||
startsOn: string | null;
|
||||
endsOn: string | null;
|
||||
memberId: number | null;
|
||||
memberName: string | null;
|
||||
memberCity: string | null;
|
||||
memberLogoUrl: string | null;
|
||||
};
|
||||
|
||||
export function PromoCard({ promo: p }: { promo: PublicPromo }) {
|
||||
const validity = formatValidity(p);
|
||||
return (
|
||||
<Link
|
||||
href={p.memberId ? memberPath({ id: p.memberId, name: p.memberName ?? "", city: p.memberCity }) : "#"}
|
||||
className="lift-card"
|
||||
style={{
|
||||
textDecoration: "none",
|
||||
color: "inherit",
|
||||
background: "#fff",
|
||||
border: "1px solid #ece3d0",
|
||||
borderRadius: 18,
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
aspectRatio: "1 / 1",
|
||||
background: STRIPE_WARM,
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{p.imageUrl && <PromoImage src={p.imageUrl} alt={p.title ?? ""} />}
|
||||
{!p.imageUrl && (
|
||||
<span style={{ fontSize: 10.5, letterSpacing: "0.12em", color: "#a99c82", textTransform: "uppercase" }}>
|
||||
photo de l'offre
|
||||
</span>
|
||||
)}
|
||||
{p.badge && (
|
||||
<span
|
||||
className="font-display"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
background: badgeColor(p.badge),
|
||||
color: "#fff",
|
||||
fontWeight: 800,
|
||||
fontSize: 18,
|
||||
padding: "8px 16px",
|
||||
borderRadius: "0 0 0 16px",
|
||||
}}
|
||||
>
|
||||
{p.badge}
|
||||
</span>
|
||||
)}
|
||||
{p.category && (
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 12,
|
||||
left: 12,
|
||||
background: "#9a6638",
|
||||
color: "#fff",
|
||||
borderRadius: 999,
|
||||
padding: "5px 12px",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{p.category}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ padding: "17px 18px 18px", display: "flex", flexDirection: "column", flex: 1 }}>
|
||||
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 18, margin: "0 0 5px", color: "#26201a" }}>
|
||||
{p.title}
|
||||
</h3>
|
||||
<p style={{ margin: "0 0 14px", fontSize: 13.5, color: "#8c8068", lineHeight: 1.5, flex: 1 }}>
|
||||
{p.text}
|
||||
</p>
|
||||
<div style={{ borderTop: "1px solid #f0e8d6", paddingTop: 12 }}>
|
||||
{/* Le logo du commerce juste à gauche de son nom. */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 9 }}>
|
||||
<MemberAvatar name={p.memberName ?? ""} logoUrl={p.memberLogoUrl} size={28} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: "#3c3322" }}>{p.memberName}</div>
|
||||
{validity && <div style={{ fontSize: 11.5, color: "#a99c82" }}>{validity}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -32,7 +32,7 @@ export async function SiteFooter() {
|
||||
<div className="footer-title">Accès rapide</div>
|
||||
<div className="footer-links">
|
||||
<Link href="/annuaire">Annuaire</Link>
|
||||
<Link href="/#promotions">Promotions</Link>
|
||||
<Link href="/promotions">Promotions</Link>
|
||||
<Link href="/association">L'association</Link>
|
||||
<Link href="/rencontres-passees">Rencontres passées</Link>
|
||||
<Link href="/login">Espace adhérent</Link>
|
||||
@@ -61,7 +61,7 @@ export async function SiteFooter() {
|
||||
rel="noopener noreferrer"
|
||||
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} />
|
||||
</span>
|
||||
{s.label}
|
||||
|
||||
@@ -3,7 +3,7 @@ import Link from "next/link";
|
||||
const NAV = [
|
||||
{ key: "accueil", label: "Accueil", href: "/" },
|
||||
{ key: "annuaire", label: "Annuaire", href: "/annuaire" },
|
||||
{ key: "promotions", label: "Promotions", href: "/#promotions" },
|
||||
{ key: "promotions", label: "Promotions", href: "/promotions" },
|
||||
{ key: "association", label: "L'association", href: "/association" },
|
||||
];
|
||||
|
||||
|
||||
@@ -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.
|
||||
* - Photo de couverture présente -> affichée en plein cadre (cover).
|
||||
* - Sinon, le logo est affiché centré et réduit (contain ~80%), pas étiré.
|
||||
* - Sinon, le logo est affiché centré et réduit (contain, ~60 % du cadre) sur
|
||||
* un fond fait du logo lui-même flouté : le cadre prend les couleurs de la
|
||||
* marque sans que le logo soit étiré ni ne déborde.
|
||||
* - Sinon, fond rayé + texte placeholder.
|
||||
*/
|
||||
export function VitrineImage({
|
||||
@@ -43,30 +45,43 @@ export function VitrineImage({
|
||||
}
|
||||
|
||||
if (logoOnly) {
|
||||
// `height` est fixe sur le cadre : les max-height en % du logo sont donc
|
||||
// résolus, là où un conteneur intermédiaire sans hauteur les laissait sans
|
||||
// effet et le logo débordait.
|
||||
return (
|
||||
<div style={{ ...base, background: "linear-gradient(160deg,#fffdf8 0%,#f3ecdc 100%)", padding: "min(8%, 22px)" }}>
|
||||
{/* Cadre thème autour du logo (évite le bloc blanc nu) */}
|
||||
<div style={{ ...base, background: "linear-gradient(160deg,#fffdf8 0%,#f3ecdc 100%)" }}>
|
||||
<div
|
||||
aria-hidden
|
||||
style={{
|
||||
background: "#fff",
|
||||
border: "1px solid #e6dcc6",
|
||||
borderRadius: 14,
|
||||
boxShadow: "0 14px 30px -20px rgba(40,30,15,0.35)",
|
||||
padding: "min(6%, 18px) min(9%, 28px)",
|
||||
maxWidth: "84%",
|
||||
maxHeight: "84%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
backgroundImage: `url(${logoOnly})`,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
filter: "blur(28px) saturate(1.25)",
|
||||
transform: "scale(1.25)",
|
||||
opacity: 0.32,
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={logoOnly}
|
||||
alt=""
|
||||
style={{ maxWidth: "100%", maxHeight: "100%", objectFit: "contain", display: "block" }}
|
||||
/>
|
||||
</div>
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
style={{ position: "absolute", inset: 0, background: "linear-gradient(180deg, rgba(255,253,248,0.35) 0%, rgba(246,242,232,0.6) 100%)" }}
|
||||
/>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={logoOnly}
|
||||
alt=""
|
||||
style={{
|
||||
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}
|
||||
</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,
|
||||
}));
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
text,
|
||||
varchar,
|
||||
boolean,
|
||||
date,
|
||||
timestamp,
|
||||
index,
|
||||
uniqueIndex,
|
||||
@@ -21,6 +22,10 @@ export const promoStatusEnum = pgEnum("promo_status", [
|
||||
"expired",
|
||||
"rejected",
|
||||
"suspended",
|
||||
// Validée par l'association mais en attente de sa date de publication
|
||||
// (`publish_at`). Invisible du site public tant que l'échéance n'est pas
|
||||
// atteinte, et non encore diffusée sur les réseaux.
|
||||
"scheduled",
|
||||
]);
|
||||
// Qui a suspendu la promotion : son créateur (l'adhérent) ou l'association.
|
||||
// Une promotion suspendue par l'association ne peut être réactivée que par elle.
|
||||
@@ -52,7 +57,11 @@ export const categories = pgTable("categories", {
|
||||
export const members = pgTable("members", {
|
||||
id: serial("id").primaryKey(),
|
||||
name: varchar("name", { length: 200 }).notNull(),
|
||||
// E-mail administratif : identifiant de connexion à la création, échanges
|
||||
// avec l'association. Jamais forcément celui que le commerce veut afficher.
|
||||
email: varchar("email", { length: 200 }).notNull(),
|
||||
// E-mail public de la fiche ; à vide, la fiche retombe sur `email`.
|
||||
contactEmail: varchar("contact_email", { length: 200 }),
|
||||
categoryId: integer("category_id").references(() => categories.id),
|
||||
city: varchar("city", { length: 120 }),
|
||||
address: varchar("address", { length: 240 }),
|
||||
@@ -103,11 +112,18 @@ export const promotions = pgTable("promotions", {
|
||||
imageUrl: text("image_url"),
|
||||
memberId: integer("member_id").references(() => members.id),
|
||||
status: promoStatusEnum("status").notNull().default("pending"),
|
||||
// Période de validité. Les deux bornes sont facultatives et indépendantes :
|
||||
// une offre peut n'avoir qu'une date de fin, qu'une date de début, ou aucune.
|
||||
startsOn: date("starts_on"),
|
||||
endsOn: date("ends_on"),
|
||||
/** @deprecated Texte libre des anciennes promos ; sert de repli à l'affichage. */
|
||||
validUntil: varchar("valid_until", { length: 120 }),
|
||||
// Réseaux demandés à la soumission. Le choix reste modifiable tant que la
|
||||
// promo est « pending » ; la validation le fige ET déclenche la publication.
|
||||
shareFacebook: boolean("share_facebook").notNull().default(false),
|
||||
shareLinkedin: boolean("share_linkedin").notNull().default(false),
|
||||
// Programmation de la publication. Vide = mise en ligne dès la validation.
|
||||
publishAt: timestamp("publish_at", { withTimezone: true }),
|
||||
suspendedBy: promoSuspendedByEnum("suspended_by"),
|
||||
suspendedById: integer("suspended_by_id").references(() => users.id, { onDelete: "set null" }),
|
||||
suspendedAt: timestamp("suspended_at", { withTimezone: true }),
|
||||
@@ -151,6 +167,11 @@ export const socialAccounts = pgTable("social_accounts", {
|
||||
targetName: varchar("target_name", { length: 200 }),
|
||||
connectedById: integer("connected_by_id").references(() => users.id, { onDelete: "set null" }),
|
||||
connectedAt: timestamp("connected_at", { withTimezone: true }),
|
||||
// Dernier contrôle de validité auprès de la plateforme : permet au tableau de
|
||||
// bord d'alerter sans refaire un appel réseau à chaque affichage.
|
||||
lastCheckAt: timestamp("last_check_at", { withTimezone: true }),
|
||||
lastCheckOk: boolean("last_check_ok"),
|
||||
lastCheckError: text("last_check_error"),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
|
||||
+5
-87
@@ -5,6 +5,7 @@ import bcrypt from "bcryptjs";
|
||||
import { randomInt } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import * as schema from "./schema";
|
||||
import { categoryRows } from "./categories";
|
||||
import {
|
||||
DEMO_PASSWORD,
|
||||
demoActivity,
|
||||
@@ -40,93 +41,10 @@ async function main() {
|
||||
|
||||
console.log("Seeding database…");
|
||||
|
||||
// ---- Categories (métiers) — liste complète ----
|
||||
const 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" },
|
||||
];
|
||||
// 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,
|
||||
}));
|
||||
// ---- Categories (métiers) : référentiel partagé (src/db/categories.ts) ----
|
||||
// La migration 0012 insère déjà ce référentiel ; le seed le rejoue par sécurité
|
||||
// pour une base créée sans migration, sans jamais écraser un libellé existant.
|
||||
const categoryData = categoryRows();
|
||||
|
||||
for (const c of categoryData) {
|
||||
await db.insert(categories).values(c).onConflictDoNothing({ target: categories.slug });
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Boucle de libération des publications programmées. Fichier séparé de
|
||||
* `instrumentation.ts` : il touche la base et ne doit donc jamais entrer dans
|
||||
* le bundle edge du middleware.
|
||||
*/
|
||||
import { releaseDuePromotions } from "@/lib/promo-publish";
|
||||
|
||||
const INTERVAL_MS = 60_000;
|
||||
|
||||
async function tick() {
|
||||
try {
|
||||
const n = await releaseDuePromotions();
|
||||
if (n > 0) console.log(`[promotions] ${n} publication(s) programmée(s) mise(s) en ligne`);
|
||||
} catch (error) {
|
||||
// `releaseDuePromotions` avale déjà ses erreurs ; ce filet couvre le cas
|
||||
// d'une base injoignable au démarrage, qui ne doit pas tuer le serveur.
|
||||
console.error("[promotions] libérateur :", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Un premier passage au démarrage rattrape les échéances tombées pendant un
|
||||
// redéploiement ou une coupure.
|
||||
void tick();
|
||||
setInterval(tick, INTERVAL_MS).unref();
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Démarrage du serveur : libérateur des publications programmées.
|
||||
*
|
||||
* Le déploiement est un conteneur unique et permanent (`docker-compose`), il
|
||||
* n'y a donc ni cron ni file d'attente. Une boucle minute suffit : elle relit
|
||||
* les promotions `scheduled` dont l'échéance est passée, les met en ligne et
|
||||
* lance la diffusion réseaux. La minute est aussi la précision annoncée dans
|
||||
* le formulaire.
|
||||
*
|
||||
* Sûr à plusieurs instances : la bascule passe par un `UPDATE … RETURNING`
|
||||
* filtré sur le statut (voir `releaseDuePromotions`), une seule instance
|
||||
* récupère chaque promotion.
|
||||
*/
|
||||
export async function register() {
|
||||
// `register()` est aussi appelé pour le runtime edge, où il n'y a ni base
|
||||
// ni `setInterval` durable : le module Node est chargé à la demande pour ne
|
||||
// pas entrer dans ce bundle.
|
||||
if (process.env.NEXT_RUNTIME !== "nodejs") return;
|
||||
if (process.env.PROMO_SCHEDULER === "off") return;
|
||||
await import("./instrumentation-node");
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { db } from "@/db";
|
||||
import { activityLog } from "@/db/schema";
|
||||
import { sanitizeActivityMessage } from "@/lib/activity";
|
||||
|
||||
/**
|
||||
* Écrit une ligne du journal d'activité.
|
||||
*
|
||||
* Le message agrège des saisies de tiers (jusqu'au formulaire de contact
|
||||
* public) : il ne laisse passer que `<strong>`.
|
||||
*/
|
||||
export async function logActivity(message: string, dot = "#2C6FB3") {
|
||||
await db.insert(activityLog).values({ message: sanitizeActivityMessage(message), dot });
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* Catégories de promotion : le **type de produit ou de service** mis en avant,
|
||||
* indépendant du métier de l'adhérent (une boulangerie peut promouvoir un
|
||||
* goûter, un garage des pneus). Affichées en pastille sur la carte de la promo.
|
||||
*
|
||||
* Module pur, groupé pour un `<select>` à `<optgroup>`.
|
||||
*/
|
||||
|
||||
export type PromoCategoryGroup = { label: string; items: string[] };
|
||||
|
||||
export const PROMO_CATEGORY_GROUPS: PromoCategoryGroup[] = [
|
||||
{
|
||||
label: "Alimentation",
|
||||
items: [
|
||||
"Pain & viennoiseries",
|
||||
"Pâtisserie & desserts",
|
||||
"Chocolats & confiserie",
|
||||
"Viandes & charcuterie",
|
||||
"Poissons & fruits de mer",
|
||||
"Fruits & légumes",
|
||||
"Fromages & produits laitiers",
|
||||
"Épicerie & produits du terroir",
|
||||
"Produits bio & locaux",
|
||||
"Vins, bières & spiritueux",
|
||||
"Café, thé & boissons",
|
||||
"Plats préparés & traiteur",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Restauration & sorties",
|
||||
items: [
|
||||
"Menu & formule du midi",
|
||||
"Repas & dîner",
|
||||
"Pizzas & restauration rapide",
|
||||
"Brunch, goûter & glaces",
|
||||
"Apéritif & afterwork",
|
||||
"Soirée & animation",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Mode & accessoires",
|
||||
items: [
|
||||
"Vêtements femme",
|
||||
"Vêtements homme",
|
||||
"Vêtements enfant",
|
||||
"Lingerie & maillots",
|
||||
"Chaussures",
|
||||
"Sacs & maroquinerie",
|
||||
"Bijoux & montres",
|
||||
"Lunettes & solaires",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Beauté & bien-être",
|
||||
items: [
|
||||
"Coiffure",
|
||||
"Barbier",
|
||||
"Soins visage & corps",
|
||||
"Épilation",
|
||||
"Ongles & manucure",
|
||||
"Maquillage & parfums",
|
||||
"Massage & spa",
|
||||
"Tatouage & piercing",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Maison & équipement",
|
||||
items: [
|
||||
"Meubles & décoration",
|
||||
"Literie",
|
||||
"Cuisine & salle de bain",
|
||||
"Électroménager",
|
||||
"TV, high-tech & téléphonie",
|
||||
"Linge de maison & arts de la table",
|
||||
"Luminaires",
|
||||
"Bazar & petit équipement",
|
||||
"Fleurs & plantes",
|
||||
"Jardin & extérieur",
|
||||
"Bricolage & outillage",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Auto & mobilité",
|
||||
items: [
|
||||
"Entretien & révision",
|
||||
"Pneus",
|
||||
"Carrosserie & pare-brise",
|
||||
"Contrôle technique",
|
||||
"Carburant & lavage",
|
||||
"Vélos & motos",
|
||||
"Location de véhicule",
|
||||
"Auto-école & permis",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Santé, famille & animaux",
|
||||
items: [
|
||||
"Pharmacie & parapharmacie",
|
||||
"Optique & audition",
|
||||
"Puériculture & bébé",
|
||||
"Jouets & jeux",
|
||||
"Animalerie & alimentation animale",
|
||||
"Vétérinaire & toilettage",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Services",
|
||||
items: [
|
||||
"Travaux & rénovation",
|
||||
"Plomberie, chauffage & électricité",
|
||||
"Nettoyage & entretien",
|
||||
"Aide à domicile",
|
||||
"Pressing, retouches & cordonnerie",
|
||||
"Photographe & vidéo",
|
||||
"Informatique & réparation",
|
||||
"Communication, web & impression",
|
||||
"Comptabilité, juridique & conseil",
|
||||
"Banque, assurance & crédit",
|
||||
"Immobilier",
|
||||
"Déménagement & transport",
|
||||
"Événementiel & location de matériel",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Loisirs, culture & formation",
|
||||
items: [
|
||||
"Sport & fitness",
|
||||
"Articles de sport",
|
||||
"Livres, presse & papeterie",
|
||||
"Culture, arts & spectacles",
|
||||
"Cours & formation",
|
||||
"Voyages & séjours",
|
||||
"Hôtel & hébergement",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Offres spéciales",
|
||||
items: [
|
||||
"Offre de bienvenue",
|
||||
"Carte de fidélité",
|
||||
"Parrainage",
|
||||
"Déstockage & fin de série",
|
||||
"Nouveauté",
|
||||
"Autre",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const PROMO_CATEGORIES: string[] = PROMO_CATEGORY_GROUPS.flatMap((g) => g.items);
|
||||
|
||||
/** Catégorie de promotion proposée par défaut selon le métier de l'adhérent. */
|
||||
const DEFAULT_BY_MEMBER_CATEGORY: Record<string, string> = {
|
||||
alimentation: "Épicerie & produits du terroir",
|
||||
"epicerie-fine": "Épicerie & produits du terroir",
|
||||
supermarche: "Épicerie & produits du terroir",
|
||||
boulangerie: "Pain & viennoiseries",
|
||||
"boucherie-charcuterie": "Viandes & charcuterie",
|
||||
poissonnerie: "Poissons & fruits de mer",
|
||||
primeur: "Fruits & légumes",
|
||||
fromagerie: "Fromages & produits laitiers",
|
||||
cave: "Vins, bières & spiritueux",
|
||||
"brasserie-artisanale": "Vins, bières & spiritueux",
|
||||
chocolaterie: "Chocolats & confiserie",
|
||||
"glacier-salon-de-the": "Brunch, goûter & glaces",
|
||||
traiteur: "Plats préparés & traiteur",
|
||||
restauration: "Menu & formule du midi",
|
||||
pizzeria: "Pizzas & restauration rapide",
|
||||
"restauration-rapide": "Pizzas & restauration rapide",
|
||||
"cafe-bar": "Apéritif & afterwork",
|
||||
"mode-beaute": "Vêtements femme",
|
||||
"mode-vetements": "Vêtements femme",
|
||||
lingerie: "Lingerie & maillots",
|
||||
"vetements-enfants": "Vêtements enfant",
|
||||
"chaussures-maroquinerie": "Chaussures",
|
||||
bijouterie: "Bijoux & montres",
|
||||
coiffure: "Coiffure",
|
||||
barbier: "Barbier",
|
||||
esthetique: "Soins visage & corps",
|
||||
onglerie: "Ongles & manucure",
|
||||
parfumerie: "Maquillage & parfums",
|
||||
"bien-etre": "Massage & spa",
|
||||
optique: "Lunettes & solaires",
|
||||
tatouage: "Tatouage & piercing",
|
||||
"bazar-discount": "Bazar & petit équipement",
|
||||
decoration: "Meubles & décoration",
|
||||
cuisiniste: "Cuisine & salle de bain",
|
||||
literie: "Literie",
|
||||
"arts-de-la-table": "Linge de maison & arts de la table",
|
||||
"electromenager-multimedia": "Électroménager",
|
||||
fleuriste: "Fleurs & plantes",
|
||||
jardinerie: "Jardin & extérieur",
|
||||
"bricolage-jardinage": "Bricolage & outillage",
|
||||
animalerie: "Animalerie & alimentation animale",
|
||||
artisanat: "Meubles & décoration",
|
||||
batiment: "Travaux & rénovation",
|
||||
architecte: "Travaux & rénovation",
|
||||
maconnerie: "Travaux & rénovation",
|
||||
"couverture-toiture": "Travaux & rénovation",
|
||||
plomberie: "Plomberie, chauffage & électricité",
|
||||
electricite: "Plomberie, chauffage & électricité",
|
||||
"isolation-energie": "Travaux & rénovation",
|
||||
menuiserie: "Travaux & rénovation",
|
||||
"serrurerie-metallerie": "Travaux & rénovation",
|
||||
"carrelage-sols": "Travaux & rénovation",
|
||||
peinture: "Travaux & rénovation",
|
||||
paysagiste: "Jardin & extérieur",
|
||||
"depannage-multiservices": "Travaux & rénovation",
|
||||
automobile: "Entretien & révision",
|
||||
carrosserie: "Carrosserie & pare-brise",
|
||||
"controle-technique": "Contrôle technique",
|
||||
"station-service-lavage": "Carburant & lavage",
|
||||
"location-vehicules": "Location de véhicule",
|
||||
"cycles-motos": "Vélos & motos",
|
||||
"auto-ecole": "Auto-école & permis",
|
||||
"taxi-vtc": "Déménagement & transport",
|
||||
sante: "Pharmacie & parapharmacie",
|
||||
medical: "Pharmacie & parapharmacie",
|
||||
audioprothese: "Optique & audition",
|
||||
veterinaire: "Vétérinaire & toilettage",
|
||||
"banque-assurance": "Banque, assurance & crédit",
|
||||
assurance: "Banque, assurance & crédit",
|
||||
"courtier-credit": "Banque, assurance & crédit",
|
||||
immobilier: "Immobilier",
|
||||
"diagnostic-immobilier": "Immobilier",
|
||||
services: "Autre",
|
||||
"services-a-la-personne": "Aide à domicile",
|
||||
"pressing-retouches": "Pressing, retouches & cordonnerie",
|
||||
photographe: "Photographe & vidéo",
|
||||
"pompes-funebres": "Autre",
|
||||
"agence-voyages": "Voyages & séjours",
|
||||
demenagement: "Déménagement & transport",
|
||||
comptabilite: "Comptabilité, juridique & conseil",
|
||||
juridique: "Comptabilité, juridique & conseil",
|
||||
conseil: "Comptabilité, juridique & conseil",
|
||||
communication: "Communication, web & impression",
|
||||
"imprimerie-enseignes": "Communication, web & impression",
|
||||
informatique: "Informatique & réparation",
|
||||
nettoyage: "Nettoyage & entretien",
|
||||
transport: "Déménagement & transport",
|
||||
securite: "Autre",
|
||||
coworking: "Autre",
|
||||
"interim-rh": "Autre",
|
||||
"recyclage-dechets": "Autre",
|
||||
"librairie-papeterie": "Livres, presse & papeterie",
|
||||
"tabac-presse": "Livres, presse & papeterie",
|
||||
"jouets-puericulture": "Jouets & jeux",
|
||||
"sport-loisirs": "Sport & fitness",
|
||||
"articles-de-sport": "Articles de sport",
|
||||
fitness: "Sport & fitness",
|
||||
"culture-arts": "Culture, arts & spectacles",
|
||||
"education-formation": "Cours & formation",
|
||||
"tourisme-hotellerie": "Hôtel & hébergement",
|
||||
evenementiel: "Événementiel & location de matériel",
|
||||
industrie: "Autre",
|
||||
agriculture: "Produits bio & locaux",
|
||||
"partenaire-institutionnel": "Autre",
|
||||
};
|
||||
|
||||
export function defaultPromoCategory(memberCategorySlug: string | null | undefined): string {
|
||||
const found = memberCategorySlug ? DEFAULT_BY_MEMBER_CATEGORY[memberCategorySlug] : undefined;
|
||||
return found && PROMO_CATEGORIES.includes(found) ? found : "Autre";
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Mise en ligne d'une promotion : diffusion sur les réseaux et libération des
|
||||
* publications programmées.
|
||||
*
|
||||
* Ce module vit hors de `backend/actions.ts` parce qu'il a deux appelants : la
|
||||
* validation par un modérateur (une requête HTTP) et le libérateur périodique
|
||||
* démarré par `instrumentation.ts` (aucune requête). Il ne fait donc jamais
|
||||
* appel à `revalidatePath()`, qui n'a pas de sens hors requête ; les pages
|
||||
* publiques sont en `force-dynamic` et relisent la base à chaque affichage.
|
||||
*/
|
||||
|
||||
import { and, eq, isNotNull, lte, sql } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { members, promotions, socialPosts } from "@/db/schema";
|
||||
import { logActivity } from "@/lib/activity-log";
|
||||
import {
|
||||
isNetworkConfigured,
|
||||
publishPromoToNetwork,
|
||||
SOCIAL_LABELS,
|
||||
SOCIAL_NETWORKS,
|
||||
type SocialNetwork,
|
||||
} from "@/lib/social";
|
||||
|
||||
/** Réseaux demandés pour une promo, dans l'ordre d'affichage. */
|
||||
export function requestedNetworks(promo: {
|
||||
shareFacebook: boolean;
|
||||
shareLinkedin: boolean;
|
||||
}): SocialNetwork[] {
|
||||
return SOCIAL_NETWORKS.filter((n) =>
|
||||
n === "facebook" ? promo.shareFacebook : promo.shareLinkedin
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Publie une promotion sur les réseaux demandés. Ne lève jamais : un réseau
|
||||
* indisponible ne doit pas faire échouer la mise en ligne, l'échec est
|
||||
* enregistré dans `social_posts` et rattrapable depuis le backoffice.
|
||||
*
|
||||
* Un réseau déjà publié avec succès est ignoré : c'est ce qui empêche toute
|
||||
* republication, y compris sur un cycle suspension → remise en ligne, et ce
|
||||
* qui rend le libérateur périodique sans danger s'il repasse sur une promo.
|
||||
*/
|
||||
export async function publishPromoShares(
|
||||
promoId: number,
|
||||
networks: SocialNetwork[],
|
||||
userId: number | null
|
||||
) {
|
||||
if (networks.length === 0) return;
|
||||
|
||||
const [promo] = await db
|
||||
.select({
|
||||
title: promotions.title,
|
||||
text: promotions.text,
|
||||
badge: promotions.badge,
|
||||
validUntil: promotions.validUntil,
|
||||
startsOn: promotions.startsOn,
|
||||
endsOn: promotions.endsOn,
|
||||
imageUrl: promotions.imageUrl,
|
||||
memberId: promotions.memberId,
|
||||
memberName: members.name,
|
||||
memberCity: members.city,
|
||||
})
|
||||
.from(promotions)
|
||||
.leftJoin(members, eq(promotions.memberId, members.id))
|
||||
.where(eq(promotions.id, promoId));
|
||||
if (!promo) return;
|
||||
|
||||
const done = await db
|
||||
.select({ network: socialPosts.network })
|
||||
.from(socialPosts)
|
||||
.where(and(eq(socialPosts.promotionId, promoId), eq(socialPosts.status, "posted")));
|
||||
const alreadyPosted = new Set(done.map((d) => d.network));
|
||||
|
||||
for (const network of networks) {
|
||||
if (alreadyPosted.has(network)) continue;
|
||||
|
||||
if (!(await isNetworkConfigured(network))) {
|
||||
await db.insert(socialPosts).values({
|
||||
promotionId: promoId,
|
||||
network,
|
||||
status: "failed",
|
||||
error: `${SOCIAL_LABELS[network]} n'est pas configuré sur ce serveur.`,
|
||||
postedById: userId,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await publishPromoToNetwork(network, promo);
|
||||
await db.insert(socialPosts).values({
|
||||
promotionId: promoId,
|
||||
network,
|
||||
status: "posted",
|
||||
externalId: result.externalId,
|
||||
url: result.url,
|
||||
postedById: userId,
|
||||
});
|
||||
await logActivity(
|
||||
`Promotion « ${promo.title} » publiée sur ${SOCIAL_LABELS[network]}`,
|
||||
"#2C6FB3"
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Erreur inconnue";
|
||||
await db.insert(socialPosts).values({
|
||||
promotionId: promoId,
|
||||
network,
|
||||
status: "failed",
|
||||
error: message.slice(0, 2000),
|
||||
postedById: userId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Met en ligne les promotions programmées dont l'échéance est passée.
|
||||
*
|
||||
* La bascule `scheduled → live` est faite par un `UPDATE … RETURNING` filtré
|
||||
* sur le statut : deux instances de l'application qui tourneraient en même
|
||||
* temps ne peuvent pas récupérer la même ligne, chacune n'obtient que celles
|
||||
* qu'elle a effectivement changées. La diffusion réseaux suit, protégée en
|
||||
* plus par la garde anti-republication de `publishPromoShares`.
|
||||
*
|
||||
* Ne lève jamais : le libérateur tourne en tâche de fond, une erreur ne doit
|
||||
* pas arrêter la boucle.
|
||||
*/
|
||||
export async function releaseDuePromotions(now = new Date()): Promise<number> {
|
||||
let released: { id: number; title: string; memberId: number | null; shareFacebook: boolean; shareLinkedin: boolean }[];
|
||||
try {
|
||||
released = await db
|
||||
.update(promotions)
|
||||
.set({ status: "live", publishAt: null })
|
||||
.where(
|
||||
and(
|
||||
eq(promotions.status, "scheduled"),
|
||||
isNotNull(promotions.publishAt),
|
||||
lte(promotions.publishAt, now)
|
||||
)
|
||||
)
|
||||
.returning({
|
||||
id: promotions.id,
|
||||
title: promotions.title,
|
||||
memberId: promotions.memberId,
|
||||
shareFacebook: promotions.shareFacebook,
|
||||
shareLinkedin: promotions.shareLinkedin,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[promotions] libération programmée impossible :", error);
|
||||
return 0;
|
||||
}
|
||||
|
||||
for (const promo of released) {
|
||||
try {
|
||||
await logActivity(
|
||||
`Promotion « ${promo.title} » publiée à l'heure programmée`,
|
||||
"#1f8a5b"
|
||||
);
|
||||
// `null` : la publication n'est déclenchée par personne, c'est l'échéance.
|
||||
await publishPromoShares(promo.id, requestedNetworks(promo), null);
|
||||
} catch (error) {
|
||||
console.error(`[promotions] publication programmée #${promo.id} :`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return released.length;
|
||||
}
|
||||
|
||||
/** Nombre de promotions encore en attente de leur date de publication. */
|
||||
export async function countScheduledPromotions(): Promise<number> {
|
||||
const [row] = await db
|
||||
.select({ n: sql<number>`count(*)::int` })
|
||||
.from(promotions)
|
||||
.where(eq(promotions.status, "scheduled"));
|
||||
return row?.n ?? 0;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Programmation de la publication d'une promotion.
|
||||
*
|
||||
* Une promotion validée peut soit partir tout de suite, soit attendre une
|
||||
* date choisie : c'est `promotions.publish_at`. Tant que cette date n'est pas
|
||||
* atteinte la promotion reste au statut `scheduled` — invisible sur le site
|
||||
* public (les lectures filtrent `status = 'live'`) et non diffusée sur les
|
||||
* réseaux. Le libérateur (`releaseDuePromotions`) fait la bascule.
|
||||
*
|
||||
* Ce module est **pur** : aucune base, aucun réseau. Il ne s'occupe que de la
|
||||
* conversion et du rendu des dates, et il est verrouillé par
|
||||
* `tests/promo-schedule.test.ts`.
|
||||
*
|
||||
* Le fuseau est celui de l'association, pas celui du serveur : un conteneur
|
||||
* qui tourne en UTC ne doit pas décaler d'une heure la publication demandée
|
||||
* par un adhérent lorrain.
|
||||
*/
|
||||
|
||||
export const ASSOCIATION_TIME_ZONE = "Europe/Paris";
|
||||
|
||||
const LOCAL_DATETIME = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::\d{2})?$/;
|
||||
|
||||
/**
|
||||
* Décalage du fuseau de l'association à un instant donné, en millisecondes.
|
||||
* Passe par `Intl` plutôt que par une constante : l'heure d'été change deux
|
||||
* fois par an et une valeur figée publierait à la mauvaise heure la moitié de
|
||||
* l'année.
|
||||
*/
|
||||
function zoneOffsetMs(instant: Date): number {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: ASSOCIATION_TIME_ZONE,
|
||||
hour12: false,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
}).formatToParts(instant);
|
||||
const get = (type: string) => Number(parts.find((p) => p.type === type)?.value ?? "0");
|
||||
// `hour` vaut 24 à minuit avec hour12:false sur certaines plateformes.
|
||||
const hour = get("hour") % 24;
|
||||
const asIfUtc = Date.UTC(get("year"), get("month") - 1, get("day"), hour, get("minute"), get("second"));
|
||||
return asIfUtc - instant.getTime();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit la saisie d'un `<input type="datetime-local">` (heure locale de
|
||||
* l'association) en instant absolu.
|
||||
*
|
||||
* Renvoie `null` si le champ est vide, et lève si la saisie n'a pas la forme
|
||||
* attendue : une date illisible ne doit pas se transformer silencieusement en
|
||||
* publication immédiate.
|
||||
*/
|
||||
export function parseScheduleInput(value: string | null | undefined): Date | null {
|
||||
const raw = (value ?? "").trim();
|
||||
if (!raw) return null;
|
||||
|
||||
const m = LOCAL_DATETIME.exec(raw);
|
||||
if (!m) throw new Error("Date de programmation invalide.");
|
||||
const [, y, mo, d, h, mi] = m.map(Number) as unknown as number[];
|
||||
|
||||
// `Date.UTC` accepte 13 comme mois ou 99 comme heure en débordant sur la
|
||||
// date suivante : on refuse plutôt que de publier à un moment inattendu.
|
||||
if (mo < 1 || mo > 12 || d < 1 || d > 31 || h > 23 || mi > 59) {
|
||||
throw new Error("Date de programmation invalide.");
|
||||
}
|
||||
|
||||
const naive = Date.UTC(y, mo - 1, d, h, mi);
|
||||
if (new Date(naive).getUTCDate() !== d) throw new Error("Date de programmation invalide.");
|
||||
// Deux passes : la première donne un décalage approché, la seconde le corrige
|
||||
// quand la date tombe juste après un changement d'heure.
|
||||
const first = new Date(naive - zoneOffsetMs(new Date(naive)));
|
||||
const exact = new Date(naive - zoneOffsetMs(first));
|
||||
if (Number.isNaN(exact.getTime())) throw new Error("Date de programmation invalide.");
|
||||
return exact;
|
||||
}
|
||||
|
||||
/** Valeur à remettre dans un `<input type="datetime-local">`. */
|
||||
export function toScheduleInput(date: Date | null | undefined): string {
|
||||
if (!date || Number.isNaN(date.getTime())) return "";
|
||||
const local = new Date(date.getTime() + zoneOffsetMs(date));
|
||||
return local.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
/** Une programmation passée n'a plus lieu d'être : on publie tout de suite. */
|
||||
export function isDue(publishAt: Date | null | undefined, now = new Date()): boolean {
|
||||
return !publishAt || publishAt.getTime() <= now.getTime();
|
||||
}
|
||||
|
||||
/** `true` si la promotion doit attendre. */
|
||||
export function isPending(publishAt: Date | null | undefined, now = new Date()): boolean {
|
||||
return !isDue(publishAt, now);
|
||||
}
|
||||
|
||||
/** « le 12 mars 2027 à 09:00 » — sans majuscule, à insérer dans une phrase. */
|
||||
export function formatSchedule(publishAt: Date | null | undefined): string | null {
|
||||
if (!publishAt || Number.isNaN(publishAt.getTime())) return null;
|
||||
const day = new Intl.DateTimeFormat("fr-FR", {
|
||||
timeZone: ASSOCIATION_TIME_ZONE,
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
}).format(publishAt);
|
||||
const time = new Intl.DateTimeFormat("fr-FR", {
|
||||
timeZone: ASSOCIATION_TIME_ZONE,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(publishAt);
|
||||
return `le ${day} à ${time}`;
|
||||
}
|
||||
|
||||
/** Phrase complète pour le backoffice. */
|
||||
export function scheduleLabel(publishAt: Date | null | undefined): string | null {
|
||||
const when = formatSchedule(publishAt);
|
||||
return when ? `Publication programmée ${when}` : null;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Période de validité d'une promotion.
|
||||
*
|
||||
* Module **pur** : aucune dépendance à la base ni à React, pour être verrouillé
|
||||
* par les tests. Les dates arrivent en `YYYY-MM-DD` (colonnes `date` de
|
||||
* Postgres, lues telles quelles par Drizzle) et sont donc traitées comme des
|
||||
* jours calendaires, sans fuseau horaire.
|
||||
*/
|
||||
|
||||
export type PromoValidity = {
|
||||
startsOn?: string | null;
|
||||
endsOn?: string | null;
|
||||
/** Texte libre des anciennes promotions, utilisé à défaut de dates. */
|
||||
validUntil?: string | null;
|
||||
};
|
||||
|
||||
const MONTHS = [
|
||||
"janvier", "février", "mars", "avril", "mai", "juin",
|
||||
"juillet", "août", "septembre", "octobre", "novembre", "décembre",
|
||||
];
|
||||
|
||||
type Parts = { day: number; month: number; year: number };
|
||||
|
||||
function parse(value: string | null | undefined): Parts | null {
|
||||
if (!value) return null;
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(value.trim());
|
||||
if (!match) return null;
|
||||
const [, year, month, day] = match;
|
||||
const parts = { day: Number(day), month: Number(month) - 1, year: Number(year) };
|
||||
if (parts.month < 0 || parts.month > 11 || parts.day < 1 || parts.day > 31) return null;
|
||||
return parts;
|
||||
}
|
||||
|
||||
/** « 1er » et non « 1 », comme on l'écrit en français. */
|
||||
function dayLabel(day: number): string {
|
||||
return day === 1 ? "1er" : String(day);
|
||||
}
|
||||
|
||||
function full(p: Parts): string {
|
||||
return `${dayLabel(p.day)} ${MONTHS[p.month]} ${p.year}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Phrase de validité, ou `null` si la promotion n'en a pas.
|
||||
* L'année et le mois ne sont répétés que lorsqu'ils changent : « du 1er au
|
||||
* 15 mars 2027 » plutôt que « du 1er mars 2027 au 15 mars 2027 ».
|
||||
*/
|
||||
export function formatValidity(promo: PromoValidity): string | null {
|
||||
const start = parse(promo.startsOn);
|
||||
const end = parse(promo.endsOn);
|
||||
|
||||
if (start && end) {
|
||||
const sameYear = start.year === end.year;
|
||||
const sameMonth = sameYear && start.month === end.month;
|
||||
const from = sameMonth
|
||||
? dayLabel(start.day)
|
||||
: sameYear
|
||||
? `${dayLabel(start.day)} ${MONTHS[start.month]}`
|
||||
: full(start);
|
||||
return `Valable du ${from} au ${full(end)}`;
|
||||
}
|
||||
if (end) return `Valable jusqu'au ${full(end)}`;
|
||||
if (start) return `Valable à partir du ${full(start)}`;
|
||||
|
||||
const legacy = promo.validUntil?.trim();
|
||||
return legacy || null;
|
||||
}
|
||||
|
||||
/** Variante sans le mot « Valable », pour les libellés déjà introduits. */
|
||||
export function formatValidityShort(promo: PromoValidity): string | null {
|
||||
const formatted = formatValidity(promo);
|
||||
if (!formatted) return null;
|
||||
return formatted.replace(/^Valable /, "");
|
||||
}
|
||||
|
||||
/** `true` si la date de fin est dépassée. Un jour de fin reste valable en entier. */
|
||||
export function isExpired(promo: PromoValidity, today = new Date()): boolean {
|
||||
const end = parse(promo.endsOn);
|
||||
if (!end) return false;
|
||||
const endOfDay = Date.UTC(end.year, end.month, end.day, 23, 59, 59);
|
||||
return today.getTime() > endOfDay;
|
||||
}
|
||||
|
||||
/** Bornes invalides : fin antérieure au début. */
|
||||
export function isRangeInvalid(promo: PromoValidity): boolean {
|
||||
const start = parse(promo.startsOn);
|
||||
const end = parse(promo.endsOn);
|
||||
if (!start || !end) return false;
|
||||
return Date.UTC(end.year, end.month, end.day) < Date.UTC(start.year, start.month, start.day);
|
||||
}
|
||||
|
||||
/** `true` si la période n'a pas encore commencé. */
|
||||
export function isNotStarted(promo: PromoValidity, today = new Date()): boolean {
|
||||
const start = parse(promo.startsOn);
|
||||
if (!start) return false;
|
||||
return today.getTime() < Date.UTC(start.year, start.month, start.day);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pourquoi une promotion validée n'apparaît pas sur le site aujourd'hui.
|
||||
*
|
||||
* Les lectures publiques (`src/lib/queries.ts`) écartent les offres hors
|
||||
* période ; ce message l'explique côté backoffice, pour qu'un adhérent ne
|
||||
* cherche pas une offre « en ligne » qui n'est pas encore, ou plus, affichée.
|
||||
* Renvoie `null` quand la promotion est bien visible.
|
||||
*/
|
||||
export function visibilityNote(promo: PromoValidity, today = new Date()): string | null {
|
||||
const start = parse(promo.startsOn);
|
||||
const end = parse(promo.endsOn);
|
||||
if (start && isNotStarted(promo, today)) {
|
||||
return `Pas encore affichée : elle apparaîtra sur le site le ${full(start)}.`;
|
||||
}
|
||||
if (end && isExpired(promo, today)) {
|
||||
return `Période terminée le ${full(end)} : elle n'apparaît plus sur le site.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
+95
-8
@@ -1,7 +1,22 @@
|
||||
import { and, asc, count, desc, eq } from "drizzle-orm";
|
||||
import { and, asc, count, desc, eq, sql } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { categories, members, promotions } from "@/db/schema";
|
||||
|
||||
/**
|
||||
* Promotion réellement visible par le public : validée **et** dans sa période
|
||||
* de validité.
|
||||
*
|
||||
* Les deux bornes sont facultatives — une offre sans date reste affichée sans
|
||||
* limite. La journée de référence est celle de l'association, pas celle du
|
||||
* serveur : un conteneur en UTC retirerait sinon une offre « valable jusqu'au
|
||||
* 30 juin » deux heures trop tôt.
|
||||
*/
|
||||
const VISIBLE_PROMO = and(
|
||||
eq(promotions.status, "live"),
|
||||
sql`(${promotions.startsOn} is null or ${promotions.startsOn} <= (current_timestamp at time zone 'Europe/Paris')::date)`,
|
||||
sql`(${promotions.endsOn} is null or ${promotions.endsOn} >= (current_timestamp at time zone 'Europe/Paris')::date)`
|
||||
);
|
||||
|
||||
export async function getActiveMembersWithCategory() {
|
||||
return db
|
||||
.select({
|
||||
@@ -32,7 +47,7 @@ export async function getLiveBadgesByMember() {
|
||||
return db
|
||||
.select({ memberId: promotions.memberId, badge: promotions.badge })
|
||||
.from(promotions)
|
||||
.where(eq(promotions.status, "live"));
|
||||
.where(VISIBLE_PROMO);
|
||||
}
|
||||
|
||||
export async function getCategoriesWithCounts(limit = 6) {
|
||||
@@ -57,8 +72,14 @@ export async function getCategoriesWithCounts(limit = 6) {
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function getLivePromotions(limit = 6) {
|
||||
const rows = await db
|
||||
/**
|
||||
* Promotions visibles, de la plus récente à la plus ancienne.
|
||||
*
|
||||
* `limit` à `null` : toutes les offres en cours (page `/promotions`) ; sinon
|
||||
* les N dernières (accueil).
|
||||
*/
|
||||
export async function getLivePromotions(limit: number | null = 6) {
|
||||
const query = db
|
||||
.select({
|
||||
id: promotions.id,
|
||||
title: promotions.title,
|
||||
@@ -67,15 +88,21 @@ export async function getLivePromotions(limit = 6) {
|
||||
badge: promotions.badge,
|
||||
imageUrl: promotions.imageUrl,
|
||||
validUntil: promotions.validUntil,
|
||||
startsOn: promotions.startsOn,
|
||||
endsOn: promotions.endsOn,
|
||||
memberId: promotions.memberId,
|
||||
memberName: members.name,
|
||||
memberCity: members.city,
|
||||
memberLogoUrl: members.logoUrl,
|
||||
})
|
||||
.from(promotions)
|
||||
.leftJoin(members, eq(promotions.memberId, members.id))
|
||||
.where(eq(promotions.status, "live"))
|
||||
.where(VISIBLE_PROMO)
|
||||
.orderBy(desc(promotions.createdAt))
|
||||
.limit(limit);
|
||||
return rows;
|
||||
.$dynamic();
|
||||
// La limite reste dans le SQL : la page « toutes les promos » est la seule à
|
||||
// tout demander, l'accueil ne rapatrie que ses six cartes.
|
||||
return limit === null ? query : query.limit(limit);
|
||||
}
|
||||
|
||||
export async function getPublicMember(id: number) {
|
||||
@@ -84,6 +111,7 @@ export async function getPublicMember(id: number) {
|
||||
id: members.id,
|
||||
name: members.name,
|
||||
email: members.email,
|
||||
contactEmail: members.contactEmail,
|
||||
description: members.description,
|
||||
city: members.city,
|
||||
address: members.address,
|
||||
@@ -97,6 +125,7 @@ export async function getPublicMember(id: number) {
|
||||
hours: members.hours,
|
||||
tags: members.tags,
|
||||
categoryLabel: categories.label,
|
||||
categorySlug: categories.slug,
|
||||
accent: categories.accent,
|
||||
tint: categories.tint,
|
||||
})
|
||||
@@ -116,9 +145,11 @@ export async function getMemberLivePromotions(memberId: number) {
|
||||
badge: promotions.badge,
|
||||
imageUrl: promotions.imageUrl,
|
||||
validUntil: promotions.validUntil,
|
||||
startsOn: promotions.startsOn,
|
||||
endsOn: promotions.endsOn,
|
||||
})
|
||||
.from(promotions)
|
||||
.where(and(eq(promotions.memberId, memberId), eq(promotions.status, "live")))
|
||||
.where(and(eq(promotions.memberId, memberId), VISIBLE_PROMO))
|
||||
.orderBy(desc(promotions.createdAt));
|
||||
}
|
||||
|
||||
@@ -165,3 +196,59 @@ export async function getHighlightedMembers(limit = 3) {
|
||||
.limit(limit);
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Nombre d'adhérents validés (statut `active`) : affiché sur l'accueil. */
|
||||
export async function getActiveMemberCount(): Promise<number> {
|
||||
const [row] = await db
|
||||
.select({ total: count(members.id) })
|
||||
.from(members)
|
||||
.where(eq(members.status, "active"));
|
||||
return Number(row?.total ?? 0);
|
||||
}
|
||||
|
||||
/** Une catégorie par son slug d'URL, ou `null`. */
|
||||
export async function getCategoryBySlug(slug: string) {
|
||||
const [row] = await db
|
||||
.select({
|
||||
id: categories.id,
|
||||
slug: categories.slug,
|
||||
label: categories.label,
|
||||
accent: categories.accent,
|
||||
tint: categories.tint,
|
||||
})
|
||||
.from(categories)
|
||||
.where(eq(categories.slug, slug));
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
/** Adhérents actifs d'une catégorie, pour la page métier de l'annuaire. */
|
||||
export async function getActiveMembersByCategory(categoryId: number) {
|
||||
return db
|
||||
.select({
|
||||
id: members.id,
|
||||
name: members.name,
|
||||
description: members.description,
|
||||
city: members.city,
|
||||
address: members.address,
|
||||
coverUrl: members.coverUrl,
|
||||
logoUrl: members.logoUrl,
|
||||
categoryLabel: categories.label,
|
||||
accent: categories.accent,
|
||||
})
|
||||
.from(members)
|
||||
.leftJoin(categories, eq(members.categoryId, categories.id))
|
||||
.where(and(eq(members.status, "active"), eq(members.categoryId, categoryId)))
|
||||
.orderBy(asc(members.name));
|
||||
}
|
||||
|
||||
/** Catégories ayant au moins un adhérent actif, avec leur effectif. */
|
||||
export async function getCategoriesInUse() {
|
||||
const rows = await getCategoriesWithCounts(1000);
|
||||
return rows.filter((c) => Number(c.memberCount) > 0);
|
||||
}
|
||||
|
||||
/** Promotions réellement affichées aujourd'hui — le chiffre du tableau de bord. */
|
||||
export async function countVisiblePromotions(): Promise<number> {
|
||||
const [row] = await db.select({ n: count() }).from(promotions).where(VISIBLE_PROMO);
|
||||
return row?.n ?? 0;
|
||||
}
|
||||
+4
-19
@@ -1,28 +1,13 @@
|
||||
import { headers } from "next/headers";
|
||||
import { siteUrl } from "@/lib/social-accounts";
|
||||
import { publicBaseUrl } from "@/lib/social-accounts";
|
||||
|
||||
/**
|
||||
* URL publique du site pour le référencement (canoniques, sitemap, JSON-LD).
|
||||
*
|
||||
* Priorité au réglage `site_public_url` (Backend › Réseaux sociaux) puis aux
|
||||
* variables d'environnement, via `siteUrl()`. En dernier recours, l'URL est
|
||||
* déduite des en-têtes de la requête (reverse proxy compris) : le site reste
|
||||
* ainsi correctement référencé tant que le réglage n'est pas renseigné.
|
||||
* variables d'environnement ; en dernier recours, l'URL est déduite des
|
||||
* en-têtes de la requête (reverse proxy compris). Voir `publicBaseUrl()`.
|
||||
*/
|
||||
export async function publicBaseUrl(): Promise<string> {
|
||||
const configured = await siteUrl().catch(() => "");
|
||||
if (configured) return configured;
|
||||
try {
|
||||
const h = await headers();
|
||||
const host = h.get("x-forwarded-host") ?? h.get("host") ?? "";
|
||||
if (!host) return "";
|
||||
const protocol = h.get("x-forwarded-proto") ?? (host.startsWith("localhost") ? "http" : "https");
|
||||
return `${protocol}://${host}`.replace(/\/+$/, "");
|
||||
} catch {
|
||||
// Hors requête (génération de robots/sitemap au build par exemple).
|
||||
return "";
|
||||
}
|
||||
}
|
||||
export { publicBaseUrl };
|
||||
|
||||
/** `metadataBase` pour Next : indéfini si aucune URL n'est connue. */
|
||||
export async function metadataBase(): Promise<URL | undefined> {
|
||||
|
||||
+31
-6
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { DaySchedule, WeekDayKey } from "@/lib/member-profile";
|
||||
import type { SiteSettings, SocialLink } from "@/lib/site-settings";
|
||||
import { slugify } from "@/lib/slug";
|
||||
|
||||
/**
|
||||
* Référencement : constantes, métadonnées et données structurées (JSON-LD).
|
||||
@@ -70,6 +71,7 @@ export const PRIVATE_PATHS = ["/backend", "/api", "/login", "/inscription"];
|
||||
export const STATIC_ROUTES: { path: string; priority: number; changeFrequency: "daily" | "weekly" | "monthly" | "yearly" }[] = [
|
||||
{ path: "/", priority: 1, changeFrequency: "daily" },
|
||||
{ path: "/annuaire", priority: 0.9, changeFrequency: "daily" },
|
||||
{ path: "/promotions", priority: 0.9, changeFrequency: "daily" },
|
||||
{ path: "/association", priority: 0.8, changeFrequency: "weekly" },
|
||||
{ path: "/rencontres-passees", priority: 0.6, changeFrequency: "monthly" },
|
||||
{ path: "/mentions-legales", priority: 0.2, changeFrequency: "yearly" },
|
||||
@@ -83,9 +85,32 @@ export function absoluteUrl(baseUrl: string, path = "/"): string {
|
||||
return `${base}${path.startsWith("/") ? path : `/${path}`}`;
|
||||
}
|
||||
|
||||
/** Chemin canonique d'une fiche adhérent. */
|
||||
export function memberPath(id: number): string {
|
||||
return `/adherents/${id}`;
|
||||
export type MemberPathInput = { id: number; name: string; city?: string | null };
|
||||
|
||||
/**
|
||||
* Chemin canonique d'une fiche adhérent : `/adherents/12-au-bon-pain-frouard`.
|
||||
*
|
||||
* L'identifiant reste en tête : il suffit à retrouver la fiche, et le nom qui
|
||||
* suit n'est là que pour les moteurs et les lecteurs. Toute autre écriture
|
||||
* (`/adherents/12`, ancien nom…) est redirigée en 301 vers cette forme par la
|
||||
* page, donc un renommage ne casse aucun lien.
|
||||
*/
|
||||
export function memberPath(member: MemberPathInput): string {
|
||||
const slug = slugify([member.name, member.city ?? ""].filter(Boolean).join(" "));
|
||||
return slug ? `/adherents/${member.id}-${slug}` : `/adherents/${member.id}`;
|
||||
}
|
||||
|
||||
/** Identifiant numérique en tête du paramètre d'URL (`12-au-bon-pain` → 12), sinon `null`. */
|
||||
export function parseMemberParam(param: string): number | null {
|
||||
const match = /^(\d+)(?:-|$)/.exec(param);
|
||||
if (!match) return null;
|
||||
const id = Number(match[1]);
|
||||
return Number.isSafeInteger(id) && id > 0 ? id : null;
|
||||
}
|
||||
|
||||
/** Chemin d'une page métier de l'annuaire. */
|
||||
export function categoryPath(slug: string): string {
|
||||
return `/annuaire/${slug}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -259,7 +284,7 @@ export function localBusinessJsonLd(
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const url = absoluteUrl(baseUrl, memberPath(member.id));
|
||||
const url = absoluteUrl(baseUrl, memberPath(member));
|
||||
return compact({
|
||||
"@context": "https://schema.org",
|
||||
"@type": "LocalBusiness",
|
||||
@@ -325,7 +350,7 @@ export function eventJsonLd(baseUrl: string, meeting: EventInput) {
|
||||
});
|
||||
}
|
||||
|
||||
export type ItemListEntry = { id: number; name: string; description?: string | null };
|
||||
export type ItemListEntry = MemberPathInput;
|
||||
|
||||
/** Liste d'adhérents (annuaire) : chaque entrée pointe vers sa fiche. */
|
||||
export function memberListJsonLd(baseUrl: string, name: string, members: ItemListEntry[]) {
|
||||
@@ -337,7 +362,7 @@ export function memberListJsonLd(baseUrl: string, name: string, members: ItemLis
|
||||
itemListElement: members.map((m, index) => ({
|
||||
"@type": "ListItem",
|
||||
position: index + 1,
|
||||
url: absoluteUrl(baseUrl, memberPath(m.id)),
|
||||
url: absoluteUrl(baseUrl, memberPath(m)),
|
||||
name: m.name,
|
||||
})),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Déduction de l'origine publique du site à partir des en-têtes d'une requête.
|
||||
*
|
||||
* Sans dépendance à la base ni à Next : utilisable partout et testable. Sert de
|
||||
* repli quand aucune URL publique n'est enregistrée dans Backend › Réseaux
|
||||
* sociaux : derrière Caddy ou Nginx Proxy Manager, `X-Forwarded-Host` et
|
||||
* `X-Forwarded-Proto` décrivent l'adresse vue par le visiteur.
|
||||
*/
|
||||
|
||||
type HeaderReader = { get(name: string): string | null };
|
||||
|
||||
const LOCAL_HOSTS = /^(localhost|.*\.localhost|127(?:\.\d{1,3}){3}|0\.0\.0\.0|\[::1\]|\[::\])$/i;
|
||||
const IPV4 = /^\d{1,3}(?:\.\d{1,3}){3}$/;
|
||||
|
||||
function first(value: string | null): string {
|
||||
return (value ?? "").split(",")[0]?.trim() ?? "";
|
||||
}
|
||||
|
||||
/** Nom d'hôte sans port, en minuscules. */
|
||||
function hostname(host: string): string {
|
||||
const match = /^(\[[^\]]*\]|[^:]+)/.exec(host);
|
||||
return (match?.[1] ?? host).toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sans `X-Forwarded-Proto`, on présume HTTPS pour un nom de domaine — c'est le
|
||||
* cas de tout déploiement réel — et HTTP pour une adresse locale ou une IP nue,
|
||||
* qu'aucun certificat ne couvre.
|
||||
*/
|
||||
export function defaultProtocol(host: string): "http" | "https" {
|
||||
const name = hostname(host);
|
||||
if (!name || LOCAL_HOSTS.test(name) || IPV4.test(name)) return "http";
|
||||
return "https";
|
||||
}
|
||||
|
||||
/**
|
||||
* Origine (`https://pleinr.example.fr`) vue par le visiteur, ou chaîne vide si
|
||||
* la requête ne porte aucun hôte.
|
||||
*/
|
||||
export function originFromHeaders(headers: HeaderReader): string {
|
||||
const host = first(headers.get("x-forwarded-host")) || first(headers.get("host"));
|
||||
if (!host || /[\s/\\]/.test(host)) return "";
|
||||
const forwarded = first(headers.get("x-forwarded-proto")).toLowerCase();
|
||||
const protocol = forwarded === "https" || forwarded === "http" ? forwarded : defaultProtocol(host);
|
||||
try {
|
||||
return new URL(`${protocol}://${host}`).origin;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/** Slug ASCII minuscule : accents retirés, tout le reste devient un tiret. */
|
||||
export function slugify(input: string, maxLength = 70): string {
|
||||
return input
|
||||
.normalize("NFD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, maxLength)
|
||||
.replace(/-+$/g, "");
|
||||
}
|
||||
+110
-1
@@ -1,7 +1,9 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { headers } from "next/headers";
|
||||
import { db } from "@/db";
|
||||
import { siteSettings, socialAccounts, type SocialAccount, type SocialNetwork } from "@/db/schema";
|
||||
import { decryptSecret, encryptSecret } from "./crypto";
|
||||
import { originFromHeaders } from "./site-url";
|
||||
|
||||
/**
|
||||
* Configuration des comptes Facebook / LinkedIn.
|
||||
@@ -43,8 +45,29 @@ export async function siteUrl(): Promise<string> {
|
||||
return (stored || env("NEXT_PUBLIC_SITE_URL") || env("AUTH_URL")).replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* URL publique effective : le réglage s'il existe, sinon l'adresse par laquelle
|
||||
* la requête en cours est arrivée (en-têtes du reverse proxy compris). Chaîne
|
||||
* vide hors requête si rien n'est configuré.
|
||||
*/
|
||||
export async function publicBaseUrl(): Promise<string> {
|
||||
const configured = await siteUrl().catch(() => "");
|
||||
if (configured) return configured;
|
||||
try {
|
||||
return originFromHeaders(await headers());
|
||||
} catch {
|
||||
// Hors requête (génération de robots/sitemap au build par exemple).
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adresse de retour OAuth. Elle doit être identique au clic sur « Connecter »
|
||||
* et au retour du réseau : les deux passent par le même hôte, la déduction
|
||||
* depuis la requête est donc stable.
|
||||
*/
|
||||
export async function redirectUri(network: SocialNetwork): Promise<string> {
|
||||
return `${await siteUrl()}/api/social/${network}/callback`;
|
||||
return `${await publicBaseUrl()}/api/social/${network}/callback`;
|
||||
}
|
||||
|
||||
// ---- Lecture ----
|
||||
@@ -118,6 +141,92 @@ export function expiryStatus(expiresAt: Date | null | undefined): ExpiryStatus {
|
||||
return remainingMs <= EXPIRY_WARNING_DAYS * 86_400_000 ? "soon" : "ok";
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie que le jeton fonctionne encore, en interrogeant la plateforme.
|
||||
*
|
||||
* « N'expire pas » ne veut pas dire « ne meurt jamais » : un jeton de page
|
||||
* Facebook est invalidé si le mot de passe du compte connecté change, si ce
|
||||
* compte perd son rôle d'administrateur de la page, si l'autorisation est
|
||||
* retirée, ou si Meta le révoque. Sans cette vérification, on ne l'apprend
|
||||
* qu'au moment où une publication échoue.
|
||||
*/
|
||||
export type TokenHealth =
|
||||
| { ok: true; detail: string }
|
||||
| { ok: false; reason: string };
|
||||
|
||||
export async function checkTokenHealth(network: SocialNetwork): Promise<TokenHealth | null> {
|
||||
const health = await probeToken(network);
|
||||
if (health) await rememberCheck(network, health);
|
||||
return health;
|
||||
}
|
||||
|
||||
/** Contrôle mis en cache : évite un appel réseau à chaque affichage. */
|
||||
export async function tokenHealthCached(
|
||||
network: SocialNetwork,
|
||||
maxAgeMs = 6 * 3_600_000
|
||||
): Promise<TokenHealth | null> {
|
||||
const account = await getSocialAccount(network);
|
||||
if (!account?.accessToken || !account.targetId) return null;
|
||||
|
||||
const fresh =
|
||||
account.lastCheckAt && Date.now() - new Date(account.lastCheckAt).getTime() < maxAgeMs;
|
||||
if (fresh && account.lastCheckOk !== null) {
|
||||
return account.lastCheckOk
|
||||
? { ok: true, detail: "Vérifié récemment" }
|
||||
: { ok: false, reason: account.lastCheckError ?? "Jeton refusé" };
|
||||
}
|
||||
return checkTokenHealth(network);
|
||||
}
|
||||
|
||||
async function rememberCheck(network: SocialNetwork, health: TokenHealth) {
|
||||
await db
|
||||
.update(socialAccounts)
|
||||
.set({
|
||||
lastCheckAt: new Date(),
|
||||
lastCheckOk: health.ok,
|
||||
lastCheckError: health.ok ? null : health.reason.slice(0, 2000),
|
||||
})
|
||||
.where(eq(socialAccounts.network, network));
|
||||
}
|
||||
|
||||
async function probeToken(network: SocialNetwork): Promise<TokenHealth | null> {
|
||||
const credentials = await resolveCredentials(network);
|
||||
if (!credentials) return null;
|
||||
|
||||
try {
|
||||
if (network === "facebook") {
|
||||
// Un jeton de page répond `/me` avec la page elle-même.
|
||||
const res = await fetch(
|
||||
`https://graph.facebook.com/${FACEBOOK_GRAPH_VERSION}/me?${new URLSearchParams({
|
||||
fields: "id,name",
|
||||
access_token: credentials.accessToken,
|
||||
})}`
|
||||
);
|
||||
if (!res.ok) return { ok: false, reason: await readError(res) };
|
||||
const page = (await res.json()) as { id?: string; name?: string };
|
||||
if (page.id && credentials.targetId && page.id !== credentials.targetId) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `Le jeton ne pointe plus vers la bonne page (${page.name ?? page.id}).`,
|
||||
};
|
||||
}
|
||||
return { ok: true, detail: page.name ?? "Page accessible" };
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
"https://api.linkedin.com/rest/organizationAcls?q=roleAssignee&role=ADMINISTRATOR&state=APPROVED&count=1",
|
||||
{ headers: linkedinHeaders(credentials.accessToken) }
|
||||
);
|
||||
if (!res.ok) return { ok: false, reason: await readError(res) };
|
||||
return { ok: true, detail: "Accès à la page confirmé" };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: error instanceof Error ? error.message : "Plateforme injoignable",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ---- OAuth ----
|
||||
|
||||
export class SocialAuthError extends Error {}
|
||||
|
||||
+13
-4
@@ -12,9 +12,11 @@
|
||||
import {
|
||||
refreshLinkedInIfNeeded,
|
||||
resolveCredentials,
|
||||
siteUrl,
|
||||
publicBaseUrl,
|
||||
type SocialNetwork,
|
||||
} from "./social-accounts";
|
||||
import { formatValidity } from "./promo-validity";
|
||||
import { memberPath } from "./seo";
|
||||
|
||||
export {
|
||||
configuredNetworks,
|
||||
@@ -34,15 +36,20 @@ export type PromoForSharing = {
|
||||
text: string | null;
|
||||
badge: string | null;
|
||||
validUntil: string | null;
|
||||
startsOn?: string | null;
|
||||
endsOn?: string | null;
|
||||
imageUrl: string | null;
|
||||
memberId: number | null;
|
||||
memberName: string | null;
|
||||
memberCity?: string | null;
|
||||
};
|
||||
|
||||
export async function promoLink(promo: PromoForSharing): Promise<string | null> {
|
||||
const base = await siteUrl();
|
||||
const base = await publicBaseUrl();
|
||||
if (!base) return null;
|
||||
return promo.memberId ? `${base}/adherents/${promo.memberId}` : `${base}/#promotions`;
|
||||
return promo.memberId
|
||||
? `${base}${memberPath({ id: promo.memberId, name: promo.memberName ?? "", city: promo.memberCity })}`
|
||||
: `${base}/promotions`;
|
||||
}
|
||||
|
||||
/** Texte du post, commun aux deux réseaux. */
|
||||
@@ -51,7 +58,9 @@ export async function buildPromoMessage(promo: PromoForSharing): Promise<string>
|
||||
lines.push(promo.badge ? `${promo.badge} — ${promo.title}` : promo.title);
|
||||
if (promo.memberName) lines.push(`Chez ${promo.memberName}`);
|
||||
if (promo.text?.trim()) lines.push("", promo.text.trim());
|
||||
if (promo.validUntil?.trim()) lines.push("", promo.validUntil.trim());
|
||||
// « Valable du … au … » : la même phrase que sur la carte du site.
|
||||
const validity = formatValidity(promo);
|
||||
if (validity) lines.push("", validity);
|
||||
const link = await promoLink(promo);
|
||||
if (link) lines.push("", link);
|
||||
lines.push("", "#PleinR #BassinDePompey #CommerceLocal");
|
||||
|
||||
+244
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* Tags automatiques des fiches adhérents.
|
||||
*
|
||||
* Module **pur** (aucun accès base, utilisable côté client comme côté serveur) :
|
||||
* - un vocabulaire de mots-clés par métier (slug de catégorie) ;
|
||||
* - un vocabulaire transversal détecté dans la description (fait maison, bio,
|
||||
* livraison, sur rendez-vous…) ;
|
||||
* - `suggestTags()` qui combine métier, commune et description ;
|
||||
* - `autoTags()` qui n'intervient que si l'adhérent n'a rien saisi.
|
||||
*
|
||||
* Les tags s'affichent sur la fiche publique et partent dans les données
|
||||
* structurées (`LocalBusiness.keywords`) : ce sont des mots que les clients
|
||||
* tapent dans Google, pas des étiquettes internes.
|
||||
*/
|
||||
|
||||
export const MAX_TAGS = 10;
|
||||
|
||||
/** Mots-clés par métier. Les slugs sont ceux de `src/db/categories.ts`. */
|
||||
export const CATEGORY_TAGS: Record<string, string[]> = {
|
||||
// Bouche & alimentation
|
||||
alimentation: ["Épicerie", "Produits frais", "Produits locaux", "Dépannage"],
|
||||
"epicerie-fine": ["Épicerie fine", "Produits du terroir", "Cadeaux gourmands", "Vins"],
|
||||
supermarche: ["Supermarché", "Courses", "Drive", "Produits frais"],
|
||||
boulangerie: ["Boulangerie", "Pain", "Viennoiseries", "Pâtisserie", "Sandwichs"],
|
||||
"boucherie-charcuterie": ["Boucherie", "Charcuterie", "Viande locale", "Plats cuisinés"],
|
||||
poissonnerie: ["Poissonnerie", "Poisson frais", "Fruits de mer", "Plateaux"],
|
||||
primeur: ["Primeur", "Fruits et légumes", "Produits de saison", "Producteurs locaux"],
|
||||
fromagerie: ["Fromagerie", "Fromages", "Crèmerie", "Plateaux de fromages"],
|
||||
cave: ["Cave à vins", "Vins", "Spiritueux", "Bières", "Conseils"],
|
||||
"brasserie-artisanale": ["Brasserie artisanale", "Bières locales", "Boissons", "Coffrets"],
|
||||
chocolaterie: ["Chocolaterie", "Chocolats", "Confiserie", "Cadeaux"],
|
||||
"glacier-salon-de-the": ["Glacier", "Salon de thé", "Glaces artisanales", "Goûter"],
|
||||
traiteur: ["Traiteur", "Plats à emporter", "Buffets", "Événements", "Repas d'entreprise"],
|
||||
restauration: ["Restaurant", "Cuisine maison", "Menu du jour", "Repas de groupe"],
|
||||
pizzeria: ["Pizzeria", "Pizzas", "À emporter", "Livraison"],
|
||||
"restauration-rapide": ["Restauration rapide", "Snack", "Burgers", "À emporter", "Food truck"],
|
||||
"cafe-bar": ["Café", "Bar", "Brasserie", "Terrasse", "Afterwork"],
|
||||
// Mode, beauté & soin
|
||||
"mode-beaute": ["Mode", "Beauté", "Prêt-à-porter", "Accessoires"],
|
||||
"mode-vetements": ["Vêtements", "Prêt-à-porter", "Mode femme", "Mode homme", "Accessoires"],
|
||||
lingerie: ["Lingerie", "Sous-vêtements", "Maillots de bain", "Conseil"],
|
||||
"vetements-enfants": ["Vêtements enfants", "Mode enfant", "Puériculture", "Cadeaux naissance"],
|
||||
"chaussures-maroquinerie": ["Chaussures", "Maroquinerie", "Sacs", "Cordonnerie"],
|
||||
bijouterie: ["Bijouterie", "Bijoux", "Montres", "Réparation", "Gravure"],
|
||||
coiffure: ["Coiffeur", "Coupe", "Couleur", "Brushing", "Coiffure mariage"],
|
||||
barbier: ["Barbier", "Barbe", "Coupe homme", "Rasage"],
|
||||
esthetique: ["Institut de beauté", "Soins du visage", "Épilation", "Manucure", "Maquillage"],
|
||||
onglerie: ["Onglerie", "Pose d'ongles", "Manucure", "Nail art"],
|
||||
parfumerie: ["Parfumerie", "Parfums", "Cosmétiques", "Coffrets cadeaux"],
|
||||
"bien-etre": ["Bien-être", "Massage", "Spa", "Relaxation", "Soins"],
|
||||
optique: ["Opticien", "Lunettes", "Lentilles", "Examen de vue", "Lunettes de soleil"],
|
||||
tatouage: ["Tatoueur", "Tatouage", "Piercing", "Sur rendez-vous"],
|
||||
// Maison, équipement & décoration
|
||||
"bazar-discount": ["Bazar", "Discount", "Équipement de la maison", "Décoration", "Jouets", "Petits prix"],
|
||||
decoration: ["Décoration", "Meubles", "Ameublement", "Luminaires", "Idées cadeaux"],
|
||||
cuisiniste: ["Cuisiniste", "Cuisine équipée", "Salle de bain", "Dressing", "Sur mesure"],
|
||||
literie: ["Literie", "Matelas", "Sommiers", "Linge de lit"],
|
||||
"arts-de-la-table": ["Arts de la table", "Vaisselle", "Cadeaux", "Liste de mariage"],
|
||||
"electromenager-multimedia": ["Électroménager", "Multimédia", "TV", "Réparation", "Livraison"],
|
||||
fleuriste: ["Fleuriste", "Bouquets", "Plantes", "Mariage", "Deuil", "Livraison de fleurs"],
|
||||
jardinerie: ["Jardinerie", "Plantes", "Motoculture", "Terreau", "Conseils jardin"],
|
||||
"bricolage-jardinage": ["Bricolage", "Jardinage", "Outillage", "Quincaillerie"],
|
||||
animalerie: ["Animalerie", "Alimentation animale", "Toilettage", "Accessoires"],
|
||||
// Artisanat & bâtiment
|
||||
artisanat: ["Artisan", "Fait main", "Création", "Sur mesure"],
|
||||
batiment: ["Bâtiment", "Rénovation", "Travaux", "Devis gratuit"],
|
||||
architecte: ["Architecte", "Maître d'œuvre", "Permis de construire", "Rénovation"],
|
||||
maconnerie: ["Maçonnerie", "Gros œuvre", "Terrassement", "Rénovation"],
|
||||
"couverture-toiture": ["Couvreur", "Toiture", "Zinguerie", "Isolation de toiture", "Urgence"],
|
||||
plomberie: ["Plombier", "Chauffagiste", "Chaudière", "Pompe à chaleur", "Dépannage"],
|
||||
electricite: ["Électricien", "Installation électrique", "Mise aux normes", "Domotique", "Dépannage"],
|
||||
"isolation-energie": ["Isolation", "Énergies renouvelables", "Panneaux solaires", "Rénovation énergétique"],
|
||||
menuiserie: ["Menuisier", "Menuiserie", "Ébénisterie", "Sur mesure", "Fenêtres"],
|
||||
"serrurerie-metallerie": ["Serrurier", "Métallerie", "Portails", "Garde-corps", "Urgence"],
|
||||
"carrelage-sols": ["Carreleur", "Carrelage", "Parquet", "Revêtements de sols"],
|
||||
peinture: ["Peintre", "Peinture", "Décoration intérieure", "Papier peint", "Ravalement"],
|
||||
paysagiste: ["Paysagiste", "Espaces verts", "Entretien de jardin", "Clôtures", "Élagage"],
|
||||
"depannage-multiservices": ["Dépannage", "Multiservices", "Petits travaux", "Homme toutes mains"],
|
||||
// Auto & mobilité
|
||||
automobile: ["Garage", "Entretien auto", "Réparation", "Pneus", "Contrôle technique"],
|
||||
carrosserie: ["Carrosserie", "Peinture auto", "Pare-brise", "Débosselage"],
|
||||
"controle-technique": ["Contrôle technique", "Contre-visite", "Sans rendez-vous"],
|
||||
"station-service-lavage": ["Station-service", "Carburant", "Lavage auto", "Boutique"],
|
||||
"location-vehicules": ["Location de voitures", "Location utilitaire", "Location camionnette"],
|
||||
"cycles-motos": ["Vélos", "Motos", "Vélo électrique", "Réparation", "Accessoires"],
|
||||
"auto-ecole": ["Auto-école", "Permis B", "Code de la route", "Conduite accompagnée"],
|
||||
"taxi-vtc": ["Taxi", "VTC", "Transport de personnes", "Gare", "Aéroport"],
|
||||
// Santé
|
||||
sante: ["Pharmacie", "Santé", "Parapharmacie", "Orthopédie", "Conseils"],
|
||||
medical: ["Médical", "Paramédical", "Soins", "Sur rendez-vous"],
|
||||
audioprothese: ["Audioprothésiste", "Appareils auditifs", "Bilan auditif", "Piles"],
|
||||
veterinaire: ["Vétérinaire", "Soins animaux", "Vaccination", "Urgences vétérinaires"],
|
||||
// Banque, assurance & immobilier
|
||||
"banque-assurance": ["Banque", "Assurance", "Crédit", "Épargne", "Professionnels"],
|
||||
assurance: ["Assurance", "Assurance auto", "Assurance habitation", "Mutuelle santé", "Prévoyance", "Assurance professionnelle"],
|
||||
"courtier-credit": ["Courtier", "Crédit immobilier", "Rachat de crédit", "Financement", "Assurance emprunteur"],
|
||||
immobilier: ["Agence immobilière", "Vente", "Location", "Estimation gratuite", "Gestion locative"],
|
||||
"diagnostic-immobilier": ["Diagnostic immobilier", "DPE", "Expertise", "Amiante", "Plomb"],
|
||||
// Services aux particuliers
|
||||
services: ["Services", "Proximité", "Sur devis"],
|
||||
"services-a-la-personne": ["Aide à domicile", "Ménage", "Garde d'enfants", "Services à la personne", "Crédit d'impôt"],
|
||||
"pressing-retouches": ["Pressing", "Retouches", "Cordonnerie", "Nettoyage à sec", "Clés"],
|
||||
photographe: ["Photographe", "Portrait", "Mariage", "Photo d'identité", "Vidéo"],
|
||||
"pompes-funebres": ["Pompes funèbres", "Obsèques", "Marbrerie", "Prévoyance obsèques"],
|
||||
"agence-voyages": ["Agence de voyages", "Séjours", "Billets", "Croisières", "Voyages de groupe"],
|
||||
demenagement: ["Déménagement", "Garde-meubles", "Cartons", "Devis gratuit"],
|
||||
// Services aux entreprises
|
||||
comptabilite: ["Expert-comptable", "Comptabilité", "Gestion", "Création d'entreprise", "Paie"],
|
||||
juridique: ["Notaire", "Juridique", "Avocat", "Conseil", "Succession"],
|
||||
conseil: ["Conseil", "Consultant", "Stratégie", "Accompagnement", "Formation"],
|
||||
communication: ["Communication", "Site web", "Marketing", "Réseaux sociaux", "Graphisme"],
|
||||
"imprimerie-enseignes": ["Imprimerie", "Enseignes", "Signalétique", "Flocage", "Cartes de visite"],
|
||||
informatique: ["Informatique", "Dépannage informatique", "Téléphonie", "Réparation", "Réseau"],
|
||||
nettoyage: ["Nettoyage", "Entretien de locaux", "Vitres", "Fin de chantier"],
|
||||
transport: ["Transport", "Logistique", "Livraison", "Messagerie"],
|
||||
securite: ["Sécurité", "Alarme", "Vidéosurveillance", "Gardiennage"],
|
||||
coworking: ["Coworking", "Bureaux", "Salle de réunion", "Domiciliation"],
|
||||
"interim-rh": ["Intérim", "Recrutement", "Ressources humaines", "Emploi"],
|
||||
"recyclage-dechets": ["Recyclage", "Déchets", "Débarras", "Ferraille", "Location de bennes"],
|
||||
// Culture, loisirs & formation
|
||||
"librairie-papeterie": ["Librairie", "Papeterie", "Presse", "Livres", "Fournitures scolaires"],
|
||||
"tabac-presse": ["Tabac", "Presse", "Loto", "FDJ", "Relais colis"],
|
||||
"jouets-puericulture": ["Jouets", "Puériculture", "Jeux", "Cadeaux enfants", "Liste de naissance"],
|
||||
"sport-loisirs": ["Sport", "Loisirs", "Activités", "Cours", "Stages"],
|
||||
"articles-de-sport": ["Articles de sport", "Équipement sportif", "Chaussures de sport", "Textile"],
|
||||
fitness: ["Salle de sport", "Fitness", "Musculation", "Cours collectifs", "Coaching"],
|
||||
"culture-arts": ["Culture", "Arts", "Galerie", "Cours", "Spectacles"],
|
||||
"education-formation": ["Formation", "Cours", "Soutien scolaire", "Formation professionnelle"],
|
||||
"tourisme-hotellerie": ["Hôtel", "Hébergement", "Chambres d'hôtes", "Gîte", "Séminaires"],
|
||||
evenementiel: ["Événementiel", "Organisation d'événements", "Mariage", "Location de matériel", "Animation"],
|
||||
// Entreprise, production & institutions
|
||||
industrie: ["Industrie", "Production", "Fabrication", "Sous-traitance"],
|
||||
agriculture: ["Producteur local", "Ferme", "Vente directe", "Produits fermiers", "Circuit court"],
|
||||
"partenaire-institutionnel": ["Partenaire", "Collectivité", "Institution", "Service public"],
|
||||
};
|
||||
|
||||
/**
|
||||
* Vocabulaire transversal détecté dans la description. Chaque motif est testé
|
||||
* sur le texte normalisé (minuscules, sans accents).
|
||||
*/
|
||||
export const DESCRIPTION_VOCABULARY: { tag: string; patterns: RegExp[] }[] = [
|
||||
{ tag: "Fait maison", patterns: [/\bfaite?s? maison\b/, /\bmaison\b.*\b(pain|plat|patisserie|gateau|cuisine)/] },
|
||||
{ tag: "Artisanal", patterns: [/\bartisanal(e|es|aux)?\b/, /\bartisan\b/] },
|
||||
{ tag: "Bio", patterns: [/\bbio(logique|logiques)?\b/] },
|
||||
{ tag: "Produits locaux", patterns: [/\blocal(e|es)?\b/, /\blocaux\b/, /\bterroir\b/, /\bcircuit court\b/] },
|
||||
{ tag: "Sur commande", patterns: [/\bsur commande\b/, /\bcommandes?\b/] },
|
||||
{ tag: "Livraison", patterns: [/\blivraisons?\b/, /\blivr(e|ons|ez)\b/] },
|
||||
{ tag: "À emporter", patterns: [/\ba emporter\b/, /\bvente a emporter\b/] },
|
||||
{ tag: "Click & collect", patterns: [/\bclick\s*(&|and|et)\s*collect\b/, /\bretrait en magasin\b/] },
|
||||
{ tag: "Sur rendez-vous", patterns: [/\bsur rendez[- ]vous\b/, /\brdv\b/] },
|
||||
{ tag: "Sans rendez-vous", patterns: [/\bsans rendez[- ]vous\b/] },
|
||||
{ tag: "Devis gratuit", patterns: [/\bdevis gratuits?\b/, /\bdevis\b/] },
|
||||
{ tag: "Dépannage", patterns: [/\bdepannages?\b/, /\burgences?\b/, /\b7j\s*\/\s*7\b/, /\b24h\s*\/\s*24\b/] },
|
||||
{ tag: "Sur mesure", patterns: [/\bsur[- ]mesure\b/, /\bpersonnalis(e|es|ee|ees|ation)\b/] },
|
||||
{ tag: "Terrasse", patterns: [/\bterrasses?\b/] },
|
||||
{ tag: "Parking", patterns: [/\bparking\b/, /\bstationnement\b/] },
|
||||
{ tag: "Ouvert le dimanche", patterns: [/\bdimanche\b/] },
|
||||
{ tag: "Accessible PMR", patterns: [/\bpmr\b/, /\bhandicap/, /\baccessible\b/] },
|
||||
{ tag: "Événements", patterns: [/\bevenements?\b/, /\bmariages?\b/, /\bseminaires?\b/, /\banniversaires?\b/] },
|
||||
{ tag: "Professionnels", patterns: [/\bprofessionnels\b/, /\bentreprises\b/, /\bb ?to ?b\b/, /\bb2b\b/] },
|
||||
{ tag: "Cadeaux", patterns: [/\bcadeaux?\b/, /\bcoffrets?\b/, /\bcartes? cadeau\b/] },
|
||||
{ tag: "Conseil", patterns: [/\bconseils?\b/, /\baccompagnement\b/] },
|
||||
{ tag: "Formation", patterns: [/\bformations?\b/, /\bcours\b/, /\bateliers?\b/, /\bstages?\b/] },
|
||||
{ tag: "Nouveau", patterns: [/\bnouveau\b/, /\bnouvelle\b/, /\bouverture\b/] },
|
||||
];
|
||||
|
||||
export function normalizeText(value: string): string {
|
||||
return value
|
||||
.normalize("NFD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.toLowerCase()
|
||||
.replace(/[’']/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** « a, b ,, c » → ["a", "b", "c"], sans doublons (accents et casse ignorés). */
|
||||
export function parseTags(value: string | null | undefined): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const raw of (value ?? "").split(/[,;\n]/)) {
|
||||
const tag = raw.replace(/\s+/g, " ").trim();
|
||||
if (!tag) continue;
|
||||
const key = normalizeText(tag);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(tag);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function formatTags(tags: string[]): string {
|
||||
return parseTags(tags.join(", ")).join(", ");
|
||||
}
|
||||
|
||||
/** Tags du métier : depuis le vocabulaire, sinon dérivés du libellé de la catégorie. */
|
||||
export function categoryTags(categorySlug: string | null | undefined, categoryLabel: string | null | undefined): string[] {
|
||||
if (categorySlug && CATEGORY_TAGS[categorySlug]) return [...CATEGORY_TAGS[categorySlug]];
|
||||
if (!categoryLabel) return [];
|
||||
return categoryLabel
|
||||
.split(/\s*(?:–|—|-|\/|&|,)\s*/)
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part.length > 1);
|
||||
}
|
||||
|
||||
/** Termes du vocabulaire transversal présents dans la description. */
|
||||
export function tagsFromDescription(description: string | null | undefined): string[] {
|
||||
const text = normalizeText(description ?? "");
|
||||
if (!text) return [];
|
||||
return DESCRIPTION_VOCABULARY.filter((entry) => entry.patterns.some((p) => p.test(text))).map((e) => e.tag);
|
||||
}
|
||||
|
||||
export type TagSuggestionInput = {
|
||||
categorySlug?: string | null;
|
||||
categoryLabel?: string | null;
|
||||
city?: string | null;
|
||||
description?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Suggestions ordonnées : le métier d'abord (c'est ce que l'on cherche le plus),
|
||||
* puis la commune, puis ce que la description révèle. Plafonné à `MAX_TAGS`.
|
||||
*/
|
||||
export function suggestTags(input: TagSuggestionInput, max = MAX_TAGS): string[] {
|
||||
const fromCategory = categoryTags(input.categorySlug, input.categoryLabel);
|
||||
const city = (input.city ?? "").trim();
|
||||
const fromDescription = tagsFromDescription(input.description);
|
||||
// Un mot-clé métier qui figure aussi dans la description remonte en tête : il
|
||||
// décrit vraiment l'activité, pas seulement la famille de métier.
|
||||
const text = normalizeText(input.description ?? "");
|
||||
const ranked = [...fromCategory].sort((a, b) => Number(text.includes(normalizeText(b))) - Number(text.includes(normalizeText(a))));
|
||||
return parseTags([...ranked, city, ...fromDescription].join(", ")).slice(0, max);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tags à enregistrer : la saisie de l'adhérent si elle existe, sinon les
|
||||
* suggestions. Ne modifie jamais des tags renseignés.
|
||||
*/
|
||||
export function autoTags(existing: string | null | undefined, input: TagSuggestionInput): string | null {
|
||||
const own = parseTags(existing);
|
||||
if (own.length > 0) return formatTags(own);
|
||||
const suggested = suggestTags(input);
|
||||
return suggested.length > 0 ? formatTags(suggested) : null;
|
||||
}
|
||||
+11
-4
@@ -18,7 +18,7 @@ const { auth } = NextAuth(authConfig);
|
||||
* `style` et sur la feuille Google Fonts. C'est un compromis assumé — une
|
||||
* injection de style est sans commune mesure avec une injection de script.
|
||||
*/
|
||||
function contentSecurityPolicy(nonce: string, isDev: boolean): string {
|
||||
function contentSecurityPolicy(nonce: string, isDev: boolean, isHttps: boolean): string {
|
||||
return [
|
||||
"default-src 'self'",
|
||||
"base-uri 'self'",
|
||||
@@ -35,14 +35,21 @@ function contentSecurityPolicy(nonce: string, isDev: boolean): string {
|
||||
"connect-src 'self'",
|
||||
// Carte « Nous situer » de la fiche adhérent.
|
||||
"frame-src https://www.google.com https://maps.google.com",
|
||||
"upgrade-insecure-requests",
|
||||
].join("; ");
|
||||
// Uniquement quand la page est servie en HTTPS : sur une page HTTP (test
|
||||
// par IP avant la pose du DNS, accès direct au port), le navigateur
|
||||
// basculerait toutes les navigations vers un https:// inexistant.
|
||||
isHttps ? "upgrade-insecure-requests" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
export default auth((request: NextRequest) => {
|
||||
// `btoa` et non `Buffer` : le middleware tourne sur le runtime Edge.
|
||||
const nonce = btoa(crypto.randomUUID());
|
||||
const csp = contentSecurityPolicy(nonce, process.env.NODE_ENV !== "production");
|
||||
const forwardedProto = request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim();
|
||||
const isHttps = forwardedProto ? forwardedProto === "https" : request.nextUrl.protocol === "https:";
|
||||
const csp = contentSecurityPolicy(nonce, process.env.NODE_ENV !== "production", isHttps);
|
||||
|
||||
// L'en-tête est posé sur la REQUÊTE : Next y lit le nonce pour l'appliquer à
|
||||
// ses balises <script>. Il est ensuite renvoyé sur la réponse au navigateur.
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import { CATEGORY_PALETTE, CATEGORY_REFERENTIAL, categoryRows } from "../src/db/categories";
|
||||
|
||||
describe("Référentiel des catégories", () => {
|
||||
it("garde des slugs uniques et stables", () => {
|
||||
const slugs = CATEGORY_REFERENTIAL.map((c) => c.slug);
|
||||
assert.equal(new Set(slugs).size, slugs.length, "slug en double");
|
||||
for (const slug of slugs) {
|
||||
assert.match(slug, /^[a-z0-9]+(?:-[a-z0-9]+)*$/, `slug invalide : ${slug}`);
|
||||
}
|
||||
// Slugs historiques : des fiches adhérents y sont rattachées.
|
||||
for (const legacy of ["alimentation", "boulangerie", "restauration", "mode-beaute", "artisanat", "services", "sante"]) {
|
||||
assert.ok(slugs.includes(legacy), `${legacy} doit rester présent`);
|
||||
}
|
||||
});
|
||||
|
||||
it("couvre les métiers demandés (assurance, bazar/discount, grande distribution…)", () => {
|
||||
const slugs = new Set(CATEGORY_REFERENTIAL.map((c) => c.slug));
|
||||
for (const expected of ["assurance", "bazar-discount", "supermarche", "librairie-papeterie", "tabac-presse", "veterinaire", "auto-ecole"]) {
|
||||
assert.ok(slugs.has(expected), `${expected} manquant`);
|
||||
}
|
||||
});
|
||||
|
||||
it("respecte les contraintes de la table", () => {
|
||||
for (const row of categoryRows()) {
|
||||
assert.ok(row.label.length <= 120, `libellé trop long : ${row.label}`);
|
||||
assert.ok(row.slug.length <= 80);
|
||||
assert.ok(CATEGORY_PALETTE.some((p) => p.accent === row.accent && p.tint === row.tint));
|
||||
assert.ok(row.sort >= 1);
|
||||
}
|
||||
});
|
||||
|
||||
it("est intégralement repris par la migration 0012", () => {
|
||||
const sql = readFileSync(new URL("../drizzle/0012_referentiel_categories.sql", import.meta.url), "utf8");
|
||||
for (const row of categoryRows()) {
|
||||
assert.ok(sql.includes(`('${row.slug}', '${row.label.replace(/'/g, "''")}', '${row.accent}', '${row.tint}', ${row.sort})`),
|
||||
`${row.slug} absent ou différent dans la migration`);
|
||||
}
|
||||
// Ne jamais écraser un libellé renommé depuis le backend.
|
||||
assert.ok(sql.includes('ON CONFLICT ("slug") DO UPDATE SET "sort" = EXCLUDED."sort";'));
|
||||
assert.ok(!/DO UPDATE SET[^;]*"label"/.test(sql));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import { CATEGORY_REFERENTIAL } from "../src/db/categories";
|
||||
import { PROMO_CATEGORIES, PROMO_CATEGORY_GROUPS, defaultPromoCategory } from "../src/lib/promo-categories";
|
||||
|
||||
describe("Catégories de promotion (types de produits)", () => {
|
||||
it("propose une liste large, sans doublon, groupée", () => {
|
||||
assert.ok(PROMO_CATEGORIES.length >= 70, `liste trop courte : ${PROMO_CATEGORIES.length}`);
|
||||
assert.equal(new Set(PROMO_CATEGORIES).size, PROMO_CATEGORIES.length, "doublon");
|
||||
assert.ok(PROMO_CATEGORY_GROUPS.every((g) => g.items.length > 0 && g.label));
|
||||
assert.ok(PROMO_CATEGORIES.includes("Autre"));
|
||||
for (const c of PROMO_CATEGORIES) assert.ok(c.length <= 120, `trop long pour la colonne : ${c}`);
|
||||
});
|
||||
|
||||
it("pré-sélectionne un type cohérent pour chaque métier du référentiel", () => {
|
||||
for (const c of CATEGORY_REFERENTIAL) {
|
||||
const d = defaultPromoCategory(c.slug);
|
||||
assert.ok(PROMO_CATEGORIES.includes(d), `${c.slug} → ${d} inconnu`);
|
||||
}
|
||||
assert.equal(defaultPromoCategory("boulangerie"), "Pain & viennoiseries");
|
||||
assert.equal(defaultPromoCategory("bazar-discount"), "Bazar & petit équipement");
|
||||
assert.equal(defaultPromoCategory("inconnu"), "Autre");
|
||||
assert.equal(defaultPromoCategory(null), "Autre");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
formatSchedule,
|
||||
isDue,
|
||||
isPending,
|
||||
parseScheduleInput,
|
||||
scheduleLabel,
|
||||
toScheduleInput,
|
||||
} from "../src/lib/promo-schedule";
|
||||
|
||||
test("programmation — champ vide = publication immédiate", () => {
|
||||
assert.equal(parseScheduleInput(""), null);
|
||||
assert.equal(parseScheduleInput(null), null);
|
||||
assert.equal(parseScheduleInput(" "), null);
|
||||
assert.equal(isDue(null), true);
|
||||
});
|
||||
|
||||
test("programmation — heure d'hiver interprétée en heure de Paris (UTC+1)", () => {
|
||||
const d = parseScheduleInput("2027-01-15T09:00");
|
||||
assert.ok(d);
|
||||
assert.equal(d.toISOString(), "2027-01-15T08:00:00.000Z");
|
||||
});
|
||||
|
||||
test("programmation — heure d'été interprétée en heure de Paris (UTC+2)", () => {
|
||||
const d = parseScheduleInput("2027-07-15T09:00");
|
||||
assert.ok(d);
|
||||
assert.equal(d.toISOString(), "2027-07-15T07:00:00.000Z");
|
||||
});
|
||||
|
||||
test("programmation — minuit ne bascule pas d'un jour", () => {
|
||||
const d = parseScheduleInput("2027-03-10T00:00");
|
||||
assert.ok(d);
|
||||
assert.equal(d.toISOString(), "2027-03-09T23:00:00.000Z");
|
||||
});
|
||||
|
||||
test("programmation — aller-retour avec le champ du formulaire", () => {
|
||||
for (const value of ["2027-01-15T09:00", "2027-07-15T18:30", "2027-12-31T23:45"]) {
|
||||
assert.equal(toScheduleInput(parseScheduleInput(value)), value);
|
||||
}
|
||||
assert.equal(toScheduleInput(null), "");
|
||||
});
|
||||
|
||||
test("programmation — une saisie illisible est refusée, jamais ignorée", () => {
|
||||
assert.throws(() => parseScheduleInput("demain matin"));
|
||||
assert.throws(() => parseScheduleInput("2027-13-45T99:99"));
|
||||
assert.throws(() => parseScheduleInput("2027-01-15"));
|
||||
});
|
||||
|
||||
test("programmation — échéance atteinte", () => {
|
||||
const at = new Date("2027-01-15T08:00:00.000Z");
|
||||
assert.equal(isDue(at, new Date("2027-01-15T07:59:59.000Z")), false);
|
||||
assert.equal(isDue(at, at), true);
|
||||
assert.equal(isDue(at, new Date("2027-01-15T08:00:01.000Z")), true);
|
||||
assert.equal(isPending(at, new Date("2027-01-15T07:00:00.000Z")), true);
|
||||
});
|
||||
|
||||
test("programmation — libellé en heure de Paris", () => {
|
||||
assert.equal(formatSchedule(new Date("2027-01-15T08:00:00.000Z")), "le 15 janvier 2027 à 09:00");
|
||||
assert.equal(formatSchedule(new Date("2027-07-15T07:00:00.000Z")), "le 15 juillet 2027 à 09:00");
|
||||
assert.equal(formatSchedule(null), null);
|
||||
assert.equal(
|
||||
scheduleLabel(new Date("2027-01-15T08:00:00.000Z")),
|
||||
"Publication programmée le 15 janvier 2027 à 09:00"
|
||||
);
|
||||
assert.equal(scheduleLabel(null), null);
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import {
|
||||
formatValidity,
|
||||
formatValidityShort,
|
||||
isExpired,
|
||||
isNotStarted,
|
||||
isRangeInvalid,
|
||||
visibilityNote,
|
||||
} from "../src/lib/promo-validity";
|
||||
|
||||
describe("Période de validité d'une promotion", () => {
|
||||
it("affiche les deux bornes", () => {
|
||||
assert.equal(
|
||||
formatValidity({ startsOn: "2027-03-01", endsOn: "2027-03-15" }),
|
||||
"Valable du 1er au 15 mars 2027"
|
||||
);
|
||||
});
|
||||
|
||||
it("ne répète pas l'année quand elle est identique", () => {
|
||||
assert.equal(
|
||||
formatValidity({ startsOn: "2027-03-01", endsOn: "2027-04-10" }),
|
||||
"Valable du 1er mars au 10 avril 2027"
|
||||
);
|
||||
});
|
||||
|
||||
it("écrit les deux années quand elles diffèrent", () => {
|
||||
assert.equal(
|
||||
formatValidity({ startsOn: "2027-12-20", endsOn: "2028-01-05" }),
|
||||
"Valable du 20 décembre 2027 au 5 janvier 2028"
|
||||
);
|
||||
});
|
||||
|
||||
it("gère une seule borne", () => {
|
||||
assert.equal(formatValidity({ endsOn: "2027-06-30" }), "Valable jusqu'au 30 juin 2027");
|
||||
assert.equal(
|
||||
formatValidity({ startsOn: "2027-06-01" }),
|
||||
"Valable à partir du 1er juin 2027"
|
||||
);
|
||||
});
|
||||
|
||||
it("ne renvoie rien sans date ni texte : la carte n'affiche alors aucune mention", () => {
|
||||
assert.equal(formatValidity({}), null);
|
||||
assert.equal(formatValidity({ startsOn: null, endsOn: null, validUntil: " " }), null);
|
||||
});
|
||||
|
||||
it("retombe sur l'ancien texte libre des promotions existantes", () => {
|
||||
assert.equal(formatValidity({ validUntil: "Tout l'été" }), "Tout l'été");
|
||||
// Les dates priment sur le texte libre.
|
||||
assert.equal(
|
||||
formatValidity({ endsOn: "2027-07-31", validUntil: "Tout l'été" }),
|
||||
"Valable jusqu'au 31 juillet 2027"
|
||||
);
|
||||
});
|
||||
|
||||
it("ignore une date illisible", () => {
|
||||
assert.equal(formatValidity({ endsOn: "pas-une-date" }), null);
|
||||
assert.equal(formatValidity({ endsOn: "2027-13-45" }), null);
|
||||
});
|
||||
|
||||
it("propose une variante sans le mot « Valable »", () => {
|
||||
assert.equal(formatValidityShort({ endsOn: "2027-06-30" }), "jusqu'au 30 juin 2027");
|
||||
});
|
||||
|
||||
it("considère le dernier jour comme encore valable", () => {
|
||||
const end = { endsOn: "2027-06-30" };
|
||||
assert.equal(isExpired(end, new Date("2027-06-30T23:00:00Z")), false);
|
||||
assert.equal(isExpired(end, new Date("2027-07-01T12:00:00Z")), true);
|
||||
assert.equal(isExpired({}, new Date("2099-01-01T00:00:00Z")), false);
|
||||
});
|
||||
|
||||
it("détecte une fin antérieure au début", () => {
|
||||
assert.equal(isRangeInvalid({ startsOn: "2027-06-10", endsOn: "2027-06-01" }), true);
|
||||
assert.equal(isRangeInvalid({ startsOn: "2027-06-01", endsOn: "2027-06-01" }), false);
|
||||
assert.equal(isRangeInvalid({ startsOn: "2027-06-01" }), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Visibilité publique d'une promotion", () => {
|
||||
it("distingue avant, pendant et après la période", () => {
|
||||
const promo = { startsOn: "2027-03-01", endsOn: "2027-03-15" };
|
||||
assert.equal(
|
||||
visibilityNote(promo, new Date("2027-02-20T12:00:00Z")),
|
||||
"Pas encore affichée : elle apparaîtra sur le site le 1er mars 2027."
|
||||
);
|
||||
assert.equal(visibilityNote(promo, new Date("2027-03-01T00:00:00Z")), null);
|
||||
assert.equal(visibilityNote(promo, new Date("2027-03-15T23:00:00Z")), null);
|
||||
assert.equal(
|
||||
visibilityNote(promo, new Date("2027-03-16T08:00:00Z")),
|
||||
"Période terminée le 15 mars 2027 : elle n'apparaît plus sur le site."
|
||||
);
|
||||
});
|
||||
|
||||
it("laisse visible une offre sans date", () => {
|
||||
assert.equal(visibilityNote({ startsOn: null, endsOn: null }), null);
|
||||
assert.equal(visibilityNote({ validUntil: "Ce week-end" }), null);
|
||||
assert.equal(isNotStarted({ startsOn: null }), false);
|
||||
});
|
||||
});
|
||||
+20
-4
@@ -8,6 +8,9 @@ import {
|
||||
STATIC_ROUTES,
|
||||
absoluteUrl,
|
||||
breadcrumbJsonLd,
|
||||
categoryPath,
|
||||
memberPath,
|
||||
parseMemberParam,
|
||||
eventJsonLd,
|
||||
localBusinessJsonLd,
|
||||
memberListJsonLd,
|
||||
@@ -30,6 +33,19 @@ describe("SEO — URLs et descriptions", () => {
|
||||
assert.equal(absoluteUrl(BASE, "adherents/3"), `${BASE}/adherents/3`);
|
||||
});
|
||||
|
||||
it("construit des URLs de fiche parlantes et les relit", () => {
|
||||
assert.equal(memberPath({ id: 12, name: "Au Bon Pain", city: "Frouard" }), "/adherents/12-au-bon-pain-frouard");
|
||||
assert.equal(memberPath({ id: 3, name: "Épicerie « Chez Léa »", city: null }), "/adherents/3-epicerie-chez-lea");
|
||||
assert.equal(memberPath({ id: 7, name: "???" }), "/adherents/7");
|
||||
for (const param of ["12-au-bon-pain-frouard", "12-ancien-nom", "12"]) {
|
||||
assert.equal(parseMemberParam(param), 12, param);
|
||||
}
|
||||
for (const param of ["abc", "-12", "12abc", "0", ""]) {
|
||||
assert.equal(parseMemberParam(param), null, param);
|
||||
}
|
||||
assert.equal(categoryPath("boulangerie"), "/annuaire/boulangerie");
|
||||
});
|
||||
|
||||
it("borne la meta description sur un mot entier", () => {
|
||||
const long = "Boulangerie artisanale ".repeat(20);
|
||||
const out = metaDescription(long);
|
||||
@@ -116,7 +132,7 @@ describe("SEO — données structurées", () => {
|
||||
};
|
||||
const biz = localBusinessJsonLd(BASE, member, [], null) as Record<string, unknown>;
|
||||
assert.equal(biz["@type"], "LocalBusiness");
|
||||
assert.equal(biz.url, `${BASE}/adherents/7`);
|
||||
assert.equal(biz.url, `${BASE}/adherents/7-au-bon-pain-pompey`);
|
||||
assert.equal((biz.address as { addressLocality: string }).addressLocality, "Pompey");
|
||||
assert.equal(biz.keywords, "pain, viennoiseries");
|
||||
assert.ok(!("email" in biz));
|
||||
@@ -124,7 +140,7 @@ describe("SEO — données structurées", () => {
|
||||
|
||||
const withSite = localBusinessJsonLd(BASE, member, [], "https://aubonpain.fr") as Record<string, unknown>;
|
||||
assert.equal(withSite.url, "https://aubonpain.fr");
|
||||
assert.equal(withSite.mainEntityOfPage, `${BASE}/adherents/7`);
|
||||
assert.equal(withSite.mainEntityOfPage, `${BASE}/adherents/7-au-bon-pain-pompey`);
|
||||
});
|
||||
|
||||
it("décrit une rencontre à venir avec ses places restantes", () => {
|
||||
@@ -144,9 +160,9 @@ describe("SEO — données structurées", () => {
|
||||
});
|
||||
|
||||
it("liste les adhérents de l'annuaire", () => {
|
||||
const list = memberListJsonLd(BASE, "Annuaire", [{ id: 1, name: "A" }, { id: 2, name: "B" }]);
|
||||
const list = memberListJsonLd(BASE, "Annuaire", [{ id: 1, name: "A" }, { id: 2, name: "Bar du Port", city: "Pompey" }]);
|
||||
assert.equal(list.numberOfItems, 2);
|
||||
assert.equal(list.itemListElement[1].url, `${BASE}/adherents/2`);
|
||||
assert.equal(list.itemListElement[1].url, `${BASE}/adherents/2-bar-du-port-pompey`);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { defaultProtocol, originFromHeaders } from "../src/lib/site-url";
|
||||
|
||||
function headersOf(entries: Record<string, string>) {
|
||||
const map = new Map(Object.entries(entries).map(([k, v]) => [k.toLowerCase(), v]));
|
||||
return { get: (name: string) => map.get(name.toLowerCase()) ?? null };
|
||||
}
|
||||
|
||||
test("l'origine suit les en-têtes du reverse proxy", () => {
|
||||
assert.equal(
|
||||
originFromHeaders(headersOf({ host: "pleinr-app:3000", "x-forwarded-host": "pleinr.example.fr", "x-forwarded-proto": "https" })),
|
||||
"https://pleinr.example.fr"
|
||||
);
|
||||
});
|
||||
|
||||
test("une liste d'hôtes transmis retient le premier", () => {
|
||||
assert.equal(
|
||||
originFromHeaders(headersOf({ "x-forwarded-host": "pleinr.example.fr, proxy.interne", "x-forwarded-proto": "https, http" })),
|
||||
"https://pleinr.example.fr"
|
||||
);
|
||||
});
|
||||
|
||||
test("un accès direct en HTTP sur une IP reste en HTTP", () => {
|
||||
assert.equal(originFromHeaders(headersOf({ host: "0.0.0.0:3000" })), "http://0.0.0.0:3000");
|
||||
assert.equal(originFromHeaders(headersOf({ host: "192.168.1.20:8413" })), "http://192.168.1.20:8413");
|
||||
assert.equal(originFromHeaders(headersOf({ host: "localhost:3000", "x-forwarded-proto": "http" })), "http://localhost:3000");
|
||||
});
|
||||
|
||||
test("un nom de domaine sans indication de protocole est présumé en HTTPS", () => {
|
||||
assert.equal(originFromHeaders(headersOf({ host: "pleinr.example.fr" })), "https://pleinr.example.fr");
|
||||
assert.equal(defaultProtocol("pleinr.example.fr:8443"), "https");
|
||||
assert.equal(defaultProtocol("[::1]:3000"), "http");
|
||||
});
|
||||
|
||||
test("un en-tête absent ou malformé ne produit rien", () => {
|
||||
assert.equal(originFromHeaders(headersOf({})), "");
|
||||
assert.equal(originFromHeaders(headersOf({ host: "bad host/with path" })), "");
|
||||
assert.equal(originFromHeaders(headersOf({ host: "pleinr.example.fr", "x-forwarded-proto": "ftp" })), "https://pleinr.example.fr");
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import { CATEGORY_REFERENTIAL } from "../src/db/categories";
|
||||
import { CATEGORY_TAGS, MAX_TAGS, autoTags, formatTags, parseTags, suggestTags, tagsFromDescription } from "../src/lib/tags";
|
||||
|
||||
describe("Tags automatiques", () => {
|
||||
it("couvre chaque métier du référentiel", () => {
|
||||
for (const c of CATEGORY_REFERENTIAL) {
|
||||
assert.ok((CATEGORY_TAGS[c.slug] ?? []).length >= 3, `vocabulaire manquant ou trop court : ${c.slug}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("nettoie et dédoublonne la saisie", () => {
|
||||
assert.deepEqual(parseTags(" Pain ,pain, PAIN ;Levain,, Fait maison "), ["Pain", "Levain", "Fait maison"]);
|
||||
assert.deepEqual(parseTags("Épicerie, epicerie"), ["Épicerie"]);
|
||||
assert.equal(formatTags(["a", "b", "a"]), "a, b");
|
||||
});
|
||||
|
||||
it("lit la description sans accents ni casse", () => {
|
||||
const tags = tagsFromDescription("Pains au LEVAIN faits maison, produits LOCAUX, livraison le dimanche. Devis gratuit.");
|
||||
for (const expected of ["Fait maison", "Produits locaux", "Livraison", "Ouvert le dimanche", "Devis gratuit"]) {
|
||||
assert.ok(tags.includes(expected), `${expected} attendu dans ${tags.join(", ")}`);
|
||||
}
|
||||
assert.deepEqual(tagsFromDescription(""), []);
|
||||
});
|
||||
|
||||
it("suggère métier, commune puis description, dans la limite", () => {
|
||||
const tags = suggestTags({
|
||||
categorySlug: "boulangerie",
|
||||
city: "Frouard",
|
||||
description: "Boulangerie artisanale : pain au levain et viennoiseries faites maison.",
|
||||
});
|
||||
assert.equal(tags[0], "Boulangerie");
|
||||
assert.ok(tags.includes("Frouard"));
|
||||
assert.ok(tags.includes("Fait maison"));
|
||||
assert.ok(tags.includes("Artisanal"));
|
||||
assert.ok(tags.length <= MAX_TAGS);
|
||||
// Les mots du métier cités dans la description passent devant les autres.
|
||||
assert.ok(tags.indexOf("Pain") < tags.indexOf("Sandwichs"));
|
||||
});
|
||||
|
||||
it("dérive du libellé quand le slug est inconnu (catégorie créée dans le backend)", () => {
|
||||
assert.deepEqual(suggestTags({ categorySlug: "ma-categorie", categoryLabel: "Toilettage – Pension canine" }), [
|
||||
"Toilettage",
|
||||
"Pension canine",
|
||||
]);
|
||||
});
|
||||
|
||||
it("ne remplace jamais une saisie existante", () => {
|
||||
assert.equal(autoTags("Kouglof, Brioche", { categorySlug: "boulangerie", city: "Frouard" }), "Kouglof, Brioche");
|
||||
assert.equal(autoTags(" ", { categorySlug: "assurance", city: "Pompey" }), "Assurance, Assurance auto, Assurance habitation, Mutuelle santé, Prévoyance, Assurance professionnelle, Pompey");
|
||||
assert.equal(autoTags("", {}), null);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user