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