mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-23 23:28:04 +02:00
Refonte du bloc infos pratiques, suspension des promos et publication réseaux
Bloc « Informations pratiques » (fiche adhérent) - Suppression des dégradés hors palette (radial doré de la section, dégradés crème/bleu des deux sous-cartes) au profit du vocabulaire visuel du site : carte blanche bordée, sous-cartes plates sur le fond de champ, variables CSS. - La pastille « Préparer votre visite » devient une puce arrondie identique aux badges du reste du site ; le chip d'ouverture reprend les teintes de statut. - Les glyphes texte ◷ et ↗ sont remplacés par des icônes SVG cohérentes. Suspension des promotions - Nouveau statut `suspended` + colonnes `suspended_by` / `suspended_by_id` / `suspended_at`. - L'adhérent suspend et réactive ses propres promotions depuis Mon espace ; l'association suspend et réactive n'importe laquelle depuis le backoffice. - Une suspension décidée par l'association ne peut pas être levée par l'adhérent : le motif est affiché dans son espace. - Les lectures publiques filtrant déjà sur `live`, une promo suspendue quitte immédiatement l'accueil, l'annuaire et la fiche. Publication Facebook / LinkedIn - `src/lib/social.ts` : publication d'une promo en ligne sur la page Facebook (Graph API, upload multipart car les images sont en data-URI) et sur la page LinkedIn (Posts API + Images API en trois étapes). - Jetons d'accès exclusivement en variables d'environnement ; un réseau non configuré masque son bouton. - Table `social_posts` : chaque tentative est tracée, un échec est rattrapé et affiché sur la carte de la promo sans interrompre le backoffice. - Nouvelle capacité RBAC `publishSocial` (admin + modérateur). Liens sociaux publics - Réglages `association_facebook` / `association_linkedin` éditables dans Paramètres, préremplis avec les pages de l'association. - Section « Plein R sur les réseaux » sur l'accueil et icônes dans le pied de page, masquées si le réglage est vide. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
This commit is contained in:
20 files changed
+2370
-109
No files matched your search
@@ -29,3 +29,36 @@ SEED_ADMIN_NAME=Administrateur Plein R
|
|||||||
# ---- App ----
|
# ---- App ----
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
PORT=3000
|
PORT=3000
|
||||||
|
|
||||||
|
# URL publique du site, utilisée dans le texte des publications réseaux sociaux
|
||||||
|
# (lien vers la fiche de l'adhérent). À défaut, AUTH_URL est utilisé.
|
||||||
|
NEXT_PUBLIC_SITE_URL=
|
||||||
|
|
||||||
|
# ---- Publication des promotions sur les réseaux sociaux ----
|
||||||
|
# Facultatif : si ces variables sont vides, le bouton correspondant n'apparaît
|
||||||
|
# pas dans le backoffice (le reste du site fonctionne normalement).
|
||||||
|
# Ce sont des SECRETS : ils ne doivent jamais être saisis dans le backoffice.
|
||||||
|
#
|
||||||
|
# Facebook — page de l'association
|
||||||
|
# 1. Créer une app sur developers.facebook.com (type « Business »).
|
||||||
|
# 2. Ajouter le produit « Facebook Login » et les permissions
|
||||||
|
# pages_manage_posts + pages_read_engagement.
|
||||||
|
# 3. Générer un jeton de PAGE longue durée (Graph API Explorer puis
|
||||||
|
# /oauth/access_token?grant_type=fb_exchange_token) et le coller ci-dessous.
|
||||||
|
FACEBOOK_PAGE_ID=
|
||||||
|
FACEBOOK_PAGE_ACCESS_TOKEN=
|
||||||
|
# Version de la Graph API (facultatif)
|
||||||
|
FACEBOOK_GRAPH_VERSION=v21.0
|
||||||
|
#
|
||||||
|
# LinkedIn — page organisation
|
||||||
|
# 1. Créer une app sur linkedin.com/developers, la rattacher à la page
|
||||||
|
# « Association Plein R — Bassin de Pompey ».
|
||||||
|
# 2. Demander le produit « Community Management API ».
|
||||||
|
# 3. Générer un jeton avec les scopes w_organization_social + r_organization_social.
|
||||||
|
# L'URN se lit dans l'URL d'administration de la page (numéro d'organisation).
|
||||||
|
LINKEDIN_ORGANIZATION_URN=
|
||||||
|
# Alternative à l'URN : uniquement le numéro d'organisation
|
||||||
|
LINKEDIN_ORGANIZATION_ID=
|
||||||
|
LINKEDIN_ACCESS_TOKEN=
|
||||||
|
# Version de l'API LinkedIn (facultatif, format AAAAMM)
|
||||||
|
LINKEDIN_API_VERSION=202506
|
||||||
@@ -41,6 +41,27 @@ docker compose up --build # full stack
|
|||||||
- After changing `src/db/schema.ts`, run `npm run db:generate` and commit the new
|
- After changing `src/db/schema.ts`, run `npm run db:generate` and commit the new
|
||||||
file under `drizzle/`.
|
file under `drizzle/`.
|
||||||
|
|
||||||
|
## Promotions
|
||||||
|
|
||||||
|
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 `status = 'live'`, donc une promo suspendue disparaît du
|
||||||
|
site sans traitement supplémentaire.
|
||||||
|
|
||||||
|
## Réseaux sociaux
|
||||||
|
|
||||||
|
- `src/lib/social.ts` publie une promo sur la page Facebook (Graph API) ou
|
||||||
|
LinkedIn (Posts API). Jetons **uniquement** en variables d'environnement, jamais
|
||||||
|
en base ni dans le backoffice. Réseau non configuré = bouton masqué.
|
||||||
|
- 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.
|
||||||
|
- Chaque tentative est journalisée dans `social_posts` (succès ou échec) ; un
|
||||||
|
échec n'interrompt pas l'action, il s'affiche sur la carte de la promo.
|
||||||
|
- Les URLs publiques des pages FB/LinkedIn sont des `site_settings`
|
||||||
|
(`association_facebook`, `association_linkedin`), éditables dans Paramètres.
|
||||||
|
|
||||||
## Roles
|
## Roles
|
||||||
|
|
||||||
`admin` > `moderator` > `editor` are staff; `member` is an adhérent linked to a
|
`admin` > `moderator` > `editor` are staff; `member` is an adhérent linked to a
|
||||||
|
|||||||
@@ -62,11 +62,43 @@ charge des données de démonstration. Ensuite :
|
|||||||
| Tableau de bord | ✅ | ✅ | ✅ | — |
|
| Tableau de bord | ✅ | ✅ | ✅ | — |
|
||||||
| Adhérents (CRUD) | ✅ | ✅ | ✅ | — |
|
| Adhérents (CRUD) | ✅ | ✅ | ✅ | — |
|
||||||
| Modération des promotions | ✅ | ✅ | — | — |
|
| Modération des promotions | ✅ | ✅ | — | — |
|
||||||
|
| Publication réseaux sociaux | ✅ | ✅ | — | — |
|
||||||
| Administrateurs | ✅ | — | — | — |
|
| Administrateurs | ✅ | — | — | — |
|
||||||
| Mon espace (publier une promo) | — | — | — | ✅ |
|
| Mon espace (publier une promo) | — | — | — | ✅ |
|
||||||
|
|
||||||
`/backend` est protégé par le middleware ; chaque vue affine l'accès selon le rôle.
|
`/backend` est protégé par le middleware ; chaque vue affine l'accès selon le rôle.
|
||||||
|
|
||||||
|
## Cycle de vie d'une promotion
|
||||||
|
|
||||||
|
`pending` → `live` → `suspended` ⇄ `live`, ou suppression.
|
||||||
|
|
||||||
|
- L'adhérent soumet la promotion depuis **Mon espace** : elle part en `pending`.
|
||||||
|
- L'association la valide dans **Backend › Promotions** : elle passe `live` et
|
||||||
|
s'affiche sur l'accueil, l'annuaire et la fiche de l'adhérent.
|
||||||
|
- **Suspension** : l'adhérent peut suspendre *ses* promotions depuis Mon espace,
|
||||||
|
l'association peut suspendre n'importe laquelle. Une promotion suspendue sort
|
||||||
|
immédiatement du site public et peut être remise en ligne à tout moment.
|
||||||
|
Nuance importante : une suspension décidée par l'association ne peut pas être
|
||||||
|
levée par l'adhérent (`promotions.suspended_by` mémorise l'auteur).
|
||||||
|
|
||||||
|
## Publication sur Facebook / LinkedIn
|
||||||
|
|
||||||
|
Depuis **Backend › Promotions**, une promotion `live` peut être publiée sur la
|
||||||
|
page Facebook et/ou la page LinkedIn de l'association (image + texte + lien vers
|
||||||
|
la fiche adhérent). Chaque tentative est tracée dans la table `social_posts`
|
||||||
|
(succès avec le lien du post, ou échec avec le message d'erreur affiché sur la
|
||||||
|
carte).
|
||||||
|
|
||||||
|
Les jetons d'accès sont des **secrets** : ils se configurent uniquement par
|
||||||
|
variables d'environnement (`FACEBOOK_PAGE_ID`, `FACEBOOK_PAGE_ACCESS_TOKEN`,
|
||||||
|
`LINKEDIN_ORGANIZATION_URN` ou `LINKEDIN_ORGANIZATION_ID`,
|
||||||
|
`LINKEDIN_ACCESS_TOKEN`), jamais depuis le backoffice. Voir
|
||||||
|
[`.env.example`](./.env.example) pour la marche à suivre côté Meta et LinkedIn.
|
||||||
|
Si un réseau n'est pas configuré, son bouton n'apparaît simplement pas.
|
||||||
|
|
||||||
|
Les **liens publics** vers les deux pages (affichés sur l'accueil et dans le pied
|
||||||
|
de page) se règlent, eux, dans **Backend › Paramètres**.
|
||||||
|
|
||||||
## Développement local (sans Docker)
|
## Développement local (sans Docker)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -98,6 +130,9 @@ Voir [`.env.example`](./.env.example). Les principales :
|
|||||||
- `AUTH_URL` — URL publique de l'application
|
- `AUTH_URL` — URL publique de l'application
|
||||||
- `SEED_ON_START` — `true` pour seeder au démarrage du conteneur
|
- `SEED_ON_START` — `true` pour seeder au démarrage du conteneur
|
||||||
- `SEED_ADMIN_EMAIL` / `SEED_ADMIN_PASSWORD` / `SEED_ADMIN_NAME` — premier admin
|
- `SEED_ADMIN_EMAIL` / `SEED_ADMIN_PASSWORD` / `SEED_ADMIN_NAME` — premier admin
|
||||||
|
- `NEXT_PUBLIC_SITE_URL` — URL publique reprise dans les posts réseaux sociaux
|
||||||
|
- `FACEBOOK_PAGE_ID` / `FACEBOOK_PAGE_ACCESS_TOKEN` — publication Facebook (optionnel)
|
||||||
|
- `LINKEDIN_ORGANIZATION_URN` / `LINKEDIN_ACCESS_TOKEN` — publication LinkedIn (optionnel)
|
||||||
|
|
||||||
## Note sur le logo
|
## Note sur le logo
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,16 @@ services:
|
|||||||
AUTH_URL: ${AUTH_URL:-}
|
AUTH_URL: ${AUTH_URL:-}
|
||||||
# Lets Auth.js trust the X-Forwarded-* headers from the proxy.
|
# Lets Auth.js trust the X-Forwarded-* headers from the proxy.
|
||||||
AUTH_TRUST_HOST: "true"
|
AUTH_TRUST_HOST: "true"
|
||||||
|
# URL publique, reprise dans le texte des posts Facebook / LinkedIn.
|
||||||
|
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-}
|
||||||
|
# Publication des promotions sur les réseaux (facultatif — voir .env.example).
|
||||||
|
FACEBOOK_PAGE_ID: ${FACEBOOK_PAGE_ID:-}
|
||||||
|
FACEBOOK_PAGE_ACCESS_TOKEN: ${FACEBOOK_PAGE_ACCESS_TOKEN:-}
|
||||||
|
FACEBOOK_GRAPH_VERSION: ${FACEBOOK_GRAPH_VERSION:-}
|
||||||
|
LINKEDIN_ORGANIZATION_URN: ${LINKEDIN_ORGANIZATION_URN:-}
|
||||||
|
LINKEDIN_ORGANIZATION_ID: ${LINKEDIN_ORGANIZATION_ID:-}
|
||||||
|
LINKEDIN_ACCESS_TOKEN: ${LINKEDIN_ACCESS_TOKEN:-}
|
||||||
|
LINKEDIN_API_VERSION: ${LINKEDIN_API_VERSION:-}
|
||||||
SEED_ON_START: ${SEED_ON_START:-true}
|
SEED_ON_START: ${SEED_ON_START:-true}
|
||||||
SEED_ADMIN_EMAIL: ${SEED_ADMIN_EMAIL:-admin@plein-r.fr}
|
SEED_ADMIN_EMAIL: ${SEED_ADMIN_EMAIL:-admin@plein-r.fr}
|
||||||
SEED_ADMIN_PASSWORD: ${SEED_ADMIN_PASSWORD:-changeme123}
|
SEED_ADMIN_PASSWORD: ${SEED_ADMIN_PASSWORD:-changeme123}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
CREATE TYPE "public"."promo_suspended_by" AS ENUM('member', 'staff');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."social_network" AS ENUM('facebook', 'linkedin');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."social_post_status" AS ENUM('posted', 'failed');--> statement-breakpoint
|
||||||
|
ALTER TYPE "public"."promo_status" ADD VALUE 'suspended';--> statement-breakpoint
|
||||||
|
CREATE TABLE "social_posts" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"promotion_id" integer NOT NULL,
|
||||||
|
"network" "social_network" NOT NULL,
|
||||||
|
"status" "social_post_status" NOT NULL,
|
||||||
|
"external_id" varchar(200),
|
||||||
|
"url" text,
|
||||||
|
"error" text,
|
||||||
|
"posted_by_id" integer,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "promotions" ADD COLUMN "suspended_by" "promo_suspended_by";--> statement-breakpoint
|
||||||
|
ALTER TABLE "promotions" ADD COLUMN "suspended_by_id" integer;--> statement-breakpoint
|
||||||
|
ALTER TABLE "promotions" ADD COLUMN "suspended_at" timestamp with time zone;--> statement-breakpoint
|
||||||
|
ALTER TABLE "social_posts" ADD CONSTRAINT "social_posts_promotion_id_promotions_id_fk" FOREIGN KEY ("promotion_id") REFERENCES "public"."promotions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "social_posts" ADD CONSTRAINT "social_posts_posted_by_id_users_id_fk" FOREIGN KEY ("posted_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "social_posts_promotion_idx" ON "social_posts" USING btree ("promotion_id");--> statement-breakpoint
|
||||||
|
ALTER TABLE "promotions" ADD CONSTRAINT "promotions_suspended_by_id_users_id_fk" FOREIGN KEY ("suspended_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -43,6 +43,13 @@
|
|||||||
"when": 1784339279276,
|
"when": 1784339279276,
|
||||||
"tag": "0005_round_exiles",
|
"tag": "0005_round_exiles",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 6,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1784930674056,
|
||||||
|
"tag": "0006_tiresome_clea",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -38,7 +38,7 @@ function initialsOf(name: string) {
|
|||||||
return name.split(/\s+/).map((w) => w[0]).join("").slice(0, 2).toUpperCase();
|
return name.split(/\s+/).map((w) => w[0]).join("").slice(0, 2).toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
type ContactIconName = "phone" | "mail" | "website" | "pin";
|
type ContactIconName = "phone" | "mail" | "website" | "pin" | "clock" | "chat";
|
||||||
|
|
||||||
function ContactIcon({ name }: { name: ContactIconName }) {
|
function ContactIcon({ name }: { name: ContactIconName }) {
|
||||||
const paths: Record<ContactIconName, React.ReactNode> = {
|
const paths: Record<ContactIconName, React.ReactNode> = {
|
||||||
@@ -46,6 +46,8 @@ function ContactIcon({ name }: { name: ContactIconName }) {
|
|||||||
mail: <><rect x="2.5" y="4.5" width="19" height="15" rx="2.5" /><path d="m4 6 8 6 8-6" /></>,
|
mail: <><rect x="2.5" y="4.5" width="19" height="15" rx="2.5" /><path d="m4 6 8 6 8-6" /></>,
|
||||||
website: <><circle cx="12" cy="12" r="9.5" /><path d="M2.8 12h18.4M12 2.5c2.4 2.6 3.6 5.8 3.6 9.5S14.4 18.9 12 21.5M12 2.5C9.6 5.1 8.4 8.3 8.4 12s1.2 6.9 3.6 9.5" /></>,
|
website: <><circle cx="12" cy="12" r="9.5" /><path d="M2.8 12h18.4M12 2.5c2.4 2.6 3.6 5.8 3.6 9.5S14.4 18.9 12 21.5M12 2.5C9.6 5.1 8.4 8.3 8.4 12s1.2 6.9 3.6 9.5" /></>,
|
||||||
pin: <><path d="M20 10c0 5.5-8 11.5-8 11.5S4 15.5 4 10a8 8 0 1 1 16 0Z" /><circle cx="12" cy="10" r="2.5" /></>,
|
pin: <><path d="M20 10c0 5.5-8 11.5-8 11.5S4 15.5 4 10a8 8 0 1 1 16 0Z" /><circle cx="12" cy="10" r="2.5" /></>,
|
||||||
|
clock: <><circle cx="12" cy="12" r="9.5" /><path d="M12 6.6V12l3.6 2.4" /></>,
|
||||||
|
chat: <><path d="M20.5 12.6c0 3.9-3.8 7.1-8.5 7.1-.9 0-1.8-.1-2.6-.3l-5.4 1.7 1.7-4.2a6.6 6.6 0 0 1-2.2-4.8c0-3.9 3.8-7.1 8.5-7.1s8.5 3.2 8.5 7.1Z" /><path d="M8.8 11.9h.01M12 11.9h.01M15.2 11.9h.01" /></>,
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||||
@@ -247,7 +249,7 @@ export default async function FicheAdherentPage({
|
|||||||
<div className="practical-section__heading">
|
<div className="practical-section__heading">
|
||||||
<div>
|
<div>
|
||||||
<span className="eyebrow">Préparer votre visite</span>
|
<span className="eyebrow">Préparer votre visite</span>
|
||||||
<h2 className="font-display" style={{ ...h2, margin: "5px 0 0" }}>Informations pratiques</h2>
|
<h2 className="font-display" style={{ ...h2, margin: "10px 0 0" }}>Informations pratiques</h2>
|
||||||
</div>
|
</div>
|
||||||
{openStatus && (
|
{openStatus && (
|
||||||
<div className={`open-status${openStatus.isOpen ? " is-open" : ""}`}>
|
<div className={`open-status${openStatus.isOpen ? " is-open" : ""}`}>
|
||||||
@@ -263,7 +265,7 @@ export default async function FicheAdherentPage({
|
|||||||
<div className="practical-grid">
|
<div className="practical-grid">
|
||||||
<section className="schedule-card" aria-labelledby="schedule-title">
|
<section className="schedule-card" aria-labelledby="schedule-title">
|
||||||
<div className="practical-card-title">
|
<div className="practical-card-title">
|
||||||
<span className="practical-card-title__icon" aria-hidden="true">◷</span>
|
<span className="practical-card-title__icon" aria-hidden="true"><ContactIcon name="clock" /></span>
|
||||||
<div>
|
<div>
|
||||||
<span>Cette semaine</span>
|
<span>Cette semaine</span>
|
||||||
<h3 id="schedule-title" className="font-display">Horaires d'ouverture</h3>
|
<h3 id="schedule-title" className="font-display">Horaires d'ouverture</h3>
|
||||||
@@ -297,7 +299,7 @@ export default async function FicheAdherentPage({
|
|||||||
|
|
||||||
<section className="contact-card" aria-labelledby="contact-title">
|
<section className="contact-card" aria-labelledby="contact-title">
|
||||||
<div className="practical-card-title">
|
<div className="practical-card-title">
|
||||||
<span className="practical-card-title__icon practical-card-title__icon--blue" aria-hidden="true">↗</span>
|
<span className="practical-card-title__icon practical-card-title__icon--blue" aria-hidden="true"><ContactIcon name="chat" /></span>
|
||||||
<div>
|
<div>
|
||||||
<span>Contact direct</span>
|
<span>Contact direct</span>
|
||||||
<h3 id="contact-title" className="font-display">Joindre l'établissement</h3>
|
<h3 id="contact-title" className="font-display">Joindre l'établissement</h3>
|
||||||
|
|||||||
+151
-7
@@ -20,9 +20,17 @@ import {
|
|||||||
pastMeetings,
|
pastMeetings,
|
||||||
promotions,
|
promotions,
|
||||||
siteSettings,
|
siteSettings,
|
||||||
|
socialPosts,
|
||||||
users,
|
users,
|
||||||
} from "@/db/schema";
|
} from "@/db/schema";
|
||||||
import { can, LABEL_TO_ROLE } from "@/lib/rbac";
|
import { can, LABEL_TO_ROLE } from "@/lib/rbac";
|
||||||
|
import {
|
||||||
|
isNetworkConfigured,
|
||||||
|
publishPromoToNetwork,
|
||||||
|
SOCIAL_LABELS,
|
||||||
|
SOCIAL_NETWORKS,
|
||||||
|
type SocialNetwork,
|
||||||
|
} from "@/lib/social";
|
||||||
import { normalizeWebsite } from "@/lib/member-profile";
|
import { normalizeWebsite } from "@/lib/member-profile";
|
||||||
import { SITE_SETTING_DEFAULTS } from "@/lib/site-settings";
|
import { SITE_SETTING_DEFAULTS } from "@/lib/site-settings";
|
||||||
import type { AppRole } from "@/types/next-auth";
|
import type { AppRole } from "@/types/next-auth";
|
||||||
@@ -62,13 +70,20 @@ function generateTempPassword(): string {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requireRole(): Promise<{ role: AppRole; memberId: number | null; name: string }> {
|
async function requireRole(): Promise<{
|
||||||
|
role: AppRole;
|
||||||
|
memberId: number | null;
|
||||||
|
name: string;
|
||||||
|
userId: number | null;
|
||||||
|
}> {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user) throw new Error("Non authentifié");
|
if (!session?.user) throw new Error("Non authentifié");
|
||||||
|
const userId = Number(session.user.id);
|
||||||
return {
|
return {
|
||||||
role: session.user.role,
|
role: session.user.role,
|
||||||
memberId: session.user.memberId,
|
memberId: session.user.memberId,
|
||||||
name: session.user.name ?? "Adhérent",
|
name: session.user.name ?? "Adhérent",
|
||||||
|
userId: Number.isFinite(userId) ? userId : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,26 +102,155 @@ function asNullableString(formData: FormData, key: string) {
|
|||||||
return asString(formData, key) || null;
|
return asString(formData, key) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function revalidatePromoPaths(memberId?: number | null) {
|
||||||
|
revalidatePath("/backend/promotions");
|
||||||
|
revalidatePath("/backend/espace");
|
||||||
|
revalidatePath("/backend");
|
||||||
|
revalidatePath("/");
|
||||||
|
revalidatePath("/annuaire");
|
||||||
|
if (memberId) revalidatePath(`/adherents/${memberId}`);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Promotion moderation ----
|
// ---- Promotion moderation ----
|
||||||
export async function moderatePromo(formData: FormData) {
|
export async function moderatePromo(formData: FormData) {
|
||||||
const { role } = await requireRole();
|
const { role, userId, name } = await requireRole();
|
||||||
if (!can(role, "moderatePromos")) throw new Error("Accès refusé");
|
if (!can(role, "moderatePromos")) throw new Error("Accès refusé");
|
||||||
|
|
||||||
const id = Number(formData.get("id"));
|
const id = Number(formData.get("id"));
|
||||||
const action = String(formData.get("action"));
|
const action = String(formData.get("action"));
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
|
|
||||||
|
const [promo] = await db.select().from(promotions).where(eq(promotions.id, id));
|
||||||
|
if (!promo) return;
|
||||||
|
|
||||||
if (action === "approve") {
|
if (action === "approve") {
|
||||||
await db.update(promotions).set({ status: "live" }).where(eq(promotions.id, id));
|
await db
|
||||||
const [p] = await db.select().from(promotions).where(eq(promotions.id, id));
|
.update(promotions)
|
||||||
if (p) await logActivity(`Promotion « ${p.title} » validée et mise en ligne`, "#1f8a5b");
|
.set({ status: "live", suspendedBy: null, suspendedById: null, suspendedAt: null })
|
||||||
|
.where(eq(promotions.id, id));
|
||||||
|
await logActivity(`Promotion « ${promo.title} » validée et mise en ligne`, "#1f8a5b");
|
||||||
|
} 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.
|
||||||
|
await db
|
||||||
|
.update(promotions)
|
||||||
|
.set({ status: "suspended", suspendedBy: "staff", suspendedById: userId, suspendedAt: new Date() })
|
||||||
|
.where(eq(promotions.id, id));
|
||||||
|
await logActivity(
|
||||||
|
`Promotion « ${promo.title} » suspendue par <strong>${name}</strong>`,
|
||||||
|
"#d8472b"
|
||||||
|
);
|
||||||
|
} else if (action === "restore") {
|
||||||
|
await db
|
||||||
|
.update(promotions)
|
||||||
|
.set({ status: "live", suspendedBy: null, suspendedById: null, suspendedAt: null })
|
||||||
|
.where(eq(promotions.id, id));
|
||||||
|
await logActivity(`Promotion « ${promo.title} » remise en ligne`, "#1f8a5b");
|
||||||
} else if (action === "reject" || action === "remove") {
|
} else if (action === "reject" || action === "remove") {
|
||||||
await db.delete(promotions).where(eq(promotions.id, id));
|
await db.delete(promotions).where(eq(promotions.id, id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
revalidatePromoPaths(promo.memberId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Member space: suspendre / réactiver sa propre promotion ----
|
||||||
|
export async function setOwnPromoSuspension(formData: FormData) {
|
||||||
|
const { memberId, userId, name } = await requireRole();
|
||||||
|
if (!memberId) throw new Error("Aucune fiche adhérent liée à votre compte.");
|
||||||
|
|
||||||
|
const id = Number(formData.get("id"));
|
||||||
|
const action = String(formData.get("action"));
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
const [promo] = await db
|
||||||
|
.select()
|
||||||
|
.from(promotions)
|
||||||
|
.where(and(eq(promotions.id, id), eq(promotions.memberId, memberId)));
|
||||||
|
if (!promo) throw new Error("Promotion introuvable.");
|
||||||
|
|
||||||
|
if (action === "suspend") {
|
||||||
|
if (promo.status !== "live") return;
|
||||||
|
await db
|
||||||
|
.update(promotions)
|
||||||
|
.set({ status: "suspended", suspendedBy: "member", suspendedById: userId, suspendedAt: new Date() })
|
||||||
|
.where(eq(promotions.id, id));
|
||||||
|
await logActivity(`<strong>${name}</strong> a suspendu sa promotion « ${promo.title} »`, "#9a6638");
|
||||||
|
} else if (action === "restore") {
|
||||||
|
if (promo.status !== "suspended") return;
|
||||||
|
// Une suspension décidée par l'association ne se lève que par elle.
|
||||||
|
if (promo.suspendedBy === "staff") {
|
||||||
|
throw new Error(
|
||||||
|
"Cette promotion a été suspendue par l'association : contactez-la pour la remettre en ligne."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await db
|
||||||
|
.update(promotions)
|
||||||
|
.set({ status: "live", suspendedBy: null, suspendedById: null, suspendedAt: null })
|
||||||
|
.where(eq(promotions.id, id));
|
||||||
|
await logActivity(`<strong>${name}</strong> a réactivé sa promotion « ${promo.title} »`, "#1f8a5b");
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePromoPaths(memberId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Publication d'une promotion sur Facebook / LinkedIn ----
|
||||||
|
export async function sharePromoToSocial(formData: FormData) {
|
||||||
|
const { role, userId, name } = await requireRole();
|
||||||
|
if (!can(role, "publishSocial")) throw new Error("Accès refusé");
|
||||||
|
|
||||||
|
const id = Number(formData.get("id"));
|
||||||
|
const network = String(formData.get("network")) as SocialNetwork;
|
||||||
|
if (!id || !SOCIAL_NETWORKS.includes(network)) return;
|
||||||
|
if (!isNetworkConfigured(network)) {
|
||||||
|
throw new Error(`${SOCIAL_LABELS[network]} n'est pas configuré sur ce serveur.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [promo] = await db
|
||||||
|
.select({
|
||||||
|
id: promotions.id,
|
||||||
|
title: promotions.title,
|
||||||
|
text: promotions.text,
|
||||||
|
badge: promotions.badge,
|
||||||
|
validUntil: promotions.validUntil,
|
||||||
|
imageUrl: promotions.imageUrl,
|
||||||
|
status: promotions.status,
|
||||||
|
memberId: promotions.memberId,
|
||||||
|
memberName: members.name,
|
||||||
|
})
|
||||||
|
.from(promotions)
|
||||||
|
.leftJoin(members, eq(promotions.memberId, members.id))
|
||||||
|
.where(eq(promotions.id, id));
|
||||||
|
if (!promo) throw new Error("Promotion introuvable.");
|
||||||
|
if (promo.status !== "live") {
|
||||||
|
throw new Error("Seule une promotion en ligne peut être publiée sur les réseaux.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await publishPromoToNetwork(network, promo);
|
||||||
|
await db.insert(socialPosts).values({
|
||||||
|
promotionId: promo.id,
|
||||||
|
network,
|
||||||
|
status: "posted",
|
||||||
|
externalId: result.externalId,
|
||||||
|
url: result.url,
|
||||||
|
postedById: userId,
|
||||||
|
});
|
||||||
|
await logActivity(
|
||||||
|
`Promotion « ${promo.title} » publiée sur ${SOCIAL_LABELS[network]} par <strong>${name}</strong>`,
|
||||||
|
"#2C6FB3"
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Erreur inconnue";
|
||||||
|
await db.insert(socialPosts).values({
|
||||||
|
promotionId: promo.id,
|
||||||
|
network,
|
||||||
|
status: "failed",
|
||||||
|
error: message.slice(0, 2000),
|
||||||
|
postedById: userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
revalidatePath("/backend/promotions");
|
revalidatePath("/backend/promotions");
|
||||||
revalidatePath("/backend");
|
|
||||||
revalidatePath("/");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Member space: publish a promotion ----
|
// ---- Member space: publish a promotion ----
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { ImageField } from "@/components/ImageField";
|
|||||||
import { ImageConsentForm } from "@/components/ImageConsentForm";
|
import { ImageConsentForm } from "@/components/ImageConsentForm";
|
||||||
import { HoursEditor } from "@/components/HoursEditor";
|
import { HoursEditor } from "@/components/HoursEditor";
|
||||||
import { communeOptions } from "@/lib/communes";
|
import { communeOptions } from "@/lib/communes";
|
||||||
import { saveImageConsent, updateOwnProfile } from "../actions";
|
import { saveImageConsent, setOwnPromoSuspension, updateOwnProfile } from "../actions";
|
||||||
import { MemberSpaceForm } from "./MemberSpaceForm";
|
import { MemberSpaceForm } from "./MemberSpaceForm";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -16,6 +16,7 @@ const PROMO_STATUS: Record<string, { label: string; bg: string; color: string }>
|
|||||||
live: { label: "En ligne", bg: "#e6f4ec", color: "#1f8a5b" },
|
live: { label: "En ligne", bg: "#e6f4ec", color: "#1f8a5b" },
|
||||||
pending: { label: "En attente", bg: "#fbeede", color: "#9a6638" },
|
pending: { label: "En attente", bg: "#fbeede", color: "#9a6638" },
|
||||||
rejected: { label: "Refusée", bg: "#fbe9e6", color: "#d8472b" },
|
rejected: { label: "Refusée", bg: "#fbe9e6", color: "#d8472b" },
|
||||||
|
suspended: { label: "Suspendue", bg: "#fbe9e6", color: "#d8472b" },
|
||||||
expired: { label: "Expirée", bg: "#f1efe7", color: "#a99c82" },
|
expired: { label: "Expirée", bg: "#f1efe7", color: "#a99c82" },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -31,7 +32,14 @@ export default async function EspacePage() {
|
|||||||
let memberName = fallbackName;
|
let memberName = fallbackName;
|
||||||
let subtitle = "Espace adhérent";
|
let subtitle = "Espace adhérent";
|
||||||
let profile: Member | null = null;
|
let profile: Member | null = null;
|
||||||
let myPromos: { id: number; title: string; status: string; createdAt: Date; imageUrl: string | null }[] = [];
|
let myPromos: {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
createdAt: Date;
|
||||||
|
imageUrl: string | null;
|
||||||
|
suspendedBy: "member" | "staff" | null;
|
||||||
|
}[] = [];
|
||||||
let myRegistrations: { meetingId: number; title: string; startsAt: Date; location: string | null; participants: number; confirmed: boolean }[] = [];
|
let myRegistrations: { meetingId: number; title: string; startsAt: Date; location: string | null; participants: number; confirmed: boolean }[] = [];
|
||||||
let latestConsent: { decision: string; createdAt: Date } | null = null;
|
let latestConsent: { decision: string; createdAt: Date } | null = null;
|
||||||
|
|
||||||
@@ -52,6 +60,7 @@ export default async function EspacePage() {
|
|||||||
status: promotions.status,
|
status: promotions.status,
|
||||||
createdAt: promotions.createdAt,
|
createdAt: promotions.createdAt,
|
||||||
imageUrl: promotions.imageUrl,
|
imageUrl: promotions.imageUrl,
|
||||||
|
suspendedBy: promotions.suspendedBy,
|
||||||
})
|
})
|
||||||
.from(promotions)
|
.from(promotions)
|
||||||
.where(eq(promotions.memberId, memberId))
|
.where(eq(promotions.memberId, memberId))
|
||||||
@@ -241,38 +250,70 @@ export default async function EspacePage() {
|
|||||||
<MemberSpaceForm memberName={memberName} categories={categoryLabels} />
|
<MemberSpaceForm memberName={memberName} categories={categoryLabels} />
|
||||||
|
|
||||||
<div style={{ marginTop: 28 }}>
|
<div style={{ marginTop: 28 }}>
|
||||||
<div style={{ fontSize: 12.5, letterSpacing: "0.08em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 700, marginBottom: 12 }}>
|
<div style={{ fontSize: 12.5, letterSpacing: "0.08em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 700, marginBottom: 4 }}>
|
||||||
Mes promotions
|
Mes promotions
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ fontSize: 13, color: "#8c8068", marginBottom: 12 }}>
|
||||||
|
Vous pouvez suspendre une promotion en ligne à tout moment : elle disparaît du site et
|
||||||
|
vous la remettez en ligne quand vous voulez.
|
||||||
|
</div>
|
||||||
{myPromos.length === 0 && (
|
{myPromos.length === 0 && (
|
||||||
<div style={{ fontSize: 13.5, color: "#a99c82" }}>Vous n'avez pas encore publié de promotion.</div>
|
<div style={{ fontSize: 13.5, color: "#a99c82" }}>Vous n'avez pas encore publié de promotion.</div>
|
||||||
)}
|
)}
|
||||||
{myPromos.map((mp) => {
|
{myPromos.map((mp) => {
|
||||||
const st = PROMO_STATUS[mp.status] ?? PROMO_STATUS.expired;
|
const st = PROMO_STATUS[mp.status] ?? PROMO_STATUS.expired;
|
||||||
|
const suspendedByStaff = mp.status === "suspended" && mp.suspendedBy === "staff";
|
||||||
return (
|
return (
|
||||||
<div key={mp.id} style={{ display: "flex", alignItems: "center", gap: 13, background: "#fff", border: "1px solid #e6dcc6", borderRadius: 12, padding: "12px 14px", marginBottom: 10 }}>
|
<div key={mp.id} style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 12, padding: "12px 14px", marginBottom: 10 }}>
|
||||||
<span
|
<div style={{ display: "flex", alignItems: "center", gap: 13 }}>
|
||||||
style={{
|
<span
|
||||||
width: 42,
|
style={{
|
||||||
height: 42,
|
width: 42,
|
||||||
borderRadius: 9,
|
height: 42,
|
||||||
background: mp.imageUrl
|
borderRadius: 9,
|
||||||
? `url(${mp.imageUrl})`
|
background: mp.imageUrl
|
||||||
: "repeating-linear-gradient(45deg,#efe9da,#efe9da 7px,#e6ddc9 7px,#e6ddc9 14px)",
|
? `url(${mp.imageUrl})`
|
||||||
backgroundSize: "cover",
|
: "repeating-linear-gradient(45deg,#efe9da,#efe9da 7px,#e6ddc9 7px,#e6ddc9 14px)",
|
||||||
backgroundPosition: "center",
|
backgroundSize: "cover",
|
||||||
flexShrink: 0,
|
backgroundPosition: "center",
|
||||||
}}
|
flexShrink: 0,
|
||||||
/>
|
opacity: mp.status === "suspended" ? 0.5 : 1,
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
}}
|
||||||
<div style={{ fontSize: 14, fontWeight: 700, color: "#26201a", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
/>
|
||||||
{mp.title}
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 700, color: "#26201a", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||||
|
{mp.title}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: "#a99c82" }}>Publié le {fmtDate(mp.createdAt)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 12, color: "#a99c82" }}>Publié le {fmtDate(mp.createdAt)}</div>
|
<span style={{ display: "inline-block", fontSize: 11.5, fontWeight: 700, padding: "4px 11px", borderRadius: 999, background: st.bg, color: st.color, flexShrink: 0 }}>
|
||||||
|
{st.label}
|
||||||
|
</span>
|
||||||
|
{mp.status === "live" && (
|
||||||
|
<form action={setOwnPromoSuspension}>
|
||||||
|
<input type="hidden" name="id" value={mp.id} />
|
||||||
|
<input type="hidden" name="action" value="suspend" />
|
||||||
|
<button type="submit" style={{ border: "1px solid #e0c3bb", background: "#fff", color: "#d8472b", fontWeight: 700, fontSize: 12.5, padding: "7px 13px", borderRadius: 9, cursor: "pointer", whiteSpace: "nowrap" }}>
|
||||||
|
Suspendre
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
{mp.status === "suspended" && !suspendedByStaff && (
|
||||||
|
<form action={setOwnPromoSuspension}>
|
||||||
|
<input type="hidden" name="id" value={mp.id} />
|
||||||
|
<input type="hidden" name="action" value="restore" />
|
||||||
|
<button type="submit" style={{ border: "none", background: "#1f8a5b", color: "#fff", fontWeight: 700, fontSize: 12.5, padding: "8px 14px", borderRadius: 9, cursor: "pointer", whiteSpace: "nowrap" }}>
|
||||||
|
Remettre en ligne
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span style={{ display: "inline-block", fontSize: 11.5, fontWeight: 700, padding: "4px 11px", borderRadius: 999, background: st.bg, color: st.color }}>
|
{suspendedByStaff && (
|
||||||
{st.label}
|
<div style={{ marginTop: 10, background: "#fbe9e6", border: "1px solid #f2d5cf", color: "#a8503c", borderRadius: 9, padding: "9px 11px", fontSize: 12.5, lineHeight: 1.5 }}>
|
||||||
</span>
|
Cette promotion a été suspendue par l'association. Contactez-la pour la
|
||||||
|
remettre en ligne.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -25,12 +25,30 @@ export default async function ParametresPage() {
|
|||||||
<Field label="Téléphone" name="association_phone" value={settings.association_phone} />
|
<Field label="Téléphone" name="association_phone" value={settings.association_phone} />
|
||||||
<Field label="Site web" name="association_website" value={settings.association_website} />
|
<Field label="Site web" name="association_website" value={settings.association_website} />
|
||||||
<Field label="Contact principal" name="association_contact" value={settings.association_contact} />
|
<Field label="Contact principal" name="association_contact" value={settings.association_contact} />
|
||||||
|
<Field
|
||||||
|
label="Page Facebook"
|
||||||
|
name="association_facebook"
|
||||||
|
value={settings.association_facebook}
|
||||||
|
placeholder="https://www.facebook.com/..."
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Page LinkedIn"
|
||||||
|
name="association_linkedin"
|
||||||
|
value={settings.association_linkedin}
|
||||||
|
placeholder="https://www.linkedin.com/company/..."
|
||||||
|
/>
|
||||||
<div style={{ gridColumn: "1 / -1" }}>
|
<div style={{ gridColumn: "1 / -1" }}>
|
||||||
<Field label="Adresse" name="association_address" value={settings.association_address} />
|
<Field label="Adresse" name="association_address" value={settings.association_address} />
|
||||||
</div>
|
</div>
|
||||||
<Area label="Texte d'introduction Association" name="association_intro" value={settings.association_intro} />
|
<Area label="Texte d'introduction Association" name="association_intro" value={settings.association_intro} />
|
||||||
<Area label="Mission" name="association_mission" value={settings.association_mission} />
|
<Area label="Mission" name="association_mission" value={settings.association_mission} />
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ marginTop: 14, color: "#9a6638", fontSize: 13, lineHeight: 1.6 }}>
|
||||||
|
Les pages Facebook et LinkedIn s'affichent sur la page d'accueil et dans le pied
|
||||||
|
de page. Laissez un champ vide pour masquer le réseau correspondant. La publication
|
||||||
|
automatique des promotions, elle, se configure côté serveur (jetons d'accès dans les
|
||||||
|
variables d'environnement).
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section style={panel}>
|
<section style={panel}>
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import { redirect } from "next/navigation";
|
|||||||
import { desc, eq, inArray } from "drizzle-orm";
|
import { desc, eq, inArray } from "drizzle-orm";
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { members, promotions } from "@/db/schema";
|
import { members, promotions, socialPosts, users } from "@/db/schema";
|
||||||
import { can } from "@/lib/rbac";
|
import { can } from "@/lib/rbac";
|
||||||
import { moderatePromo } from "../actions";
|
import { configuredNetworks, SOCIAL_LABELS, type SocialNetwork } from "@/lib/social";
|
||||||
|
import { moderatePromo, sharePromoToSocial } from "../actions";
|
||||||
import { PromoImage } from "@/components/PromoImage";
|
import { PromoImage } from "@/components/PromoImage";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -14,11 +15,23 @@ const STRIPE_WARM =
|
|||||||
const STRIPE_COOL =
|
const STRIPE_COOL =
|
||||||
"repeating-linear-gradient(45deg,#eef0ec,#eef0ec 11px,#e2e8e6 11px,#e2e8e6 22px)";
|
"repeating-linear-gradient(45deg,#eef0ec,#eef0ec 11px,#e2e8e6 11px,#e2e8e6 22px)";
|
||||||
|
|
||||||
|
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" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function fmtDate(d: Date) {
|
||||||
|
return new Date(d).toLocaleDateString("fr-FR", { day: "numeric", month: "long", year: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
export default async function PromotionsPage() {
|
export default async function PromotionsPage() {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!can(session?.user.role, "moderatePromos")) {
|
if (!can(session?.user.role, "moderatePromos")) {
|
||||||
redirect("/backend");
|
redirect("/backend");
|
||||||
}
|
}
|
||||||
|
const canShare = can(session?.user.role, "publishSocial");
|
||||||
|
const networks = canShare ? configuredNetworks() : [];
|
||||||
|
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({
|
.select({
|
||||||
@@ -28,17 +41,45 @@ export default async function PromotionsPage() {
|
|||||||
category: promotions.category,
|
category: promotions.category,
|
||||||
status: promotions.status,
|
status: promotions.status,
|
||||||
imageUrl: promotions.imageUrl,
|
imageUrl: promotions.imageUrl,
|
||||||
|
suspendedBy: promotions.suspendedBy,
|
||||||
|
suspendedAt: promotions.suspendedAt,
|
||||||
memberName: members.name,
|
memberName: members.name,
|
||||||
})
|
})
|
||||||
.from(promotions)
|
.from(promotions)
|
||||||
.leftJoin(members, eq(promotions.memberId, members.id))
|
.leftJoin(members, eq(promotions.memberId, members.id))
|
||||||
.where(inArray(promotions.status, ["pending", "live"]))
|
.where(inArray(promotions.status, ["pending", "live", "suspended"]))
|
||||||
.orderBy(desc(promotions.status), desc(promotions.createdAt));
|
.orderBy(desc(promotions.createdAt));
|
||||||
|
|
||||||
const pending = rows.filter((r) => r.status === "pending");
|
const pending = rows.filter((r) => r.status === "pending");
|
||||||
const live = rows.filter((r) => r.status === "live");
|
const live = rows.filter((r) => r.status === "live");
|
||||||
// pending first, then live
|
const suspended = rows.filter((r) => r.status === "suspended");
|
||||||
const ordered = [...pending, ...live];
|
// En attente d'abord, puis en ligne, puis suspendues.
|
||||||
|
const ordered = [...pending, ...live, ...suspended];
|
||||||
|
|
||||||
|
const shares = ordered.length
|
||||||
|
? await db
|
||||||
|
.select({
|
||||||
|
promotionId: socialPosts.promotionId,
|
||||||
|
network: socialPosts.network,
|
||||||
|
status: socialPosts.status,
|
||||||
|
url: socialPosts.url,
|
||||||
|
error: socialPosts.error,
|
||||||
|
createdAt: socialPosts.createdAt,
|
||||||
|
authorName: users.name,
|
||||||
|
})
|
||||||
|
.from(socialPosts)
|
||||||
|
.leftJoin(users, eq(socialPosts.postedById, users.id))
|
||||||
|
.where(inArray(socialPosts.promotionId, ordered.map((r) => r.id)))
|
||||||
|
.orderBy(desc(socialPosts.createdAt))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// La liste est triée du plus récent au plus ancien : la première occurrence
|
||||||
|
// rencontrée pour un couple (promo, réseau) est donc la plus récente.
|
||||||
|
const lastShare = new Map<string, (typeof shares)[number]>();
|
||||||
|
for (const s of shares) {
|
||||||
|
const key = `${s.promotionId}:${s.network}`;
|
||||||
|
if (!lastShare.has(key)) lastShare.set(key, s);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -49,8 +90,18 @@ export default async function PromotionsPage() {
|
|||||||
<span style={{ background: "#fff", border: "1px solid #e6dcc6", color: "#6c6150", fontSize: 13, fontWeight: 600, padding: "8px 16px", borderRadius: 999 }}>
|
<span style={{ background: "#fff", border: "1px solid #e6dcc6", color: "#6c6150", fontSize: 13, fontWeight: 600, padding: "8px 16px", borderRadius: 999 }}>
|
||||||
En ligne · {live.length}
|
En ligne · {live.length}
|
||||||
</span>
|
</span>
|
||||||
|
<span style={{ background: "#fff", border: "1px solid #e6dcc6", color: "#6c6150", fontSize: 13, fontWeight: 600, padding: "8px 16px", borderRadius: 999 }}>
|
||||||
|
Suspendues · {suspended.length}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{canShare && networks.length === 0 && (
|
||||||
|
<div style={{ background: "#fbeede", border: "1px solid #ecd8b8", color: "#9a6638", borderRadius: 12, padding: "12px 16px", marginBottom: 18, fontSize: 13.5, lineHeight: 1.55 }}>
|
||||||
|
Publication sur les réseaux indisponible : aucun jeton d'accès Facebook ou LinkedIn
|
||||||
|
n'est configuré sur le serveur (voir <code>.env.example</code>).
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{ordered.length === 0 && (
|
{ordered.length === 0 && (
|
||||||
<div style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 16, padding: 28, color: "#a99c82", fontSize: 14 }}>
|
<div style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 16, padding: 28, color: "#a99c82", fontSize: 14 }}>
|
||||||
Aucune promotion pour le moment.
|
Aucune promotion pour le moment.
|
||||||
@@ -60,6 +111,8 @@ export default async function PromotionsPage() {
|
|||||||
<div className="grid grid-3" style={{ gap: 16, width: "100%", maxWidth: 960 }}>
|
<div className="grid grid-3" style={{ gap: 16, width: "100%", maxWidth: 960 }}>
|
||||||
{ordered.map((p) => {
|
{ordered.map((p) => {
|
||||||
const isPending = p.status === "pending";
|
const isPending = p.status === "pending";
|
||||||
|
const isSuspended = p.status === "suspended";
|
||||||
|
const chip = STATUS_CHIP[p.status] ?? STATUS_CHIP.pending;
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
key={p.id}
|
key={p.id}
|
||||||
@@ -81,6 +134,7 @@ export default async function PromotionsPage() {
|
|||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
|
opacity: isSuspended ? 0.55 : 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{p.imageUrl && <PromoImage src={p.imageUrl} alt={p.title ?? ""} />}
|
{p.imageUrl && <PromoImage src={p.imageUrl} alt={p.title ?? ""} />}
|
||||||
@@ -98,11 +152,11 @@ export default async function PromotionsPage() {
|
|||||||
padding: "4px 11px",
|
padding: "4px 11px",
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontWeight: 800,
|
fontWeight: 800,
|
||||||
background: isPending ? "#fbeede" : "#e6f4ec",
|
background: chip.bg,
|
||||||
color: isPending ? "#9a6638" : "#1f8a5b",
|
color: chip.color,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isPending ? "En attente" : "En ligne"}
|
{chip.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding: "13px 14px 14px", display: "flex", flexDirection: "column", flex: 1 }}>
|
<div style={{ padding: "13px 14px 14px", display: "flex", flexDirection: "column", flex: 1 }}>
|
||||||
@@ -116,7 +170,14 @@ export default async function PromotionsPage() {
|
|||||||
{p.text}
|
{p.text}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{isPending ? (
|
{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"}
|
||||||
|
{p.suspendedAt ? ` le ${fmtDate(p.suspendedAt)}` : ""}.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isPending && (
|
||||||
<div style={{ display: "flex", gap: 8 }}>
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
<form action={moderatePromo} style={{ flex: 1 }}>
|
<form action={moderatePromo} style={{ flex: 1 }}>
|
||||||
<input type="hidden" name="id" value={p.id} />
|
<input type="hidden" name="id" value={p.id} />
|
||||||
@@ -133,18 +194,82 @@ export default async function PromotionsPage() {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
)}
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", borderTop: "1px solid #f0e8d6", paddingTop: 11 }}>
|
|
||||||
<span style={{ fontSize: 12.5, color: "#1f8a5b", fontWeight: 700 }}>● En ligne</span>
|
{!isPending && (
|
||||||
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
|
<form action={moderatePromo} style={{ flex: 1 }}>
|
||||||
|
<input type="hidden" name="id" value={p.id} />
|
||||||
|
<input type="hidden" name="action" value={isSuspended ? "restore" : "suspend"} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
border: isSuspended ? "none" : "1px solid #e0c3bb",
|
||||||
|
background: isSuspended ? "#1f8a5b" : "#fff",
|
||||||
|
color: isSuspended ? "#fff" : "#d8472b",
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: 13,
|
||||||
|
padding: 10,
|
||||||
|
borderRadius: 9,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isSuspended ? "Remettre en ligne" : "Suspendre"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
<form action={moderatePromo}>
|
<form action={moderatePromo}>
|
||||||
<input type="hidden" name="id" value={p.id} />
|
<input type="hidden" name="id" value={p.id} />
|
||||||
<input type="hidden" name="action" value="remove" />
|
<input type="hidden" name="action" value="remove" />
|
||||||
<button type="submit" style={{ border: "none", background: "transparent", color: "#a99c82", fontWeight: 700, fontSize: 12.5, cursor: "pointer" }}>
|
<button type="submit" style={{ border: "1px solid #e6dcc6", background: "#fff", color: "#a99c82", fontWeight: 700, fontSize: 13, padding: "10px 12px", borderRadius: 9, cursor: "pointer" }}>
|
||||||
Retirer
|
Supprimer
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{networks.length > 0 && (
|
||||||
|
<div style={{ borderTop: "1px solid #f0e8d6", marginTop: 12, paddingTop: 11 }}>
|
||||||
|
<div style={{ fontSize: 10.5, letterSpacing: "0.1em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 800, marginBottom: 8 }}>
|
||||||
|
Partager sur les réseaux
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||||
|
{networks.map((network) => (
|
||||||
|
<ShareButton
|
||||||
|
key={network}
|
||||||
|
network={network}
|
||||||
|
promoId={p.id}
|
||||||
|
disabled={p.status !== "live"}
|
||||||
|
last={lastShare.get(`${p.id}:${network}`)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{networks.map((network) => {
|
||||||
|
const last = lastShare.get(`${p.id}:${network}`);
|
||||||
|
if (!last) return null;
|
||||||
|
return (
|
||||||
|
<div key={network} style={{ marginTop: 8, fontSize: 11.5, lineHeight: 1.45, color: last.status === "posted" ? "#6c6150" : "#d8472b" }}>
|
||||||
|
{last.status === "posted" ? (
|
||||||
|
<>
|
||||||
|
✓ {SOCIAL_LABELS[network]} · {fmtDate(last.createdAt)}
|
||||||
|
{last.authorName ? ` · ${last.authorName}` : ""}
|
||||||
|
{last.url && (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
<a href={last.url} target="_blank" rel="noopener noreferrer" style={{ color: "#2C6FB3", fontWeight: 700 }}>
|
||||||
|
voir
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>✕ {SOCIAL_LABELS[network]} — échec : {last.error?.slice(0, 160)}</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
@@ -153,3 +278,43 @@ export default async function PromotionsPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ShareButton({
|
||||||
|
network,
|
||||||
|
promoId,
|
||||||
|
disabled,
|
||||||
|
last,
|
||||||
|
}: {
|
||||||
|
network: SocialNetwork;
|
||||||
|
promoId: number;
|
||||||
|
disabled: boolean;
|
||||||
|
last?: { status: string };
|
||||||
|
}) {
|
||||||
|
const alreadyPosted = last?.status === "posted";
|
||||||
|
const brand = network === "facebook" ? "#1877F2" : "#0A66C2";
|
||||||
|
return (
|
||||||
|
<form action={sharePromoToSocial} style={{ flex: "1 1 120px" }}>
|
||||||
|
<input type="hidden" name="id" value={promoId} />
|
||||||
|
<input type="hidden" name="network" value={network} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={disabled}
|
||||||
|
title={disabled ? "Seule une promotion en ligne peut être publiée." : undefined}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
border: `1px solid ${brand}`,
|
||||||
|
background: alreadyPosted ? "#fff" : brand,
|
||||||
|
color: alreadyPosted ? brand : "#fff",
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: 12.5,
|
||||||
|
padding: "9px 10px",
|
||||||
|
borderRadius: 9,
|
||||||
|
cursor: disabled ? "not-allowed" : "pointer",
|
||||||
|
opacity: disabled ? 0.45 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{alreadyPosted ? `Republier sur ${SOCIAL_LABELS[network]}` : `Publier sur ${SOCIAL_LABELS[network]}`}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
+68
-55
@@ -580,11 +580,9 @@ img {
|
|||||||
.practical-section {
|
.practical-section {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: 26px 28px 28px;
|
padding: 26px 28px 28px;
|
||||||
border: 1px solid #e6dcc6;
|
border: 1px solid var(--border);
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
background:
|
background: #fff;
|
||||||
radial-gradient(circle at 100% 0, rgba(224, 166, 60, 0.12), transparent 190px),
|
|
||||||
#fff;
|
|
||||||
}
|
}
|
||||||
.practical-section__heading {
|
.practical-section__heading {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -593,31 +591,44 @@ img {
|
|||||||
gap: 18px;
|
gap: 18px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
/* Pastille de section, identique aux badges « Bons plans » du reste du site. */
|
||||||
.eyebrow {
|
.eyebrow {
|
||||||
color: #9a6638;
|
display: inline-flex;
|
||||||
font-size: 10.5px;
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 5px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #fdeccd;
|
||||||
|
color: var(--brown);
|
||||||
|
font-size: 11.5px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
letter-spacing: 0.14em;
|
letter-spacing: 0.1em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
.eyebrow::before {
|
||||||
|
content: "";
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--gold);
|
||||||
|
}
|
||||||
.open-status {
|
.open-status {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 9px;
|
gap: 9px;
|
||||||
min-width: 154px;
|
min-width: 154px;
|
||||||
padding: 8px 12px;
|
padding: 8px 13px;
|
||||||
border: 1px solid #e7d3ce;
|
border: 1px solid #f2d5cf;
|
||||||
border-radius: 12px;
|
border-radius: 999px;
|
||||||
background: #fff8f6;
|
background: #fbe9e6;
|
||||||
color: #9f3e2e;
|
color: var(--red);
|
||||||
}
|
}
|
||||||
.open-status > span {
|
.open-status > span {
|
||||||
width: 9px;
|
width: 8px;
|
||||||
height: 9px;
|
height: 8px;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #d8472b;
|
background: var(--red);
|
||||||
box-shadow: 0 0 0 4px rgba(216, 71, 43, 0.12);
|
|
||||||
}
|
}
|
||||||
.open-status div {
|
.open-status div {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -626,23 +637,25 @@ img {
|
|||||||
}
|
}
|
||||||
.open-status strong {
|
.open-status strong {
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
|
font-weight: 800;
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
}
|
}
|
||||||
.open-status small {
|
.open-status small {
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
color: #916a61;
|
color: #b1786c;
|
||||||
font-size: 10.5px;
|
font-size: 10.5px;
|
||||||
}
|
}
|
||||||
.open-status.is-open {
|
.open-status.is-open {
|
||||||
border-color: #cce7d7;
|
border-color: #c7e6d5;
|
||||||
background: #f1faf5;
|
background: #e6f4ec;
|
||||||
color: #176c48;
|
color: var(--green);
|
||||||
}
|
}
|
||||||
.open-status.is-open > span {
|
.open-status.is-open > span {
|
||||||
background: #1f8a5b;
|
background: var(--green);
|
||||||
box-shadow: 0 0 0 4px rgba(31, 138, 91, 0.12);
|
}
|
||||||
|
.open-status.is-open small {
|
||||||
|
color: #6d9d86;
|
||||||
}
|
}
|
||||||
.open-status.is-open small { color: #5d7f6e; }
|
|
||||||
.practical-grid {
|
.practical-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1.08fr) minmax(0, 0.92fr);
|
grid-template-columns: minmax(0, 1.08fr) minmax(0, 0.92fr);
|
||||||
@@ -651,22 +664,17 @@ img {
|
|||||||
.schedule-card,
|
.schedule-card,
|
||||||
.contact-card {
|
.contact-card {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
border: 1px solid #eadfca;
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
}
|
border: 1px solid var(--border-2);
|
||||||
.schedule-card {
|
border-radius: 16px;
|
||||||
background: linear-gradient(145deg, #fffdf8, #fbf5e8);
|
background: var(--field-bg);
|
||||||
}
|
|
||||||
.contact-card {
|
|
||||||
background: linear-gradient(145deg, #fff, #f3f7fa);
|
|
||||||
}
|
}
|
||||||
.practical-card-title {
|
.practical-card-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 11px;
|
gap: 11px;
|
||||||
padding-bottom: 14px;
|
padding-bottom: 14px;
|
||||||
border-bottom: 1px solid rgba(216, 205, 180, 0.68);
|
border-bottom: 1px solid var(--border-2);
|
||||||
}
|
}
|
||||||
.practical-card-title__icon {
|
.practical-card-title__icon {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -675,19 +683,21 @@ img {
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
border-radius: 11px;
|
border-radius: 11px;
|
||||||
background: #f3dfb6;
|
background: #f6efdc;
|
||||||
color: #8c5f2e;
|
color: var(--brown);
|
||||||
font-size: 20px;
|
}
|
||||||
font-weight: 800;
|
.practical-card-title__icon svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
}
|
}
|
||||||
.practical-card-title__icon--blue {
|
.practical-card-title__icon--blue {
|
||||||
background: #dfeef5;
|
background: #eaf0f6;
|
||||||
color: #2c6fb3;
|
color: var(--blue);
|
||||||
}
|
}
|
||||||
.practical-card-title span:not(.practical-card-title__icon) {
|
.practical-card-title span:not(.practical-card-title__icon) {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
color: #a09279;
|
color: var(--muted-3);
|
||||||
font-size: 9.5px;
|
font-size: 9.5px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
letter-spacing: 0.12em;
|
letter-spacing: 0.12em;
|
||||||
@@ -695,8 +705,9 @@ img {
|
|||||||
}
|
}
|
||||||
.practical-card-title h3 {
|
.practical-card-title h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #26201a;
|
color: var(--ink-strong);
|
||||||
font-size: 15.5px;
|
font-size: 15.5px;
|
||||||
|
font-weight: 700;
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
}
|
}
|
||||||
.schedule-list {
|
.schedule-list {
|
||||||
@@ -710,14 +721,15 @@ img {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
min-height: 38px;
|
min-height: 38px;
|
||||||
padding: 7px 8px;
|
padding: 7px 9px;
|
||||||
border-radius: 9px;
|
border: 1px solid transparent;
|
||||||
color: #655b4c;
|
border-radius: 10px;
|
||||||
|
color: var(--muted);
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
}
|
}
|
||||||
.schedule-row.is-today {
|
.schedule-row.is-today {
|
||||||
|
border-color: var(--border);
|
||||||
background: #fff;
|
background: #fff;
|
||||||
box-shadow: 0 5px 16px -12px rgba(80, 55, 22, 0.58);
|
|
||||||
}
|
}
|
||||||
.schedule-row__day {
|
.schedule-row__day {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -727,20 +739,20 @@ img {
|
|||||||
}
|
}
|
||||||
.schedule-row__day small {
|
.schedule-row__day small {
|
||||||
margin-top: 1px;
|
margin-top: 1px;
|
||||||
color: #9a6638;
|
color: var(--brown);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
.schedule-row > strong {
|
.schedule-row > strong {
|
||||||
color: #352d22;
|
color: var(--ink-soft);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
.schedule-row > strong.is-closed {
|
.schedule-row > strong.is-closed {
|
||||||
color: #b16a5e;
|
color: var(--muted-4);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.contact-actions {
|
.contact-actions {
|
||||||
@@ -758,12 +770,12 @@ img {
|
|||||||
padding: 10px;
|
padding: 10px;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: 11px;
|
border-radius: 11px;
|
||||||
color: #4d4437;
|
color: var(--ink);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: background 160ms ease, border-color 160ms ease, transform 160ms ease;
|
transition: background 160ms ease, border-color 160ms ease, transform 160ms ease;
|
||||||
}
|
}
|
||||||
.contact-action:hover {
|
.contact-action:hover {
|
||||||
border-color: #d7e4eb;
|
border-color: var(--border);
|
||||||
background: #fff;
|
background: #fff;
|
||||||
transform: translateX(2px);
|
transform: translateX(2px);
|
||||||
}
|
}
|
||||||
@@ -773,8 +785,8 @@ img {
|
|||||||
height: 34px;
|
height: 34px;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: #eaf2f7;
|
background: #eaf0f6;
|
||||||
color: #2c6fb3;
|
color: var(--blue);
|
||||||
}
|
}
|
||||||
.contact-action__icon svg {
|
.contact-action__icon svg {
|
||||||
width: 17px;
|
width: 17px;
|
||||||
@@ -786,24 +798,25 @@ img {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
.contact-action__copy strong {
|
.contact-action__copy strong {
|
||||||
color: #33291d;
|
color: var(--ink-strong);
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
.contact-action__copy > span {
|
.contact-action__copy > span {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
color: #7d715f;
|
color: var(--muted-2);
|
||||||
font-size: 10.5px;
|
font-size: 10.5px;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.contact-action__arrow {
|
.contact-action__arrow {
|
||||||
color: #7f9db5;
|
color: var(--muted-4);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
.practical-empty {
|
.practical-empty {
|
||||||
padding: 20px 4px 8px;
|
padding: 20px 4px 8px;
|
||||||
color: #9b8e78;
|
color: var(--muted-4);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+101
-1
@@ -7,7 +7,9 @@ import { MembershipModalButton } from "@/components/MembershipModalButton";
|
|||||||
import { MembersCarousel } from "@/components/MembersCarousel";
|
import { MembersCarousel } from "@/components/MembersCarousel";
|
||||||
import { StarField } from "@/components/StarField";
|
import { StarField } from "@/components/StarField";
|
||||||
import { PromoImage } from "@/components/PromoImage";
|
import { PromoImage } from "@/components/PromoImage";
|
||||||
|
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
|
||||||
import { getLivePromotions, getRotatingActiveMembers } from "@/lib/queries";
|
import { getLivePromotions, getRotatingActiveMembers } from "@/lib/queries";
|
||||||
|
import { getSiteSettings, socialLinks } from "@/lib/site-settings";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -20,10 +22,12 @@ function badgeColor(badge: string | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function AccueilPage() {
|
export default async function AccueilPage() {
|
||||||
const [promos, rotatingMembers] = await Promise.all([
|
const [promos, rotatingMembers, settings] = await Promise.all([
|
||||||
getLivePromotions(6),
|
getLivePromotions(6),
|
||||||
getRotatingActiveMembers(),
|
getRotatingActiveMembers(),
|
||||||
|
getSiteSettings(),
|
||||||
]);
|
]);
|
||||||
|
const socials = socialLinks(settings);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -318,6 +322,102 @@ export default async function AccueilPage() {
|
|||||||
<MembersCarousel members={rotatingMembers} />
|
<MembersCarousel members={rotatingMembers} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* réseaux sociaux */}
|
||||||
|
{socials.length > 0 && (
|
||||||
|
<section
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
margin: "0 0 46px",
|
||||||
|
background: "#fff",
|
||||||
|
border: "1px solid #e6dcc6",
|
||||||
|
borderRadius: 24,
|
||||||
|
padding: "34px 36px 36px",
|
||||||
|
overflow: "hidden",
|
||||||
|
boxShadow: "0 26px 60px -40px rgba(40,30,15,0.5)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Sparkle color="#6FB0C6" size={18} style={{ top: 26, right: 42 }} duration={3.1} />
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
background: "#eaf0f6",
|
||||||
|
color: "#2C6FB3",
|
||||||
|
fontSize: 12,
|
||||||
|
letterSpacing: "0.12em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
fontWeight: 800,
|
||||||
|
padding: "6px 13px",
|
||||||
|
borderRadius: 999,
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ width: 8, height: 8, borderRadius: "50%", background: "#2C6FB3" }} />
|
||||||
|
Suivez-nous
|
||||||
|
</div>
|
||||||
|
<h2
|
||||||
|
className="font-display"
|
||||||
|
style={{ fontWeight: 800, fontSize: "clamp(22px, 5vw, 30px)", letterSpacing: "-0.02em", margin: 0, color: "#26201a" }}
|
||||||
|
>
|
||||||
|
Plein R sur les réseaux
|
||||||
|
</h2>
|
||||||
|
<p style={{ margin: "7px 0 0", color: "#8c8068", fontSize: 15, maxWidth: 560 }}>
|
||||||
|
Bons plans, rencontres et actualités des commerçants du Bassin de Pompey : retrouvez-nous
|
||||||
|
sur Facebook et LinkedIn.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-2" style={{ gap: 16, marginTop: 24 }}>
|
||||||
|
{socials.map((s) => (
|
||||||
|
<a
|
||||||
|
key={s.network}
|
||||||
|
href={s.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="lift-card"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 14,
|
||||||
|
textDecoration: "none",
|
||||||
|
color: "inherit",
|
||||||
|
background: "#faf7ef",
|
||||||
|
border: "1px solid #f0e8d6",
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: "16px 18px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 13,
|
||||||
|
background: SOCIAL_BRAND[s.network],
|
||||||
|
color: "#fff",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SocialIcon network={s.network} size={22} />
|
||||||
|
</span>
|
||||||
|
<span style={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<span className="font-display" style={{ display: "block", fontWeight: 700, fontSize: 16, color: "#26201a" }}>
|
||||||
|
{s.label}
|
||||||
|
</span>
|
||||||
|
<span style={{ display: "block", fontSize: 12.5, color: "#8c8068", marginTop: 2, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||||
|
{s.handle}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span style={{ color: "#2C6FB3", fontWeight: 700, fontSize: 14, flexShrink: 0 }}>↗</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* warm CTA */}
|
{/* warm CTA */}
|
||||||
<section
|
<section
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { getSiteSettings, socialLinks } from "@/lib/site-settings";
|
||||||
import { ContactModalButton } from "./ContactModalButton";
|
import { ContactModalButton } from "./ContactModalButton";
|
||||||
|
import { SOCIAL_BRAND, SocialIcon } from "./SocialIcons";
|
||||||
|
|
||||||
export function SiteFooter() {
|
export async function SiteFooter() {
|
||||||
const year = new Date().getFullYear();
|
const year = new Date().getFullYear();
|
||||||
|
const socials = socialLinks(await getSiteSettings());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer style={{ background: "#EFE9DA", borderTop: "1px solid #e2d6bd" }}>
|
<footer style={{ background: "#EFE9DA", borderTop: "1px solid #e2d6bd" }}>
|
||||||
@@ -17,6 +20,33 @@ export function SiteFooter() {
|
|||||||
Association des commerçants et entreprises du Bassin de Pompey.
|
Association des commerçants et entreprises du Bassin de Pompey.
|
||||||
Réseau · Rencontre · Réussite.
|
Réseau · Rencontre · Réussite.
|
||||||
</p>
|
</p>
|
||||||
|
{socials.length > 0 && (
|
||||||
|
<div style={{ display: "flex", gap: 9, marginTop: 16 }}>
|
||||||
|
{socials.map((s) => (
|
||||||
|
<a
|
||||||
|
key={s.network}
|
||||||
|
href={s.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label={`Plein R sur ${s.label}`}
|
||||||
|
title={`Plein R sur ${s.label}`}
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: 38,
|
||||||
|
height: 38,
|
||||||
|
borderRadius: 11,
|
||||||
|
border: "1px solid #e2d6bd",
|
||||||
|
background: "#fff",
|
||||||
|
color: SOCIAL_BRAND[s.network],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SocialIcon network={s.network} />
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { SocialLink } from "@/lib/site-settings";
|
||||||
|
|
||||||
|
const PATHS: Record<SocialLink["network"], string> = {
|
||||||
|
facebook:
|
||||||
|
"M22 12.06C22 6.5 17.52 2 12 2S2 6.5 2 12.06c0 5.02 3.66 9.18 8.44 9.94v-7.03H7.9v-2.91h2.54V9.85c0-2.52 1.5-3.91 3.77-3.91 1.09 0 2.23.2 2.23.2v2.46h-1.26c-1.24 0-1.63.78-1.63 1.57v1.89h2.78l-.44 2.91h-2.34V22c4.78-.76 8.44-4.92 8.44-9.94Z",
|
||||||
|
linkedin:
|
||||||
|
"M6.94 5.5a2.06 2.06 0 1 1-4.12 0 2.06 2.06 0 0 1 4.12 0ZM6.6 9.05H3.15V21H6.6V9.05Zm5.52 0H8.8V21h3.32v-6.27c0-3.1 4-3.35 4-.01V21h3.33v-7.42c0-5.26-5.94-5.07-7.33-2.48V9.05Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SOCIAL_BRAND: Record<SocialLink["network"], string> = {
|
||||||
|
facebook: "#1877F2",
|
||||||
|
linkedin: "#0A66C2",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SocialIcon({ network, size = 18 }: { network: SocialLink["network"]; size?: number }) {
|
||||||
|
return (
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor" width={size} height={size} style={{ display: "block" }}>
|
||||||
|
<path d={PATHS[network]} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
+41
-1
@@ -20,7 +20,13 @@ export const promoStatusEnum = pgEnum("promo_status", [
|
|||||||
"live",
|
"live",
|
||||||
"expired",
|
"expired",
|
||||||
"rejected",
|
"rejected",
|
||||||
|
"suspended",
|
||||||
]);
|
]);
|
||||||
|
// 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.
|
||||||
|
export const promoSuspendedByEnum = pgEnum("promo_suspended_by", ["member", "staff"]);
|
||||||
|
export const socialNetworkEnum = pgEnum("social_network", ["facebook", "linkedin"]);
|
||||||
|
export const socialPostStatusEnum = pgEnum("social_post_status", ["posted", "failed"]);
|
||||||
export const requestStatusEnum = pgEnum("request_status", [
|
export const requestStatusEnum = pgEnum("request_status", [
|
||||||
"new",
|
"new",
|
||||||
"approved",
|
"approved",
|
||||||
@@ -94,9 +100,33 @@ export const promotions = pgTable("promotions", {
|
|||||||
memberId: integer("member_id").references(() => members.id),
|
memberId: integer("member_id").references(() => members.id),
|
||||||
status: promoStatusEnum("status").notNull().default("pending"),
|
status: promoStatusEnum("status").notNull().default("pending"),
|
||||||
validUntil: varchar("valid_until", { length: 120 }),
|
validUntil: varchar("valid_until", { length: 120 }),
|
||||||
|
suspendedBy: promoSuspendedByEnum("suspended_by"),
|
||||||
|
suspendedById: integer("suspended_by_id").references(() => users.id, { onDelete: "set null" }),
|
||||||
|
suspendedAt: timestamp("suspended_at", { withTimezone: true }),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- Publications réseaux sociaux (Facebook / LinkedIn) ----
|
||||||
|
export const socialPosts = pgTable(
|
||||||
|
"social_posts",
|
||||||
|
{
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
promotionId: integer("promotion_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => promotions.id, { onDelete: "cascade" }),
|
||||||
|
network: socialNetworkEnum("network").notNull(),
|
||||||
|
status: socialPostStatusEnum("status").notNull(),
|
||||||
|
externalId: varchar("external_id", { length: 200 }),
|
||||||
|
url: text("url"),
|
||||||
|
error: text("error"),
|
||||||
|
postedById: integer("posted_by_id").references(() => users.id, { onDelete: "set null" }),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
promoIdx: index("social_posts_promotion_idx").on(t.promotionId),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
// ---- Membership requests (demandes d'adhésion) ----
|
// ---- Membership requests (demandes d'adhésion) ----
|
||||||
export const membershipRequests = pgTable("membership_requests", {
|
export const membershipRequests = pgTable("membership_requests", {
|
||||||
id: serial("id").primaryKey(),
|
id: serial("id").primaryKey(),
|
||||||
@@ -218,11 +248,19 @@ export const membersRelations = relations(members, ({ one, many }) => ({
|
|||||||
imageConsents: many(imageConsents),
|
imageConsents: many(imageConsents),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const promotionsRelations = relations(promotions, ({ one }) => ({
|
export const promotionsRelations = relations(promotions, ({ one, many }) => ({
|
||||||
member: one(members, {
|
member: one(members, {
|
||||||
fields: [promotions.memberId],
|
fields: [promotions.memberId],
|
||||||
references: [members.id],
|
references: [members.id],
|
||||||
}),
|
}),
|
||||||
|
socialPosts: many(socialPosts),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const socialPostsRelations = relations(socialPosts, ({ one }) => ({
|
||||||
|
promotion: one(promotions, {
|
||||||
|
fields: [socialPosts.promotionId],
|
||||||
|
references: [promotions.id],
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const usersRelations = relations(users, ({ one }) => ({
|
export const usersRelations = relations(users, ({ one }) => ({
|
||||||
@@ -273,6 +311,8 @@ export type Category = typeof categories.$inferSelect;
|
|||||||
export type Member = typeof members.$inferSelect;
|
export type Member = typeof members.$inferSelect;
|
||||||
export type User = typeof users.$inferSelect;
|
export type User = typeof users.$inferSelect;
|
||||||
export type Promotion = typeof promotions.$inferSelect;
|
export type Promotion = typeof promotions.$inferSelect;
|
||||||
|
export type SocialPost = typeof socialPosts.$inferSelect;
|
||||||
|
export type SocialNetwork = (typeof socialNetworkEnum.enumValues)[number];
|
||||||
export type MembershipRequest = typeof membershipRequests.$inferSelect;
|
export type MembershipRequest = typeof membershipRequests.$inferSelect;
|
||||||
export type ContactMessage = typeof contactMessages.$inferSelect;
|
export type ContactMessage = typeof contactMessages.$inferSelect;
|
||||||
export type ActivityEntry = typeof activityLog.$inferSelect;
|
export type ActivityEntry = typeof activityLog.$inferSelect;
|
||||||
|
|||||||
+9
-1
@@ -18,6 +18,7 @@ type Capability =
|
|||||||
| "viewDashboard"
|
| "viewDashboard"
|
||||||
| "manageMembers"
|
| "manageMembers"
|
||||||
| "moderatePromos"
|
| "moderatePromos"
|
||||||
|
| "publishSocial"
|
||||||
| "manageAdmins"
|
| "manageAdmins"
|
||||||
| "manageCategories"
|
| "manageCategories"
|
||||||
| "manageMeetings"
|
| "manageMeetings"
|
||||||
@@ -30,6 +31,7 @@ const CAPABILITIES: Record<AppRole, Capability[]> = {
|
|||||||
"viewDashboard",
|
"viewDashboard",
|
||||||
"manageMembers",
|
"manageMembers",
|
||||||
"moderatePromos",
|
"moderatePromos",
|
||||||
|
"publishSocial",
|
||||||
"manageAdmins",
|
"manageAdmins",
|
||||||
"manageCategories",
|
"manageCategories",
|
||||||
"manageMeetings",
|
"manageMeetings",
|
||||||
@@ -37,7 +39,13 @@ const CAPABILITIES: Record<AppRole, Capability[]> = {
|
|||||||
"manageSettings",
|
"manageSettings",
|
||||||
"memberSpace",
|
"memberSpace",
|
||||||
],
|
],
|
||||||
moderator: ["viewDashboard", "manageMembers", "moderatePromos", "manageMeetings"],
|
moderator: [
|
||||||
|
"viewDashboard",
|
||||||
|
"manageMembers",
|
||||||
|
"moderatePromos",
|
||||||
|
"publishSocial",
|
||||||
|
"manageMeetings",
|
||||||
|
],
|
||||||
editor: ["viewDashboard", "manageMembers"],
|
editor: ["viewDashboard", "manageMembers"],
|
||||||
member: ["memberSpace"],
|
member: ["memberSpace"],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ export const SITE_SETTING_DEFAULTS = {
|
|||||||
association_email: "",
|
association_email: "",
|
||||||
association_phone: "",
|
association_phone: "",
|
||||||
association_website: "",
|
association_website: "",
|
||||||
|
association_facebook:
|
||||||
|
"https://www.facebook.com/search/top?q=pleinr%20-%20les%20bons%20plan%20du%20bassin%20de%20pompey",
|
||||||
|
association_linkedin:
|
||||||
|
"https://www.linkedin.com/company/association-plein-r-bassin-de-pompey/",
|
||||||
association_siret: "",
|
association_siret: "",
|
||||||
association_rna: "",
|
association_rna: "",
|
||||||
association_contact: "",
|
association_contact: "",
|
||||||
@@ -46,6 +50,37 @@ export async function getSiteSettings(): Promise<SiteSettings> {
|
|||||||
return merged;
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SocialLink = {
|
||||||
|
network: "facebook" | "linkedin";
|
||||||
|
label: string;
|
||||||
|
handle: string;
|
||||||
|
href: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Réseaux sociaux publics de l'association, dans l'ordre d'affichage. */
|
||||||
|
export function socialLinks(settings: SiteSettings): SocialLink[] {
|
||||||
|
const links: SocialLink[] = [];
|
||||||
|
const facebook = settings.association_facebook.trim();
|
||||||
|
const linkedin = settings.association_linkedin.trim();
|
||||||
|
if (facebook) {
|
||||||
|
links.push({
|
||||||
|
network: "facebook",
|
||||||
|
label: "Facebook",
|
||||||
|
handle: "Plein R — Les bons plans du Bassin de Pompey",
|
||||||
|
href: facebook,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (linkedin) {
|
||||||
|
links.push({
|
||||||
|
network: "linkedin",
|
||||||
|
label: "LinkedIn",
|
||||||
|
handle: "Association Plein R — Bassin de Pompey",
|
||||||
|
href: linkedin,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return links;
|
||||||
|
}
|
||||||
|
|
||||||
export function splitLines(value: string) {
|
export function splitLines(value: string) {
|
||||||
return value
|
return value
|
||||||
.split(/\r?\n/)
|
.split(/\r?\n/)
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
/**
|
||||||
|
* Publication des promotions sur les pages Facebook / LinkedIn de l'association.
|
||||||
|
*
|
||||||
|
* Les jetons d'accès sont des SECRETS : ils vivent dans les variables
|
||||||
|
* d'environnement du conteneur, jamais en base ni dans le backoffice. Voir
|
||||||
|
* `.env.example` pour la marche à suivre côté Meta / LinkedIn.
|
||||||
|
*
|
||||||
|
* Si un réseau n'est pas configuré, le backoffice masque simplement son bouton :
|
||||||
|
* le reste du site fonctionne normalement.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type SocialNetwork = "facebook" | "linkedin";
|
||||||
|
|
||||||
|
export const SOCIAL_NETWORKS: SocialNetwork[] = ["facebook", "linkedin"];
|
||||||
|
|
||||||
|
export const SOCIAL_LABELS: Record<SocialNetwork, string> = {
|
||||||
|
facebook: "Facebook",
|
||||||
|
linkedin: "LinkedIn",
|
||||||
|
};
|
||||||
|
|
||||||
|
const FACEBOOK_GRAPH_VERSION = process.env.FACEBOOK_GRAPH_VERSION ?? "v21.0";
|
||||||
|
const LINKEDIN_VERSION = process.env.LINKEDIN_API_VERSION ?? "202506";
|
||||||
|
|
||||||
|
function env(key: string): string {
|
||||||
|
return (process.env[key] ?? "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isNetworkConfigured(network: SocialNetwork): boolean {
|
||||||
|
if (network === "facebook") {
|
||||||
|
return !!env("FACEBOOK_PAGE_ID") && !!env("FACEBOOK_PAGE_ACCESS_TOKEN");
|
||||||
|
}
|
||||||
|
return !!linkedinOrganizationUrn() && !!env("LINKEDIN_ACCESS_TOKEN");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function configuredNetworks(): SocialNetwork[] {
|
||||||
|
return SOCIAL_NETWORKS.filter(isNetworkConfigured);
|
||||||
|
}
|
||||||
|
|
||||||
|
function linkedinOrganizationUrn(): string {
|
||||||
|
const urn = env("LINKEDIN_ORGANIZATION_URN");
|
||||||
|
if (urn) return urn;
|
||||||
|
const id = env("LINKEDIN_ORGANIZATION_ID");
|
||||||
|
return id ? `urn:li:organization:${id}` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function siteUrl(): string {
|
||||||
|
return (env("NEXT_PUBLIC_SITE_URL") || env("AUTH_URL")).replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Contenu du post ----
|
||||||
|
|
||||||
|
export type PromoForSharing = {
|
||||||
|
title: string;
|
||||||
|
text: string | null;
|
||||||
|
badge: string | null;
|
||||||
|
validUntil: string | null;
|
||||||
|
imageUrl: string | null;
|
||||||
|
memberId: number | null;
|
||||||
|
memberName: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function promoLink(promo: PromoForSharing): string | null {
|
||||||
|
const base = siteUrl();
|
||||||
|
if (!base) return null;
|
||||||
|
return promo.memberId ? `${base}/adherents/${promo.memberId}` : `${base}/#promotions`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Texte du post, commun aux deux réseaux. */
|
||||||
|
export function buildPromoMessage(promo: PromoForSharing): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push(promo.badge ? `${promo.badge} — ${promo.title}` : promo.title);
|
||||||
|
if (promo.memberName) lines.push(`Chez ${promo.memberName}`);
|
||||||
|
if (promo.text?.trim()) lines.push("", promo.text.trim());
|
||||||
|
if (promo.validUntil?.trim()) lines.push("", promo.validUntil.trim());
|
||||||
|
const link = promoLink(promo);
|
||||||
|
if (link) lines.push("", link);
|
||||||
|
lines.push("", "#PleinR #BassinDePompey #CommerceLocal");
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Image ----
|
||||||
|
|
||||||
|
type LoadedImage = { bytes: Uint8Array; contentType: string; fileName: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Les images de promo sont stockées en data-URI (upload direct depuis l'espace
|
||||||
|
* adhérent) ; on accepte aussi une URL absolue au cas où.
|
||||||
|
*/
|
||||||
|
export async function loadPromoImage(imageUrl: string | null): Promise<LoadedImage | null> {
|
||||||
|
if (!imageUrl) return null;
|
||||||
|
|
||||||
|
const dataMatch = /^data:([^;,]+);base64,(.+)$/s.exec(imageUrl.trim());
|
||||||
|
if (dataMatch) {
|
||||||
|
const contentType = dataMatch[1];
|
||||||
|
const bytes = Uint8Array.from(Buffer.from(dataMatch[2], "base64"));
|
||||||
|
return { bytes, contentType, fileName: `promo.${extensionFor(contentType)}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^https?:\/\//i.test(imageUrl)) {
|
||||||
|
const res = await fetch(imageUrl);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const contentType = res.headers.get("content-type")?.split(";")[0] ?? "image/jpeg";
|
||||||
|
const bytes = new Uint8Array(await res.arrayBuffer());
|
||||||
|
return { bytes, contentType, fileName: `promo.${extensionFor(contentType)}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extensionFor(contentType: string): string {
|
||||||
|
if (contentType.includes("png")) return "png";
|
||||||
|
if (contentType.includes("webp")) return "webp";
|
||||||
|
if (contentType.includes("gif")) return "gif";
|
||||||
|
return "jpg";
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBlob(image: LoadedImage): Blob {
|
||||||
|
// `Uint8Array` → `ArrayBuffer` explicite : évite le SharedArrayBuffer côté types.
|
||||||
|
return new Blob([image.bytes.slice().buffer as ArrayBuffer], { type: image.contentType });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Résultat ----
|
||||||
|
|
||||||
|
export type PublishResult = { externalId: string | null; url: string | null };
|
||||||
|
|
||||||
|
export class SocialPublishError extends Error {}
|
||||||
|
|
||||||
|
async function readError(res: Response): Promise<string> {
|
||||||
|
const body = await res.text().catch(() => "");
|
||||||
|
return `HTTP ${res.status} ${res.statusText}${body ? ` — ${body.slice(0, 500)}` : ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Facebook (Graph API, page de l'association) ----
|
||||||
|
|
||||||
|
async function publishToFacebook(promo: PromoForSharing, message: string): Promise<PublishResult> {
|
||||||
|
const pageId = env("FACEBOOK_PAGE_ID");
|
||||||
|
const token = env("FACEBOOK_PAGE_ACCESS_TOKEN");
|
||||||
|
if (!pageId || !token) throw new SocialPublishError("Facebook n'est pas configuré.");
|
||||||
|
|
||||||
|
const image = await loadPromoImage(promo.imageUrl);
|
||||||
|
const base = `https://graph.facebook.com/${FACEBOOK_GRAPH_VERSION}/${pageId}`;
|
||||||
|
|
||||||
|
const body = new FormData();
|
||||||
|
body.set("access_token", token);
|
||||||
|
|
||||||
|
let endpoint: string;
|
||||||
|
if (image) {
|
||||||
|
// Photo : on téléverse le binaire directement (les images sont en data-URI,
|
||||||
|
// Facebook ne peut donc pas aller les chercher par URL).
|
||||||
|
endpoint = `${base}/photos`;
|
||||||
|
body.set("caption", message);
|
||||||
|
body.set("source", toBlob(image), image.fileName);
|
||||||
|
} else {
|
||||||
|
endpoint = `${base}/feed`;
|
||||||
|
body.set("message", message);
|
||||||
|
const link = promoLink(promo);
|
||||||
|
if (link) body.set("link", link);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(endpoint, { method: "POST", body });
|
||||||
|
if (!res.ok) throw new SocialPublishError(`Facebook: ${await readError(res)}`);
|
||||||
|
|
||||||
|
const json = (await res.json()) as { id?: string; post_id?: string };
|
||||||
|
const externalId = json.post_id ?? json.id ?? null;
|
||||||
|
return {
|
||||||
|
externalId,
|
||||||
|
url: externalId ? `https://www.facebook.com/${externalId}` : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- LinkedIn (Posts API, page organisation) ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Le champ `commentary` utilise le « Little Text Format » : ces caractères
|
||||||
|
* doivent être échappés sous peine de rejet de la requête.
|
||||||
|
*/
|
||||||
|
function escapeLinkedInText(text: string): string {
|
||||||
|
return text.replace(/[|{}@[\]()<>#*_~\\]/g, (c) => `\\${c}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function linkedinHeaders(token: string): Record<string, string> {
|
||||||
|
return {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"LinkedIn-Version": LINKEDIN_VERSION,
|
||||||
|
"X-Restli-Protocol-Version": "2.0.0",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadLinkedInImage(
|
||||||
|
token: string,
|
||||||
|
owner: string,
|
||||||
|
image: LoadedImage
|
||||||
|
): Promise<string> {
|
||||||
|
const initRes = await fetch("https://api.linkedin.com/rest/images?action=initializeUpload", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...linkedinHeaders(token), "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ initializeUploadRequest: { owner } }),
|
||||||
|
});
|
||||||
|
if (!initRes.ok) throw new SocialPublishError(`LinkedIn (init image): ${await readError(initRes)}`);
|
||||||
|
|
||||||
|
const init = (await initRes.json()) as { value?: { uploadUrl?: string; image?: string } };
|
||||||
|
const uploadUrl = init.value?.uploadUrl;
|
||||||
|
const imageUrn = init.value?.image;
|
||||||
|
if (!uploadUrl || !imageUrn) {
|
||||||
|
throw new SocialPublishError("LinkedIn: réponse d'initialisation d'image inattendue.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadRes = await fetch(uploadUrl, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { Authorization: `Bearer ${token}`, "Content-Type": image.contentType },
|
||||||
|
body: toBlob(image),
|
||||||
|
});
|
||||||
|
if (!uploadRes.ok) {
|
||||||
|
throw new SocialPublishError(`LinkedIn (upload image): ${await readError(uploadRes)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return imageUrn;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publishToLinkedIn(promo: PromoForSharing, message: string): Promise<PublishResult> {
|
||||||
|
const token = env("LINKEDIN_ACCESS_TOKEN");
|
||||||
|
const owner = linkedinOrganizationUrn();
|
||||||
|
if (!token || !owner) throw new SocialPublishError("LinkedIn n'est pas configuré.");
|
||||||
|
|
||||||
|
const image = await loadPromoImage(promo.imageUrl);
|
||||||
|
const imageUrn = image ? await uploadLinkedInImage(token, owner, image) : null;
|
||||||
|
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
author: owner,
|
||||||
|
commentary: escapeLinkedInText(message),
|
||||||
|
visibility: "PUBLIC",
|
||||||
|
distribution: {
|
||||||
|
feedDistribution: "MAIN_FEED",
|
||||||
|
targetEntities: [],
|
||||||
|
thirdPartyDistributionChannels: [],
|
||||||
|
},
|
||||||
|
lifecycleState: "PUBLISHED",
|
||||||
|
isReshareDisabledByAuthor: false,
|
||||||
|
};
|
||||||
|
if (imageUrn) {
|
||||||
|
payload.content = { media: { id: imageUrn, altText: promo.title.slice(0, 200) } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch("https://api.linkedin.com/rest/posts", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...linkedinHeaders(token), "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new SocialPublishError(`LinkedIn: ${await readError(res)}`);
|
||||||
|
|
||||||
|
const externalId = res.headers.get("x-restli-id");
|
||||||
|
return {
|
||||||
|
externalId,
|
||||||
|
url: externalId ? `https://www.linkedin.com/feed/update/${externalId}/` : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Point d'entrée ----
|
||||||
|
|
||||||
|
export async function publishPromoToNetwork(
|
||||||
|
network: SocialNetwork,
|
||||||
|
promo: PromoForSharing
|
||||||
|
): Promise<PublishResult> {
|
||||||
|
const message = buildPromoMessage(promo);
|
||||||
|
return network === "facebook"
|
||||||
|
? publishToFacebook(promo, message)
|
||||||
|
: publishToLinkedIn(promo, message);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user