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