mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-22 06:38:05 +02:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86e7e28020 |
No files matched your search
@@ -50,68 +50,11 @@ docker compose up --build # full stack
|
||||
|
||||
## Promotions
|
||||
|
||||
Statuts : `pending` → (`scheduled` →) `live` → `suspended` ⇄ `live`
|
||||
(+ `rejected` / `expired`).
|
||||
Statuts : `pending` → `live` → `suspended` ⇄ `live` (+ `rejected` / `expired`).
|
||||
`promotions.suspended_by` retient qui a suspendu (`member` ou `staff`) : une
|
||||
suspension par le staff ne peut être levée que par le staff. Les lectures
|
||||
publiques filtrent sur le statut `live`, donc une promo suspendue — ou encore
|
||||
programmée — disparaît du site sans traitement supplémentaire.
|
||||
|
||||
**Période de validité** : `promotions.starts_on` / `ends_on` (colonnes `date`,
|
||||
les deux facultatives et indépendantes). `src/lib/promo-validity.ts` est **pur**
|
||||
et verrouillé par `tests/promo-validity.test.ts` : `formatValidity()` rend
|
||||
« Valable du 1er au 15 mars 2027 » / « Valable jusqu'au … » / « Valable à partir
|
||||
du … », `formatValidityShort()` la variante sans « Valable » pour les lignes de
|
||||
méta, `isRangeInvalid()` garde le formulaire et l'action. Sans date, rien n'est
|
||||
affiché ; l'ancien texte libre `valid_until` (jamais écrit par l'application)
|
||||
ne sert plus que de repli d'affichage. La même phrase part dans le message
|
||||
Facebook / LinkedIn (`buildPromoMessage`) : un seul formateur pour le site,
|
||||
le backoffice et les réseaux.
|
||||
|
||||
La liste publique vit sur **`/promotions`** (toutes les offres en cours) ;
|
||||
l'accueil n'en montre que les six dernières et renvoie vers elle. Les deux
|
||||
listes partagent `src/components/PromoCard.tsx` : un seul rendu, pas deux
|
||||
cartes à maintenir. `getLivePromotions(limit)` accepte `null` pour « tout »,
|
||||
la limite restant appliquée en SQL. Les liens « Promotions » de l'en-tête, du
|
||||
pied de page et des publications réseaux pointent sur cette page, plus sur
|
||||
l'ancre `/#promotions`.
|
||||
|
||||
Une promotion n'est **affichée que pendant sa période** : les lectures de
|
||||
`src/lib/queries.ts` passent par `VISIBLE_PROMO` (statut `live` **et** fenêtre
|
||||
de dates, journée calculée en `Europe/Paris` — un conteneur en UTC retirerait
|
||||
sinon une offre deux heures trop tôt). Le statut n'est pas modifié : hors
|
||||
période, le backoffice affiche `visibilityNote()` (« pas encore affichée » /
|
||||
« période terminée »), et l'offre revient d'elle-même si les dates changent.
|
||||
|
||||
**Publication programmée** : `promotions.publish_at` (`timestamptz`, vide =
|
||||
mise en ligne immédiate). L'adhérent propose une date dans son formulaire, le
|
||||
modérateur la garde, la déplace ou la vide dans « Valider » — comme les cases
|
||||
réseaux, c'est le dernier moment où elle est ajustable. Une promotion validée
|
||||
avec une échéance future passe au statut `scheduled` : invisible du site et
|
||||
**rien n'est diffusé**. `releaseDuePromotions()` (`src/lib/promo-publish.ts`)
|
||||
fait la bascule `scheduled → live` par un `UPDATE … RETURNING` filtré sur le
|
||||
statut — atomique, donc sans double publication même à plusieurs instances —
|
||||
puis appelle `publishPromoShares()`. Le bouton « Publier maintenant » de la
|
||||
modération court-circuite l'attente.
|
||||
|
||||
`src/lib/promo-schedule.ts` est **pur** (`tests/promo-schedule.test.ts`) :
|
||||
il convertit la saisie `datetime-local` depuis/vers l'heure de l'association
|
||||
(`Europe/Paris`, changements d'heure compris) et refuse une saisie illisible
|
||||
plutôt que de la transformer en publication immédiate.
|
||||
|
||||
Le déclencheur est `src/instrumentation.ts` : une boucle d'une minute démarrée
|
||||
avec le serveur, avec un premier passage au démarrage pour rattraper les
|
||||
échéances tombées pendant un redéploiement. `PROMO_SCHEDULER=off` la désactive.
|
||||
Le travail vit dans `src/instrumentation-node.ts` parce que Next compile aussi
|
||||
`instrumentation.ts` pour le runtime edge du middleware : `next.config.mjs`
|
||||
l'écarte de ce bundle (`IgnorePlugin`), sans quoi webpack tente d'y embarquer
|
||||
`pg` et ses dépendances Node.
|
||||
|
||||
`src/lib/promo-publish.ts` porte `publishPromoShares()` — et non plus
|
||||
`backend/actions.ts` — parce qu'il a deux appelants : une action serveur et la
|
||||
boucle de libération, qui n'a pas de requête et ne peut donc pas appeler
|
||||
`revalidatePath()`. `src/lib/activity-log.ts` porte `logActivity()` pour la
|
||||
même raison.
|
||||
publiques filtrent sur `status = 'live'`, donc une promo suspendue disparaît du
|
||||
site sans traitement supplémentaire.
|
||||
|
||||
## Réseaux sociaux
|
||||
|
||||
@@ -131,45 +74,24 @@ même raison.
|
||||
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** (ou par l'échéance d'une
|
||||
publication programmée), pas par un bouton :
|
||||
- La diffusion est déclenchée **par la validation**, pas par un bouton :
|
||||
`promotions.share_facebook` / `share_linkedin` sont choisis par l'adhérent,
|
||||
ajustables par le modérateur dans le formulaire « Valider », puis figés
|
||||
(`status !== 'pending'`).
|
||||
- `publishPromoShares()` dans `src/lib/promo-publish.ts` est le seul point de
|
||||
- `publishPromoShares()` dans `backend/actions.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** : 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é.
|
||||
> **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.
|
||||
|
||||
Mise à jour : `git pull && docker compose up -d --build`.
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
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;
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE "promotions" ADD COLUMN "starts_on" date;--> statement-breakpoint
|
||||
ALTER TABLE "promotions" ADD COLUMN "ends_on" date;
|
||||
@@ -1,2 +0,0 @@
|
||||
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,27 +99,6 @@
|
||||
"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,20 +8,6 @@ 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,7 +26,6 @@ import {
|
||||
parseMemberHours,
|
||||
websiteLabel,
|
||||
} from "@/lib/member-profile";
|
||||
import { formatValidity } from "@/lib/promo-validity";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -438,8 +437,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>
|
||||
{formatValidity(p) && (
|
||||
<div style={{ borderTop: "1px solid #f0e8d6", paddingTop: 10, fontSize: 12, color: "#a99c82" }}>{formatValidity(p)}</div>
|
||||
{p.validUntil && (
|
||||
<div style={{ borderTop: "1px solid #f0e8d6", paddingTop: 10, fontSize: 12, color: "#a99c82" }}>{p.validUntil}</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
authorizeUrl,
|
||||
getDecryptedAppSecret,
|
||||
getSocialAccount,
|
||||
publicBaseUrl,
|
||||
siteUrl,
|
||||
SOCIAL_NETWORKS,
|
||||
type SocialNetwork,
|
||||
} from "@/lib/social-accounts";
|
||||
@@ -34,13 +34,11 @@ export async function GET(
|
||||
return back(request, "Réseau inconnu.");
|
||||
}
|
||||
|
||||
// Réglage enregistré, sinon l'adresse par laquelle l'administrateur est
|
||||
// arrivé : c'est celle où le réseau nous renverra.
|
||||
const base = await publicBaseUrl();
|
||||
const base = await siteUrl();
|
||||
if (!base) {
|
||||
return back(
|
||||
request,
|
||||
"Impossible de déterminer l'adresse publique du site : renseignez-la dans la section « URL publique du site »."
|
||||
"Renseignez d'abord l'URL publique du site : elle sert à construire l'adresse de retour."
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+116
-95
@@ -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, ne, sql } from "drizzle-orm";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { signOut } from "@/auth";
|
||||
import { getSession } from "@/lib/session";
|
||||
@@ -13,6 +13,7 @@ import { CATEGORY_PALETTE } from "@/db/categories";
|
||||
import { slugify } from "@/lib/slug";
|
||||
import { autoTags } from "@/lib/tags";
|
||||
import {
|
||||
activityLog,
|
||||
categories,
|
||||
contactMessages,
|
||||
imageConsents,
|
||||
@@ -24,10 +25,14 @@ 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,
|
||||
@@ -38,15 +43,16 @@ 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.
|
||||
@@ -108,14 +114,6 @@ 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");
|
||||
@@ -126,6 +124,13 @@ 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",
|
||||
@@ -133,6 +138,84 @@ 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();
|
||||
@@ -149,36 +232,20 @@ 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: immediate ? "live" : "scheduled",
|
||||
publishAt: immediate ? null : publishAt,
|
||||
status: "live",
|
||||
...targets,
|
||||
suspendedBy: null,
|
||||
suspendedById: null,
|
||||
suspendedAt: null,
|
||||
})
|
||||
.where(eq(promotions.id, id));
|
||||
|
||||
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"
|
||||
);
|
||||
}
|
||||
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 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.
|
||||
@@ -190,15 +257,6 @@ 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)
|
||||
@@ -313,15 +371,7 @@ 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({
|
||||
@@ -332,9 +382,6 @@ export async function publishPromo(formData: FormData) {
|
||||
imageUrl,
|
||||
memberId: memberId ?? null,
|
||||
status: "pending",
|
||||
startsOn,
|
||||
endsOn,
|
||||
publishAt,
|
||||
...readShareTargets(formData),
|
||||
});
|
||||
|
||||
@@ -359,16 +406,6 @@ 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`).
|
||||
@@ -391,16 +428,14 @@ async function resolveMemberTags(
|
||||
});
|
||||
}
|
||||
|
||||
export async function addMember(
|
||||
formData: FormData
|
||||
): Promise<CreatedMemberAccount | ActionError | undefined> {
|
||||
export async function addMember(formData: FormData): Promise<CreatedMemberAccount | 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) return { error: "L'e-mail est requis pour créer le compte de l'adhérent." };
|
||||
if (!email) throw new 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");
|
||||
@@ -408,7 +443,7 @@ export async function addMember(
|
||||
// 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) {
|
||||
return { error: `Un compte existe déjà avec l'e-mail ${email}.` };
|
||||
throw new Error("Un compte existe déjà avec cet e-mail.");
|
||||
}
|
||||
|
||||
const tags = await resolveMemberTags(null, { categoryId, city, description: null });
|
||||
@@ -563,9 +598,7 @@ export async function deleteMember(formData: FormData) {
|
||||
}
|
||||
|
||||
// ---- Admins ----
|
||||
export async function inviteAdmin(
|
||||
formData: FormData
|
||||
): Promise<IssuedCredentials | ActionError | undefined> {
|
||||
export async function inviteAdmin(formData: FormData): Promise<IssuedCredentials | undefined> {
|
||||
const { role } = await requireRole();
|
||||
if (!can(role, "manageAdmins")) throw new Error("Accès refusé");
|
||||
|
||||
@@ -577,7 +610,7 @@ export async function inviteAdmin(
|
||||
const newRole: AppRole = LABEL_TO_ROLE[roleLabel] ?? "editor";
|
||||
|
||||
const existing = await db.select().from(users).where(eq(users.email, email));
|
||||
if (existing.length > 0) return { error: `Un compte existe déjà avec l'e-mail ${email}.` };
|
||||
if (existing.length > 0) throw new Error("Un compte existe déjà avec cet e-mail.");
|
||||
|
||||
// Mot de passe temporaire montré une seule fois à l'inviteur, à changer à
|
||||
// la première connexion. Auparavant il n'était ni conservé ni affiché :
|
||||
@@ -1121,9 +1154,7 @@ 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 | ActionError | undefined> {
|
||||
export async function approveMembershipRequest(formData: FormData): Promise<CreatedMemberAccount | undefined> {
|
||||
const { role } = await requireRole();
|
||||
if (!can(role, "manageMembers")) throw new Error("Accès refusé");
|
||||
|
||||
@@ -1132,29 +1163,18 @@ export async function approveMembershipRequest(
|
||||
|
||||
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.",
|
||||
};
|
||||
}
|
||||
// Garde serveur : un double clic ou un onglet resté ouvert ne doit jamais
|
||||
// créer un second compte pour la même demande.
|
||||
if (req.status === "approved") throw new Error("Cette demande a déjà été approuvée : l'adhérent existe.");
|
||||
|
||||
const email = (req.email ?? "").trim().toLowerCase();
|
||||
if (!email) {
|
||||
return {
|
||||
error:
|
||||
"Cette demande n'a pas d'e-mail : impossible de créer le compte. Créez l'adhérent manuellement.",
|
||||
};
|
||||
throw new 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) {
|
||||
return { error: `Un compte existe déjà avec l'e-mail ${email}.` };
|
||||
throw new Error("Un compte existe déjà avec cet e-mail.");
|
||||
}
|
||||
|
||||
const [newMember] = await db
|
||||
@@ -1189,12 +1209,13 @@ 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.
|
||||
// Une demande approuvée a produit un adhérent : son statut ne se réécrit plus.
|
||||
const [current] = await db.select({ status: membershipRequests.status }).from(membershipRequests).where(eq(membershipRequests.id, id));
|
||||
if (!current || current.status === "approved") return;
|
||||
await db
|
||||
.update(membershipRequests)
|
||||
.set({ status: status as "new" | "approved" | "rejected" })
|
||||
.where(and(eq(membershipRequests.id, id), ne(membershipRequests.status, "approved")));
|
||||
.where(eq(membershipRequests.id, id));
|
||||
revalidatePath("/backend/demandes");
|
||||
revalidatePath("/backend");
|
||||
}
|
||||
|
||||
@@ -14,11 +14,6 @@ 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 (
|
||||
@@ -43,18 +38,8 @@ 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.
|
||||
@@ -63,23 +48,18 @@ 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" defaultValue={draft.name ?? ""} />
|
||||
<input name="name" required placeholder="ex : Au Bon Pain" className="field" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">E-mail (identifiant de connexion)</label>
|
||||
<input name="email" type="email" required placeholder="contact@exemple.fr" className="field" defaultValue={draft.email ?? ""} />
|
||||
<input name="email" type="email" required placeholder="contact@exemple.fr" className="field" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Catégorie</label>
|
||||
<select name="categoryId" className="field" defaultValue={draft.categoryId ?? ""}>
|
||||
<select name="categoryId" className="field" defaultValue="">
|
||||
<option value="">—</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
@@ -90,7 +70,7 @@ export function AddMemberPanel({
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Commune</label>
|
||||
<select name="city" className="field" defaultValue={draft.city ?? ""}>
|
||||
<select name="city" className="field" defaultValue="">
|
||||
<option value="">—</option>
|
||||
{BASSIN_POMPEY_COMMUNES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
@@ -101,7 +81,7 @@ export function AddMemberPanel({
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Statut</label>
|
||||
<select name="status" className="field" defaultValue={draft.status ?? "pending"}>
|
||||
<select name="status" className="field" defaultValue="pending">
|
||||
<option value="pending">En attente</option>
|
||||
<option value="active">Actif</option>
|
||||
</select>
|
||||
|
||||
@@ -25,16 +25,11 @@ 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 {
|
||||
// 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.");
|
||||
// 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é.");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
|
||||
@@ -10,22 +10,10 @@ import { approveMembershipRequest, type CreatedMemberAccount } from "../actions"
|
||||
* Approuve une demande et affiche une seule fois les identifiants du compte
|
||||
* créé. On reste sur la page : une redirection ferait perdre le mot de passe,
|
||||
* qui n'est conservé nulle part.
|
||||
*
|
||||
* Le bouton ne s'affiche que pour une demande encore à traiter, mais c'est ce
|
||||
* composant — et non la page — qui en décide : après l'approbation,
|
||||
* `router.refresh()` renvoie la demande en « approuvée », et un masquage côté
|
||||
* serveur démonterait le bloc d'identifiants avant qu'il ait été lu.
|
||||
*/
|
||||
export function ApproveRequestForm({
|
||||
requestId,
|
||||
status,
|
||||
}: {
|
||||
requestId: number;
|
||||
status: string;
|
||||
}) {
|
||||
export function ApproveRequestForm({ requestId }: { requestId: number }) {
|
||||
const [created, setCreated] = useState<CreatedMemberAccount | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
if (created) {
|
||||
@@ -39,20 +27,13 @@ export function ApproveRequestForm({
|
||||
);
|
||||
}
|
||||
|
||||
if (status !== "new") return null;
|
||||
|
||||
return (
|
||||
<form
|
||||
action={async (fd) => {
|
||||
setPending(true);
|
||||
try {
|
||||
const result = await approveMembershipRequest(fd);
|
||||
// Échec de saisie (e-mail déjà pris, demande sans e-mail) : la
|
||||
// raison s'affiche à côté du bouton, la page reste en place.
|
||||
if (result && "error" in result) {
|
||||
setError(result.error);
|
||||
} else if (result) {
|
||||
setError(null);
|
||||
if (result) {
|
||||
setCreated(result);
|
||||
router.refresh();
|
||||
}
|
||||
@@ -62,11 +43,6 @@ export function ApproveRequestForm({
|
||||
}}
|
||||
>
|
||||
<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}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { desc } from "drizzle-orm";
|
||||
import { desc, inArray } from "drizzle-orm";
|
||||
import { getSession } from "@/lib/session";
|
||||
import { db } from "@/db";
|
||||
import { contactMessages, membershipRequests } from "@/db/schema";
|
||||
import { contactMessages, members, membershipRequests } from "@/db/schema";
|
||||
import { can } from "@/lib/rbac";
|
||||
import { setContactStatus, setRequestStatus } from "../actions";
|
||||
import { ApproveRequestForm } from "./ApproveRequestForm";
|
||||
@@ -52,6 +53,21 @@ export default async function DemandesPage() {
|
||||
db.select().from(contactMessages).orderBy(desc(contactMessages.createdAt)),
|
||||
]);
|
||||
|
||||
// Demandes approuvées : on retrouve la fiche créée par son e-mail, pour
|
||||
// proposer un lien vers l'adhérent plutôt que des boutons déjà consommés.
|
||||
const approvedEmails = requests
|
||||
.filter((r) => r.status === "approved" && r.email)
|
||||
.map((r) => (r.email ?? "").trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
const memberByEmail = new Map<string, number>();
|
||||
if (approvedEmails.length > 0) {
|
||||
const rows = await db
|
||||
.select({ id: members.id, email: members.email })
|
||||
.from(members)
|
||||
.where(inArray(members.email, approvedEmails));
|
||||
for (const row of rows) memberByEmail.set(row.email.trim().toLowerCase(), row.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 26 }}>
|
||||
{/* ---- Demandes d'adhésion ---- */}
|
||||
@@ -72,24 +88,31 @@ 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} 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>
|
||||
{/* Les actions dépendent du statut : une demande approuvée a déjà
|
||||
produit son adhérent, on ne propose plus que d'ouvrir sa fiche. */}
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||
{r.status === "approved" ? (
|
||||
(() => {
|
||||
const memberId = memberByEmail.get((r.email ?? "").trim().toLowerCase());
|
||||
return memberId ? (
|
||||
<Link href={`/backend/adherents/${memberId}`} style={{ color: "#2C6FB3", fontWeight: 700, fontSize: 12.5, textDecoration: "none", whiteSpace: "nowrap" }}>
|
||||
Ouvrir la fiche adhérent →
|
||||
</Link>
|
||||
) : (
|
||||
<span style={{ fontSize: 12.5, color: "#a99c82" }}>Adhérent créé</span>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<>
|
||||
<ApproveRequestForm requestId={r.id} />
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -121,30 +144,17 @@ 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 }}>
|
||||
{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>
|
||||
)}
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<p style={{ margin: "10px 0 0", fontSize: 13.5, color: "#5a5040", whiteSpace: "pre-wrap" }}>{m.message}</p>
|
||||
|
||||
@@ -4,12 +4,9 @@ import { useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { publishPromo } from "../actions";
|
||||
import { PromoImage } from "@/components/PromoImage";
|
||||
import { MemberAvatar } from "@/components/MemberAvatar";
|
||||
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
|
||||
import type { SocialNetwork } from "@/lib/social";
|
||||
import type { PromoCategoryGroup } from "@/lib/promo-categories";
|
||||
import { formatValidity, isRangeInvalid } from "@/lib/promo-validity";
|
||||
import { formatSchedule, parseScheduleInput } from "@/lib/promo-schedule";
|
||||
|
||||
const STRIPE_WARM =
|
||||
"repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)";
|
||||
@@ -19,48 +16,13 @@ const NETWORK_LABELS: Record<SocialNetwork, string> = {
|
||||
linkedin: "LinkedIn",
|
||||
};
|
||||
|
||||
/** Une ligne du récapitulatif de dates, à droite du formulaire. */
|
||||
function SummaryRow({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
last = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
tone: "set" | "muted" | "error";
|
||||
last?: boolean;
|
||||
}) {
|
||||
const color = tone === "error" ? "#d8472b" : tone === "set" ? "#3c3322" : "#a99c82";
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
justifyContent: "space-between",
|
||||
gap: 14,
|
||||
padding: "12px 0",
|
||||
borderBottom: last ? "none" : "1px solid #f0e8d6",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 12.5, color: "#9a8d72", fontWeight: 700, whiteSpace: "nowrap" }}>{label}</span>
|
||||
<span style={{ fontSize: 13, color, fontWeight: tone === "muted" ? 500 : 700, textAlign: "right" }}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MemberSpaceForm({
|
||||
memberName,
|
||||
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. */
|
||||
@@ -74,9 +36,6 @@ 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("");
|
||||
const [shares, setShares] = useState<SocialNetwork[]>([]);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
@@ -103,9 +62,6 @@ export function MemberSpaceForm({
|
||||
setTitle("");
|
||||
setText("");
|
||||
setBadge("");
|
||||
setStartsOn("");
|
||||
setEndsOn("");
|
||||
setPublishAt("");
|
||||
setImgData("");
|
||||
setShares([]);
|
||||
setSubmitted(false);
|
||||
@@ -120,9 +76,6 @@ 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");
|
||||
@@ -136,17 +89,6 @@ 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" }}>
|
||||
@@ -227,56 +169,6 @@ 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}
|
||||
@@ -338,7 +230,7 @@ export function MemberSpaceForm({
|
||||
<div style={{ display: "flex", gap: 10 }}>
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={pending || badRange}
|
||||
disabled={pending}
|
||||
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 }}
|
||||
>
|
||||
@@ -363,31 +255,10 @@ 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>
|
||||
{/* 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)" }}>
|
||||
<article style={{ 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",
|
||||
@@ -422,14 +293,8 @@ 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={{ 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>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: "#3c3322" }}>{memberName}</div>
|
||||
<span style={{ color: "#2C6FB3", fontWeight: 700, fontSize: 13 }}>Voir l'offre →</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,8 +7,6 @@ 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";
|
||||
@@ -21,7 +19,6 @@ 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 = {
|
||||
@@ -31,9 +28,6 @@ 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;
|
||||
@@ -52,7 +46,6 @@ 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;
|
||||
@@ -66,7 +59,6 @@ export default async function EspacePromotionsPage() {
|
||||
name: members.name,
|
||||
city: members.city,
|
||||
status: members.status,
|
||||
logoUrl: members.logoUrl,
|
||||
categorySlug: categories.slug,
|
||||
categoryLabel: categories.label,
|
||||
})
|
||||
@@ -75,7 +67,6 @@ 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;
|
||||
@@ -88,9 +79,6 @@ 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,
|
||||
@@ -123,12 +111,7 @@ 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");
|
||||
// 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"
|
||||
);
|
||||
const others = myPromos.filter((p) => p.status !== "live" && p.status !== "pending");
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -163,22 +146,11 @@ 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"
|
||||
@@ -201,7 +173,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 des promotions</Link>
|
||||
Les offres en ligne apparaissent sur <Link href="/#promotions" style={{ color: "#9a6638", fontWeight: 700 }}>la page d'accueil</Link>
|
||||
{publicPath ? (
|
||||
<>
|
||||
{" "}et sur <Link href={publicPath} style={{ color: "#9a6638", fontWeight: 700 }}>votre fiche</Link>
|
||||
@@ -265,7 +237,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, formatValidityShort(mp)].filter(Boolean).join(" · ");
|
||||
const meta = [mp.category, mp.badge, mp.validUntil].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" }}>
|
||||
@@ -309,16 +281,6 @@ 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.
|
||||
|
||||
+15
-57
@@ -3,17 +3,10 @@ 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, socialPosts } from "@/db/schema";
|
||||
import { activityLog, contactMessages, members, membershipRequests, promotions } from "@/db/schema";
|
||||
import { activityNodes } from "@/lib/activity";
|
||||
import { can, isStaff } from "@/lib/rbac";
|
||||
import { countVisiblePromotions } from "@/lib/queries";
|
||||
import {
|
||||
expiryStatus,
|
||||
getSocialAccounts,
|
||||
SOCIAL_LABELS,
|
||||
tokenHealthCached,
|
||||
type SocialNetwork,
|
||||
} from "@/lib/social-accounts";
|
||||
import { expiryStatus, getSocialAccounts, SOCIAL_LABELS } from "@/lib/social-accounts";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -49,7 +42,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")),
|
||||
countVisiblePromotions(),
|
||||
db.select({ id: promotions.id }).from(promotions).where(eq(promotions.status, "live")),
|
||||
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")),
|
||||
@@ -58,47 +51,14 @@ export default async function DashboardPage() {
|
||||
|
||||
const pendingCount = pendingPromos.length;
|
||||
|
||||
// Jetons réseaux à renouveler. Deux signaux, car une date d'expiration ne
|
||||
// suffit pas : un jeton de page Facebook n'expire jamais mais peut être
|
||||
// révoqué, et cela ne se voit qu'à la première publication en échec.
|
||||
const expiringNetworks: { network: SocialNetwork; status: string }[] = [];
|
||||
if (can(session?.user.role, "manageSettings")) {
|
||||
const connected = (await getSocialAccounts()).filter((a) => a.accessToken && a.targetId);
|
||||
|
||||
// Dernière tentative de publication par réseau : une erreur d'autorisation
|
||||
// signale un jeton mort, sans avoir à interroger la plateforme ici.
|
||||
const attempts = connected.length
|
||||
? await db
|
||||
.select({
|
||||
network: socialPosts.network,
|
||||
status: socialPosts.status,
|
||||
error: socialPosts.error,
|
||||
})
|
||||
.from(socialPosts)
|
||||
.orderBy(desc(socialPosts.createdAt))
|
||||
.limit(40)
|
||||
: [];
|
||||
const lastAttempt = new Map<string, (typeof attempts)[number]>();
|
||||
for (const a of attempts) if (!lastAttempt.has(a.network)) lastAttempt.set(a.network, a);
|
||||
|
||||
for (const account of connected) {
|
||||
const last = lastAttempt.get(account.network);
|
||||
const authFailure =
|
||||
last?.status === "failed" &&
|
||||
/oauth|token|401|expired|invalid|permission/i.test(last.error ?? "");
|
||||
// Contrôle direct, mis en cache 6 h : c'est ce qui permet de prévenir
|
||||
// avant la première publication en échec, sans appeler la plateforme à
|
||||
// chaque affichage du tableau de bord.
|
||||
const probe = authFailure ? null : await tokenHealthCached(account.network);
|
||||
const expiry = expiryStatus(account.expiresAt);
|
||||
|
||||
if (authFailure || probe?.ok === false) {
|
||||
expiringNetworks.push({ network: account.network, status: "broken" });
|
||||
} else if (expiry === "soon" || expiry === "expired") {
|
||||
expiringNetworks.push({ network: account.network, status: expiry });
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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")
|
||||
: [];
|
||||
|
||||
function timeAgo(date: Date) {
|
||||
const diff = Date.now() - new Date(date).getTime();
|
||||
@@ -130,11 +90,9 @@ export default async function DashboardPage() {
|
||||
{expiringNetworks.map((n) => (
|
||||
<div key={n.network}>
|
||||
<strong>{SOCIAL_LABELS[n.network]}</strong>{" "}
|
||||
{n.status === "broken"
|
||||
? "— la dernière publication a été refusée : le jeton ne fonctionne plus."
|
||||
: n.status === "expired"
|
||||
? "— le jeton a expiré, les publications échoueront."
|
||||
: "— le jeton expire dans moins de 7 jours."}{" "}
|
||||
{n.status === "expired"
|
||||
? "— le jeton a expiré, les publications échoueront."
|
||||
: "— le jeton expire dans moins de 7 jours."}{" "}
|
||||
Reconnecter le compte →
|
||||
</div>
|
||||
))}
|
||||
@@ -143,7 +101,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} hint="visibles sur le site" />
|
||||
<StatCard label="Promotions en ligne" value={livePromos.length} 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,10 +7,7 @@ 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";
|
||||
|
||||
@@ -23,7 +20,6 @@ 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) {
|
||||
@@ -45,30 +41,23 @@ 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", "scheduled"]))
|
||||
.where(inArray(promotions.status, ["pending", "live", "suspended"]))
|
||||
.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");
|
||||
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];
|
||||
// En attente d'abord, puis en ligne, puis suspendues.
|
||||
const ordered = [...pending, ...live, ...suspended];
|
||||
|
||||
const shares = ordered.length
|
||||
? await db
|
||||
@@ -101,9 +90,6 @@ 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>
|
||||
@@ -129,7 +115,6 @@ 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
|
||||
@@ -184,34 +169,13 @@ 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={{ 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 style={{ fontSize: 12.5, color: "#9a6638", fontWeight: 600, marginBottom: 8 }}>
|
||||
{p.memberName}
|
||||
</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"}
|
||||
@@ -250,24 +214,6 @@ export default async function PromotionsPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Date demandée par l'adhérent, modifiable ici. Vidée,
|
||||
la promotion part immédiatement. */}
|
||||
<div style={{ background: "#faf7ef", border: "1px solid #f0e8d6", borderRadius: 10, padding: "10px 11px", marginBottom: 8 }}>
|
||||
<label style={{ display: "block", fontSize: 10.5, letterSpacing: "0.08em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 800, marginBottom: 7 }}>
|
||||
Publication
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
name="publishAt"
|
||||
defaultValue={toScheduleInput(p.publishAt)}
|
||||
style={{ width: "100%", boxSizing: "border-box", border: "1px solid #e6dcc6", borderRadius: 8, padding: "7px 9px", fontSize: 12.5, fontFamily: "inherit", color: "#3c3322", background: "#fff" }}
|
||||
/>
|
||||
<div style={{ fontSize: 11, color: "#a99c82", marginTop: 7, lineHeight: 1.45 }}>
|
||||
{p.publishAt
|
||||
? `Demandé par l'adhérent ${formatSchedule(p.publishAt)}. Videz le champ pour publier tout de suite.`
|
||||
: "Vide : mise en ligne immédiate. Renseignez une date pour programmer."}
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" style={{ width: "100%", border: "none", background: "#1f8a5b", color: "#fff", fontWeight: 700, fontSize: 13, padding: 10, borderRadius: 9, cursor: "pointer" }}>
|
||||
Valider
|
||||
</button>
|
||||
@@ -284,16 +230,6 @@ 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,12 +1,10 @@
|
||||
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,
|
||||
@@ -15,7 +13,6 @@ import {
|
||||
SOCIAL_LABELS,
|
||||
SOCIAL_NETWORKS,
|
||||
type SocialTarget,
|
||||
type TokenHealth,
|
||||
} from "@/lib/social-accounts";
|
||||
import { disconnectSocial, saveSitePublicUrl, saveSocialApp, selectSocialTarget } from "../actions";
|
||||
|
||||
@@ -23,14 +20,7 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
const HELP: Record<
|
||||
(typeof SOCIAL_NETWORKS)[number],
|
||||
{
|
||||
portal: string;
|
||||
steps: string[];
|
||||
caution?: string;
|
||||
tools?: { label: string; href: string }[];
|
||||
/** Les deux liens utiles au moment de renouveler un jeton. */
|
||||
renew: { note: string; links: { label: string; hint: string; href: string }[] };
|
||||
}
|
||||
{ portal: string; steps: string[]; caution?: string }
|
||||
> = {
|
||||
facebook: {
|
||||
portal: "https://developers.facebook.com/apps",
|
||||
@@ -39,29 +29,6 @@ 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",
|
||||
@@ -71,28 +38,7 @@ 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 « 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" },
|
||||
],
|
||||
"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.",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -115,7 +61,10 @@ 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 detected = originFromHeaders(await headers());
|
||||
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}` : "";
|
||||
|
||||
// Calculées ici : le rendu des cartes n'est pas asynchrone.
|
||||
const redirectUris = new Map<string, string>();
|
||||
@@ -123,16 +72,6 @@ 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;
|
||||
@@ -185,9 +124,8 @@ export default async function ReseauxPage({
|
||||
</form>
|
||||
{!base && detected && (
|
||||
<div style={{ marginTop: 10, fontSize: 12.5, color: "#9a6638" }}>
|
||||
Aucune adresse enregistrée : celle de votre navigation, <strong>{detected}</strong>, est
|
||||
utilisée par défaut. Facebook et LinkedIn n'acceptent qu'une adresse de retour
|
||||
en <code>https</code> sur un nom de domaine : vérifiez-la puis enregistrez.
|
||||
Adresse détectée depuis votre navigation : <strong>{detected}</strong>. Vérifiez-la puis
|
||||
enregistrez.
|
||||
</div>
|
||||
)}
|
||||
{!base && !detected && (
|
||||
@@ -203,11 +141,6 @@ 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}>
|
||||
@@ -230,7 +163,7 @@ export default async function ReseauxPage({
|
||||
<h2 className="font-display" style={{ ...title, margin: 0, flex: 1 }}>
|
||||
{SOCIAL_LABELS[network]}
|
||||
</h2>
|
||||
<StatusChip connected={isConnected} status={broken ? "expired" : status} />
|
||||
<StatusChip connected={isConnected} status={status} />
|
||||
</div>
|
||||
|
||||
{isConnected && (
|
||||
@@ -240,30 +173,15 @@ export default async function ReseauxPage({
|
||||
{account?.expiresAt && status !== "never" && ` Jeton valable jusqu'au ${fmtDate(account.expiresAt)}.`}
|
||||
{account?.connectedAt && ` Connecté le ${fmtDate(account.connectedAt)}.`}
|
||||
<br />
|
||||
{tokenHealth?.ok && (
|
||||
<span style={{ color: "#1f8a5b", fontWeight: 600 }}>
|
||||
✓ Jeton vérifié à l'instant auprès de {SOCIAL_LABELS[network]} : il fonctionne.{" "}
|
||||
</span>
|
||||
)}
|
||||
Pour changer de page ou renouveler l'accès, utilisez le bouton ci-dessous.
|
||||
Pour changer de page, relancez une connexion.
|
||||
</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 ne l'aurez pas renouvelé.`
|
||||
: `Le jeton ${SOCIAL_LABELS[network]} expire bientôt. Un clic sur « Renouveler le jeton » suffit.`}
|
||||
? `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.`}
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
@@ -316,91 +234,24 @@ export default async function ReseauxPage({
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Renouvellement : l'action et ses deux liens de contrôle, visibles
|
||||
sans avoir à déplier l'aide de configuration. */}
|
||||
{isConnected && (
|
||||
<div
|
||||
<div style={{ display: "flex", gap: 10, flexWrap: "wrap", borderTop: "1px solid #f0e8d6", paddingTop: 14 }}>
|
||||
<a
|
||||
href={`/api/social/${network}/connect`}
|
||||
className="font-display"
|
||||
style={{
|
||||
background: needsRenewal ? "#fbeede" : "#faf7ef",
|
||||
border: `1px solid ${needsRenewal ? "#ecd8b8" : "#f0e8d6"}`,
|
||||
borderRadius: 12,
|
||||
padding: 15,
|
||||
marginBottom: 16,
|
||||
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",
|
||||
}}
|
||||
>
|
||||
<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 ? "Reconnecter" : "Connecter"}
|
||||
</a>
|
||||
{isConnected && (
|
||||
<form action={disconnectSocial}>
|
||||
<input type="hidden" name="network" value={network} />
|
||||
@@ -426,28 +277,7 @@ export default async function ReseauxPage({
|
||||
{help.portal}
|
||||
</a>
|
||||
</div>
|
||||
{help.tools && (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<div className="field-label">Outils pour inspecter un jeton</div>
|
||||
<ul style={{ margin: "6px 0 0", paddingLeft: 20, fontSize: 13, lineHeight: 1.8 }}>
|
||||
{help.tools.map((tool) => (
|
||||
<li key={tool.href}>
|
||||
<a href={tool.href} target="_blank" rel="noopener noreferrer" style={{ color: "#2C6FB3", fontWeight: 600 }}>
|
||||
{tool.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div style={{ marginTop: 8, fontSize: 12.5, color: "#8c8068", lineHeight: 1.6 }}>
|
||||
Ces outils servent à contrôler ou dépanner : le bouton
|
||||
<strong> Connecter</strong> fait déjà l'é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 style={{ marginTop: 10 }}>
|
||||
<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 —"}
|
||||
|
||||
+98
-5
@@ -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 { PromoCard } from "@/components/PromoCard";
|
||||
import { PromoImage } from "@/components/PromoImage";
|
||||
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
|
||||
import { getActiveMemberCount, getLivePromotions, getRotatingActiveMembers } from "@/lib/queries";
|
||||
import { getSiteSettings, socialLinks } from "@/lib/site-settings";
|
||||
import { SITE_DESCRIPTION, SITE_TITLE, pageMetadata } from "@/lib/seo";
|
||||
import { SITE_DESCRIPTION, SITE_TITLE, memberPath, pageMetadata } from "@/lib/seo";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -26,6 +26,14 @@ 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),
|
||||
@@ -218,14 +226,99 @@ export default async function AccueilPage() {
|
||||
Offres, réductions et nouveautés publiées par les commerçants du réseau.
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/promotions" className="link-arrow" style={{ textDecoration: "none", color: "#9a6638", fontWeight: 700, fontSize: 14.5, whiteSpace: "nowrap" }}>
|
||||
<a href="#promotions" className="link-arrow" style={{ textDecoration: "none", color: "#9a6638", fontWeight: 700, fontSize: 14.5, whiteSpace: "nowrap" }}>
|
||||
Toutes les promos →
|
||||
</Link>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<Reveal className="grid grid-3 reveal-stagger" style={{ marginTop: 26 }}>
|
||||
{promos.map((p) => (
|
||||
<PromoCard key={p.id} promo={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>
|
||||
))}
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Sparkle } from "@/components/Sparkle";
|
||||
import { SiteHeader } from "@/components/SiteHeader";
|
||||
import { SiteFooter } from "@/components/SiteFooter";
|
||||
import { MembershipModalButton } from "@/components/MembershipModalButton";
|
||||
import { PromoCard } from "@/components/PromoCard";
|
||||
import { JsonLd } from "@/components/JsonLd";
|
||||
import { getLivePromotions } from "@/lib/queries";
|
||||
import { breadcrumbJsonLd, memberListJsonLd, pageMetadata } from "@/lib/seo";
|
||||
import { publicBaseUrl } from "@/lib/seo-server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const metadata: Metadata = pageMetadata({
|
||||
title: "Bons plans et promotions du Bassin de Pompey",
|
||||
description:
|
||||
"Toutes les offres en cours chez les commerçants, artisans et entreprises adhérents Plein R : " +
|
||||
"réductions, nouveautés et bons plans à Pompey, Frouard, Liverdun, Champigneulles…",
|
||||
path: "/promotions",
|
||||
});
|
||||
|
||||
export default async function PromotionsPage() {
|
||||
// `null` : toutes les offres en cours, là où l'accueil n'en montre que six.
|
||||
const [promos, baseUrl] = await Promise.all([getLivePromotions(null), publicBaseUrl()]);
|
||||
|
||||
// Une offre pointe vers la fiche de son commerçant : la liste structurée
|
||||
// renvoie donc vers les adhérents, pas vers des URLs de promotion.
|
||||
const listed = promos
|
||||
.filter((p) => p.memberId != null)
|
||||
.map((p) => ({ id: p.memberId as number, name: p.memberName ?? "", city: p.memberCity }));
|
||||
|
||||
return (
|
||||
<div style={{ background: "#F6F2E8", minHeight: "100vh", fontFamily: "'Public Sans',sans-serif", color: "#33291D" }}>
|
||||
<SiteHeader active="promotions" logo />
|
||||
<JsonLd
|
||||
data={[
|
||||
breadcrumbJsonLd(baseUrl, [
|
||||
{ name: "Accueil", path: "/" },
|
||||
{ name: "Promotions", path: "/promotions" },
|
||||
]),
|
||||
memberListJsonLd(baseUrl, "Promotions en cours chez les adhérents Plein R", listed),
|
||||
]}
|
||||
/>
|
||||
|
||||
<main className="container" style={{ paddingBottom: 56 }}>
|
||||
{/* title block */}
|
||||
<section style={{ position: "relative", padding: "14px 0 22px", overflow: "hidden" }}>
|
||||
<Sparkle color="#E0A63C" size={18} style={{ top: 22, right: 30 }} duration={3.2} />
|
||||
<Sparkle color="#6FB0C6" size={12} style={{ top: 60, right: 90 }} duration={2.7} delay={0.5} />
|
||||
<div
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 9,
|
||||
background: "#fff",
|
||||
border: "1px solid #e6dcc6",
|
||||
color: "#9a6638",
|
||||
fontSize: 12,
|
||||
letterSpacing: "0.12em",
|
||||
textTransform: "uppercase",
|
||||
fontWeight: 800,
|
||||
padding: "6px 13px",
|
||||
borderRadius: 999,
|
||||
marginBottom: 14,
|
||||
}}
|
||||
>
|
||||
<span style={{ width: 7, height: 7, borderRadius: "50%", background: "#E0A63C" }} />
|
||||
{promos.length} offre{promos.length > 1 ? "s" : ""} en cours
|
||||
</div>
|
||||
<h1 className="font-display" style={{ fontWeight: 800, fontSize: "clamp(26px, 6vw, 40px)", letterSpacing: "-0.02em", margin: "0 0 8px", color: "#26201a" }}>
|
||||
Les bons plans de nos adhérents
|
||||
</h1>
|
||||
<p style={{ margin: 0, fontSize: 16.5, color: "#6c6150", maxWidth: 560 }}>
|
||||
Toutes les offres en cours chez les commerçants, artisans et entreprises du réseau.
|
||||
Chaque promotion renvoie vers la fiche de son commerce.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{promos.length === 0 ? (
|
||||
<section
|
||||
style={{
|
||||
background: "#fff",
|
||||
border: "1px solid #e6dcc6",
|
||||
borderRadius: 18,
|
||||
padding: "38px 24px",
|
||||
textAlign: "center",
|
||||
color: "#8c8068",
|
||||
fontSize: 15,
|
||||
lineHeight: 1.7,
|
||||
}}
|
||||
>
|
||||
Aucune promotion en cours pour le moment.
|
||||
<br />
|
||||
<Link href="/annuaire" style={{ color: "#9a6638", fontWeight: 700 }}>
|
||||
Parcourir l'annuaire des adhérents →
|
||||
</Link>
|
||||
</section>
|
||||
) : (
|
||||
<div className="grid grid-3" style={{ gap: 20 }}>
|
||||
{promos.map((p) => (
|
||||
<PromoCard key={p.id} promo={p} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* join CTA */}
|
||||
<section
|
||||
style={{
|
||||
marginTop: 34,
|
||||
position: "relative",
|
||||
background: "linear-gradient(120deg,#13324F,#1d4a72)",
|
||||
borderRadius: 20,
|
||||
padding: "34px 40px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 28,
|
||||
overflow: "hidden",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Sparkle color="#E0A63C" size={18} style={{ top: 20, right: 40 }} duration={3.2} />
|
||||
<div>
|
||||
<div style={{ fontSize: 12.5, letterSpacing: "0.12em", textTransform: "uppercase", color: "#9fc6e6", fontWeight: 700, marginBottom: 8 }}>
|
||||
Vous êtes commerçant du Bassin de Pompey ?
|
||||
</div>
|
||||
<h2 className="font-display" style={{ fontWeight: 800, fontSize: 26, margin: 0, color: "#fff" }}>
|
||||
Publiez vos offres sur Plein R
|
||||
</h2>
|
||||
</div>
|
||||
<MembershipModalButton
|
||||
label="Adhérer à l'association"
|
||||
className="font-display"
|
||||
style={{ border: "none", background: "#E0A63C", color: "#33291D", fontWeight: 700, fontSize: 15.5, padding: "14px 26px", borderRadius: 12, whiteSpace: "nowrap" }}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
/**
|
||||
* 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,32 +21,11 @@ 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)}
|
||||
@@ -71,21 +50,15 @@ 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, lineHeight: TITLE_LINE, margin: 0, color: "#26201a", minHeight: 18 * TITLE_LINE * TITLE_LINES, ...clampLines(TITLE_LINES) }}
|
||||
>
|
||||
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 18, margin: 0, color: "#26201a" }}>
|
||||
{m.name}
|
||||
</h3>
|
||||
<p
|
||||
style={{ margin: "6px 0 12px", fontSize: DESC_FONT, color: "#8c8068", lineHeight: DESC_LINE, height: DESC_FONT * DESC_LINE * DESC_LINES, ...clampLines(DESC_LINES) }}
|
||||
>
|
||||
<p style={{ margin: "6px 0 12px", fontSize: 13, color: "#8c8068", lineHeight: 1.5, flex: 1 }}>
|
||||
{m.description}
|
||||
</p>
|
||||
<div style={{ flex: 1 }} />
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 7, fontSize: 12.5, color: "#6c6150", borderTop: "1px solid #f0e8d6", paddingTop: 11 }}>
|
||||
<span style={{ width: 7, height: 7, borderRadius: "50%", background: m.accent ?? "#E0A63C", flex: "0 0 auto" }} />
|
||||
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{location}</span>
|
||||
<span style={{ width: 7, height: 7, borderRadius: "50%", background: m.accent ?? "#E0A63C" }} />
|
||||
{[m.address, m.city].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
/**
|
||||
* 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,7 +6,6 @@ import {
|
||||
text,
|
||||
varchar,
|
||||
boolean,
|
||||
date,
|
||||
timestamp,
|
||||
index,
|
||||
uniqueIndex,
|
||||
@@ -22,10 +21,6 @@ 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.
|
||||
@@ -112,18 +107,11 @@ 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 }),
|
||||
@@ -167,11 +155,6 @@ 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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
/**
|
||||
* 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();
|
||||
@@ -1,21 +0,0 @@
|
||||
/**
|
||||
* 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");
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
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 });
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
+8
-42
@@ -1,22 +1,7 @@
|
||||
import { and, asc, count, desc, eq, sql } from "drizzle-orm";
|
||||
import { and, asc, count, desc, eq } 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({
|
||||
@@ -47,7 +32,7 @@ export async function getLiveBadgesByMember() {
|
||||
return db
|
||||
.select({ memberId: promotions.memberId, badge: promotions.badge })
|
||||
.from(promotions)
|
||||
.where(VISIBLE_PROMO);
|
||||
.where(eq(promotions.status, "live"));
|
||||
}
|
||||
|
||||
export async function getCategoriesWithCounts(limit = 6) {
|
||||
@@ -72,14 +57,8 @@ export async function getCategoriesWithCounts(limit = 6) {
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
export async function getLivePromotions(limit = 6) {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: promotions.id,
|
||||
title: promotions.title,
|
||||
@@ -88,21 +67,16 @@ export async function getLivePromotions(limit: number | null = 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(VISIBLE_PROMO)
|
||||
.where(eq(promotions.status, "live"))
|
||||
.orderBy(desc(promotions.createdAt))
|
||||
.$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);
|
||||
.limit(limit);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function getPublicMember(id: number) {
|
||||
@@ -145,11 +119,9 @@ 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), VISIBLE_PROMO))
|
||||
.where(and(eq(promotions.memberId, memberId), eq(promotions.status, "live")))
|
||||
.orderBy(desc(promotions.createdAt));
|
||||
}
|
||||
|
||||
@@ -246,9 +218,3 @@ 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;
|
||||
}
|
||||
+19
-4
@@ -1,13 +1,28 @@
|
||||
import { publicBaseUrl } from "@/lib/social-accounts";
|
||||
import { headers } from "next/headers";
|
||||
import { siteUrl } 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 ; en dernier recours, l'URL est déduite des
|
||||
* en-têtes de la requête (reverse proxy compris). Voir `publicBaseUrl()`.
|
||||
* 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é.
|
||||
*/
|
||||
export { 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 "";
|
||||
}
|
||||
}
|
||||
|
||||
/** `metadataBase` pour Next : indéfini si aucune URL n'est connue. */
|
||||
export async function metadataBase(): Promise<URL | undefined> {
|
||||
|
||||
@@ -71,7 +71,6 @@ 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" },
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* 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 "";
|
||||
}
|
||||
}
|
||||
+1
-110
@@ -1,9 +1,7 @@
|
||||
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.
|
||||
@@ -45,29 +43,8 @@ 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 publicBaseUrl()}/api/social/${network}/callback`;
|
||||
return `${await siteUrl()}/api/social/${network}/callback`;
|
||||
}
|
||||
|
||||
// ---- Lecture ----
|
||||
@@ -141,92 +118,6 @@ 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 {}
|
||||
|
||||
+4
-9
@@ -12,10 +12,9 @@
|
||||
import {
|
||||
refreshLinkedInIfNeeded,
|
||||
resolveCredentials,
|
||||
publicBaseUrl,
|
||||
siteUrl,
|
||||
type SocialNetwork,
|
||||
} from "./social-accounts";
|
||||
import { formatValidity } from "./promo-validity";
|
||||
import { memberPath } from "./seo";
|
||||
|
||||
export {
|
||||
@@ -36,8 +35,6 @@ 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;
|
||||
@@ -45,11 +42,11 @@ export type PromoForSharing = {
|
||||
};
|
||||
|
||||
export async function promoLink(promo: PromoForSharing): Promise<string | null> {
|
||||
const base = await publicBaseUrl();
|
||||
const base = await siteUrl();
|
||||
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. */
|
||||
@@ -58,9 +55,7 @@ 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());
|
||||
// « Valable du … au … » : la même phrase que sur la carte du site.
|
||||
const validity = formatValidity(promo);
|
||||
if (validity) lines.push("", validity);
|
||||
if (promo.validUntil?.trim()) lines.push("", promo.validUntil.trim());
|
||||
const link = await promoLink(promo);
|
||||
if (link) lines.push("", link);
|
||||
lines.push("", "#PleinR #BassinDePompey #CommerceLocal");
|
||||
|
||||
+4
-11
@@ -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, isHttps: boolean): string {
|
||||
function contentSecurityPolicy(nonce: string, isDev: boolean): string {
|
||||
return [
|
||||
"default-src 'self'",
|
||||
"base-uri 'self'",
|
||||
@@ -35,21 +35,14 @@ function contentSecurityPolicy(nonce: string, isDev: boolean, isHttps: boolean):
|
||||
"connect-src 'self'",
|
||||
// Carte « Nous situer » de la fiche adhérent.
|
||||
"frame-src https://www.google.com https://maps.google.com",
|
||||
// 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("; ");
|
||||
"upgrade-insecure-requests",
|
||||
].join("; ");
|
||||
}
|
||||
|
||||
export default auth((request: NextRequest) => {
|
||||
// `btoa` et non `Buffer` : le middleware tourne sur le runtime Edge.
|
||||
const nonce = btoa(crypto.randomUUID());
|
||||
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);
|
||||
const csp = contentSecurityPolicy(nonce, process.env.NODE_ENV !== "production");
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,100 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
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