From 02c5378a4731ea336fb5d6449f5836612b369a28 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sun, 6 Sep 2026 11:00:44 +0000
Subject: [PATCH] =?UTF-8?q?Promotions=20:=20p=C3=A9riode=20de=20validit?=
=?UTF-8?q?=C3=A9=20(date=20de=20d=C3=A9but=20/=20date=20de=20fin)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Une promotion peut désormais porter une date de début et une date de fin,
toutes deux facultatives et indépendantes. Sans date, rien n'est affiché.
- Schéma : `promotions.starts_on` / `ends_on` (`date`), migration 0015.
- `src/lib/promo-validity.ts` (pur, testé) : `formatValidity()` produit
« Valable du 1er au 15 mars 2027 », « Valable jusqu'au … », « Valable à
partir du … » en repliant mois et année quand ils se répètent ;
`formatValidityShort()` pour les lignes de méta ; `isRangeInvalid()` pour
la validation.
- Espace adhérent : deux champs date, aperçu en direct de la phrase et
blocage de l'envoi si la fin précède le début.
- `publishPromo` relit et revalide les bornes côté serveur.
- Affichage : carte d'accueil, fiche adhérent, liste de l'adhérent, carte de
modération — et le message Facebook / LinkedIn, via le même formateur.
- L'ancien texte libre `valid_until` reste comme repli d'affichage pour les
promotions de démonstration.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
---
CLAUDE.md | 11 +
drizzle/0015_curious_winter_soldier.sql | 2 +
drizzle/meta/0015_snapshot.json | 1407 ++++++++++++++++++++
drizzle/meta/_journal.json | 7 +
src/app/adherents/[id]/page.tsx | 5 +-
src/app/backend/actions.ts | 18 +
src/app/backend/espace/MemberSpaceForm.tsx | 49 +-
src/app/backend/espace/promotions/page.tsx | 7 +-
src/app/backend/promotions/page.tsx | 12 +
src/app/page.tsx | 5 +-
src/db/schema.ts | 6 +
src/lib/promo-validity.ts | 90 ++
src/lib/queries.ts | 4 +
src/lib/social.ts | 7 +-
tests/promo-validity.test.ts | 76 ++
15 files changed, 1700 insertions(+), 6 deletions(-)
create mode 100644 drizzle/0015_curious_winter_soldier.sql
create mode 100644 drizzle/meta/0015_snapshot.json
create mode 100644 src/lib/promo-validity.ts
create mode 100644 tests/promo-validity.test.ts
diff --git a/CLAUDE.md b/CLAUDE.md
index 6fdc438..4ce1dde 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -56,6 +56,17 @@ 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.
+**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.
+
## Réseaux sociaux
- `src/lib/social.ts` publie une promo sur la page Facebook (Graph API) ou
diff --git a/drizzle/0015_curious_winter_soldier.sql b/drizzle/0015_curious_winter_soldier.sql
new file mode 100644
index 0000000..b9dfb3a
--- /dev/null
+++ b/drizzle/0015_curious_winter_soldier.sql
@@ -0,0 +1,2 @@
+ALTER TABLE "promotions" ADD COLUMN "starts_on" date;--> statement-breakpoint
+ALTER TABLE "promotions" ADD COLUMN "ends_on" date;
\ No newline at end of file
diff --git a/drizzle/meta/0015_snapshot.json b/drizzle/meta/0015_snapshot.json
new file mode 100644
index 0000000..bd86db6
--- /dev/null
+++ b/drizzle/meta/0015_snapshot.json
@@ -0,0 +1,1407 @@
+{
+ "id": "921c53a4-388b-4136-bd5d-06efe6ef1bd8",
+ "prevId": "d6df280b-1194-4a4a-9670-257fc2acd2f9",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.activity_log": {
+ "name": "activity_log",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "dot": {
+ "name": "dot",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'#2C6FB3'"
+ },
+ "message": {
+ "name": "message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.categories": {
+ "name": "categories",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "varchar(80)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "label": {
+ "name": "label",
+ "type": "varchar(120)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "accent": {
+ "name": "accent",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'#E0A63C'"
+ },
+ "tint": {
+ "name": "tint",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'#f6efdc'"
+ },
+ "sort": {
+ "name": "sort",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "categories_slug_unique": {
+ "name": "categories_slug_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "slug"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.contact_messages": {
+ "name": "contact_messages",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(200)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "varchar(200)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "subject": {
+ "name": "subject",
+ "type": "varchar(200)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "message": {
+ "name": "message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "contact_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'new'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.image_consents": {
+ "name": "image_consents",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "member_id": {
+ "name": "member_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "decision": {
+ "name": "decision",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "scopes": {
+ "name": "scopes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "signatory_name": {
+ "name": "signatory_name",
+ "type": "varchar(200)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "signature_png": {
+ "name": "signature_png",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "consent_version": {
+ "name": "consent_version",
+ "type": "varchar(40)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ip": {
+ "name": "ip",
+ "type": "varchar(120)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "image_consents_member_id_members_id_fk": {
+ "name": "image_consents_member_id_members_id_fk",
+ "tableFrom": "image_consents",
+ "tableTo": "members",
+ "columnsFrom": [
+ "member_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.meeting_registrations": {
+ "name": "meeting_registrations",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "meeting_id": {
+ "name": "meeting_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "member_id": {
+ "name": "member_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "attendee_name": {
+ "name": "attendee_name",
+ "type": "varchar(200)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "attendee_company": {
+ "name": "attendee_company",
+ "type": "varchar(200)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "attendee_email": {
+ "name": "attendee_email",
+ "type": "varchar(200)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "attendee_phone": {
+ "name": "attendee_phone",
+ "type": "varchar(40)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "image_consent": {
+ "name": "image_consent",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "meeting_registrations_meeting_idx": {
+ "name": "meeting_registrations_meeting_idx",
+ "columns": [
+ {
+ "expression": "meeting_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "meeting_registrations_member_meeting_idx": {
+ "name": "meeting_registrations_member_meeting_idx",
+ "columns": [
+ {
+ "expression": "member_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "meeting_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "meeting_registrations_meeting_id_meetings_id_fk": {
+ "name": "meeting_registrations_meeting_id_meetings_id_fk",
+ "tableFrom": "meeting_registrations",
+ "tableTo": "meetings",
+ "columnsFrom": [
+ "meeting_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "meeting_registrations_member_id_members_id_fk": {
+ "name": "meeting_registrations_member_id_members_id_fk",
+ "tableFrom": "meeting_registrations",
+ "tableTo": "members",
+ "columnsFrom": [
+ "member_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.meetings": {
+ "name": "meetings",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "varchar(200)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "starts_at": {
+ "name": "starts_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "location": {
+ "name": "location",
+ "type": "varchar(240)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "capacity": {
+ "name": "capacity",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 30
+ },
+ "participants_per_account": {
+ "name": "participants_per_account",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "image_url": {
+ "name": "image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.members": {
+ "name": "members",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(200)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "varchar(200)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "contact_email": {
+ "name": "contact_email",
+ "type": "varchar(200)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "category_id": {
+ "name": "category_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "city": {
+ "name": "city",
+ "type": "varchar(120)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "address": {
+ "name": "address",
+ "type": "varchar(240)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "member_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "highlighted": {
+ "name": "highlighted",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "logo_url": {
+ "name": "logo_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cover_url": {
+ "name": "cover_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "phone": {
+ "name": "phone",
+ "type": "varchar(40)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "website": {
+ "name": "website",
+ "type": "varchar(200)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "postal_code": {
+ "name": "postal_code",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "member_since": {
+ "name": "member_since",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "hours": {
+ "name": "hours",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tags": {
+ "name": "tags",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "members_category_id_categories_id_fk": {
+ "name": "members_category_id_categories_id_fk",
+ "tableFrom": "members",
+ "tableTo": "categories",
+ "columnsFrom": [
+ "category_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.membership_requests": {
+ "name": "membership_requests",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(200)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "varchar(200)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "message": {
+ "name": "message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "request_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'new'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.past_meeting_photos": {
+ "name": "past_meeting_photos",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "past_meeting_id": {
+ "name": "past_meeting_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "image_url": {
+ "name": "image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "caption": {
+ "name": "caption",
+ "type": "varchar(200)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "position": {
+ "name": "position",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "past_meeting_photos_past_meeting_id_past_meetings_id_fk": {
+ "name": "past_meeting_photos_past_meeting_id_past_meetings_id_fk",
+ "tableFrom": "past_meeting_photos",
+ "tableTo": "past_meetings",
+ "columnsFrom": [
+ "past_meeting_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.past_meetings": {
+ "name": "past_meetings",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "varchar(200)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "event_date": {
+ "name": "event_date",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "location": {
+ "name": "location",
+ "type": "varchar(240)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "participants": {
+ "name": "participants",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "meeting_id": {
+ "name": "meeting_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "past_meetings_meeting_id_meetings_id_fk": {
+ "name": "past_meetings_meeting_id_meetings_id_fk",
+ "tableFrom": "past_meetings",
+ "tableTo": "meetings",
+ "columnsFrom": [
+ "meeting_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.promotions": {
+ "name": "promotions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "varchar(200)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "category": {
+ "name": "category",
+ "type": "varchar(120)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "badge": {
+ "name": "badge",
+ "type": "varchar(40)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "image_url": {
+ "name": "image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "member_id": {
+ "name": "member_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "promo_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "starts_on": {
+ "name": "starts_on",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ends_on": {
+ "name": "ends_on",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "valid_until": {
+ "name": "valid_until",
+ "type": "varchar(120)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "share_facebook": {
+ "name": "share_facebook",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "share_linkedin": {
+ "name": "share_linkedin",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "suspended_by": {
+ "name": "suspended_by",
+ "type": "promo_suspended_by",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "suspended_by_id": {
+ "name": "suspended_by_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "suspended_at": {
+ "name": "suspended_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "promotions_member_id_members_id_fk": {
+ "name": "promotions_member_id_members_id_fk",
+ "tableFrom": "promotions",
+ "tableTo": "members",
+ "columnsFrom": [
+ "member_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "promotions_suspended_by_id_users_id_fk": {
+ "name": "promotions_suspended_by_id_users_id_fk",
+ "tableFrom": "promotions",
+ "tableTo": "users",
+ "columnsFrom": [
+ "suspended_by_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.site_settings": {
+ "name": "site_settings",
+ "schema": "",
+ "columns": {
+ "key": {
+ "name": "key",
+ "type": "varchar(120)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.social_accounts": {
+ "name": "social_accounts",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "network": {
+ "name": "network",
+ "type": "social_network",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "app_id": {
+ "name": "app_id",
+ "type": "varchar(200)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "app_secret": {
+ "name": "app_secret",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "target_id": {
+ "name": "target_id",
+ "type": "varchar(200)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "target_name": {
+ "name": "target_name",
+ "type": "varchar(200)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "connected_by_id": {
+ "name": "connected_by_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "connected_at": {
+ "name": "connected_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_check_at": {
+ "name": "last_check_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_check_ok": {
+ "name": "last_check_ok",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_check_error": {
+ "name": "last_check_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "social_accounts_connected_by_id_users_id_fk": {
+ "name": "social_accounts_connected_by_id_users_id_fk",
+ "tableFrom": "social_accounts",
+ "tableTo": "users",
+ "columnsFrom": [
+ "connected_by_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "social_accounts_network_unique": {
+ "name": "social_accounts_network_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "network"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.social_posts": {
+ "name": "social_posts",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "promotion_id": {
+ "name": "promotion_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "network": {
+ "name": "network",
+ "type": "social_network",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "social_post_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "external_id": {
+ "name": "external_id",
+ "type": "varchar(200)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "posted_by_id": {
+ "name": "posted_by_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "social_posts_promotion_idx": {
+ "name": "social_posts_promotion_idx",
+ "columns": [
+ {
+ "expression": "promotion_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "social_posts_promotion_id_promotions_id_fk": {
+ "name": "social_posts_promotion_id_promotions_id_fk",
+ "tableFrom": "social_posts",
+ "tableTo": "promotions",
+ "columnsFrom": [
+ "promotion_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "social_posts_posted_by_id_users_id_fk": {
+ "name": "social_posts_posted_by_id_users_id_fk",
+ "tableFrom": "social_posts",
+ "tableTo": "users",
+ "columnsFrom": [
+ "posted_by_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.users": {
+ "name": "users",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "varchar(200)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(200)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "password_hash": {
+ "name": "password_hash",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "role",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'member'"
+ },
+ "member_id": {
+ "name": "member_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "must_change_password": {
+ "name": "must_change_password",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "session_version": {
+ "name": "session_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "users_email_idx": {
+ "name": "users_email_idx",
+ "columns": [
+ {
+ "expression": "email",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "users_member_id_members_id_fk": {
+ "name": "users_member_id_members_id_fk",
+ "tableFrom": "users",
+ "tableTo": "members",
+ "columnsFrom": [
+ "member_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {
+ "public.contact_status": {
+ "name": "contact_status",
+ "schema": "public",
+ "values": [
+ "new",
+ "read",
+ "archived"
+ ]
+ },
+ "public.member_status": {
+ "name": "member_status",
+ "schema": "public",
+ "values": [
+ "active",
+ "pending"
+ ]
+ },
+ "public.promo_status": {
+ "name": "promo_status",
+ "schema": "public",
+ "values": [
+ "pending",
+ "live",
+ "expired",
+ "rejected",
+ "suspended"
+ ]
+ },
+ "public.promo_suspended_by": {
+ "name": "promo_suspended_by",
+ "schema": "public",
+ "values": [
+ "member",
+ "staff"
+ ]
+ },
+ "public.request_status": {
+ "name": "request_status",
+ "schema": "public",
+ "values": [
+ "new",
+ "approved",
+ "rejected"
+ ]
+ },
+ "public.role": {
+ "name": "role",
+ "schema": "public",
+ "values": [
+ "admin",
+ "moderator",
+ "editor",
+ "member"
+ ]
+ },
+ "public.social_network": {
+ "name": "social_network",
+ "schema": "public",
+ "values": [
+ "facebook",
+ "linkedin"
+ ]
+ },
+ "public.social_post_status": {
+ "name": "social_post_status",
+ "schema": "public",
+ "values": [
+ "posted",
+ "failed"
+ ]
+ }
+ },
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json
index c33c767..0981159 100644
--- a/drizzle/meta/_journal.json
+++ b/drizzle/meta/_journal.json
@@ -106,6 +106,13 @@
"when": 1788691242685,
"tag": "0014_lean_marvel_zombies",
"breakpoints": true
+ },
+ {
+ "idx": 15,
+ "version": "7",
+ "when": 1788691741621,
+ "tag": "0015_curious_winter_soldier",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/src/app/adherents/[id]/page.tsx b/src/app/adherents/[id]/page.tsx
index b2d5023..8cebdc5 100644
--- a/src/app/adherents/[id]/page.tsx
+++ b/src/app/adherents/[id]/page.tsx
@@ -26,6 +26,7 @@ import {
parseMemberHours,
websiteLabel,
} from "@/lib/member-profile";
+import { formatValidity } from "@/lib/promo-validity";
export const dynamic = "force-dynamic";
@@ -437,8 +438,8 @@ export default async function FicheAdherentPage({
{p.title}
{p.text}
- {p.validUntil && (
-
{p.validUntil}
+ {formatValidity(p) && (
+
{formatValidity(p)}
)}
diff --git a/src/app/backend/actions.ts b/src/app/backend/actions.ts
index 5144cad..d25e5a2 100644
--- a/src/app/backend/actions.ts
+++ b/src/app/backend/actions.ts
@@ -43,6 +43,7 @@ import {
selectTarget,
} from "@/lib/social-accounts";
import { normalizeWebsite } from "@/lib/member-profile";
+import { isRangeInvalid } from "@/lib/promo-validity";
import { SITE_SETTING_DEFAULTS } from "@/lib/site-settings";
import type { AppRole } from "@/types/next-auth";
@@ -114,6 +115,14 @@ function asImageDataUri(formData: FormData, key: string): string | null {
return value;
}
+/** Date `YYYY-MM-DD` facultative issue d'un ` `. */
+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");
@@ -159,6 +168,8 @@ async function publishPromoShares(
text: promotions.text,
badge: promotions.badge,
validUntil: promotions.validUntil,
+ startsOn: promotions.startsOn,
+ endsOn: promotions.endsOn,
imageUrl: promotions.imageUrl,
memberId: promotions.memberId,
memberName: members.name,
@@ -371,6 +382,11 @@ 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");
// Réseaux souhaités : rien n'est publié ici, la diffusion attend la validation.
@@ -382,6 +398,8 @@ export async function publishPromo(formData: FormData) {
imageUrl,
memberId: memberId ?? null,
status: "pending",
+ startsOn,
+ endsOn,
...readShareTargets(formData),
});
diff --git a/src/app/backend/espace/MemberSpaceForm.tsx b/src/app/backend/espace/MemberSpaceForm.tsx
index 3810aa6..c9df2f5 100644
--- a/src/app/backend/espace/MemberSpaceForm.tsx
+++ b/src/app/backend/espace/MemberSpaceForm.tsx
@@ -7,6 +7,7 @@ import { PromoImage } from "@/components/PromoImage";
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";
const STRIPE_WARM =
"repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)";
@@ -36,6 +37,8 @@ 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 [imgData, setImgData] = useState("");
const [shares, setShares] = useState([]);
const [submitted, setSubmitted] = useState(false);
@@ -62,6 +65,8 @@ export function MemberSpaceForm({
setTitle("");
setText("");
setBadge("");
+ setStartsOn("");
+ setEndsOn("");
setImgData("");
setShares([]);
setSubmitted(false);
@@ -76,6 +81,8 @@ 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("imageUrl", imgData);
if (shares.includes("facebook")) fd.set("shareFacebook", "on");
if (shares.includes("linkedin")) fd.set("shareLinkedin", "on");
@@ -89,6 +96,9 @@ 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 });
return (
@@ -169,6 +179,40 @@ export function MemberSpaceForm({
+
+
Durée de validité · facultatif
+
+
+
{ setStartsOn(e.target.value); setSubmitted(false); }}
+ className="field"
+ aria-label="Début de la promotion"
+ />
+
Début
+
+
+
{ setEndsOn(e.target.value); setSubmitted(false); }}
+ className="field"
+ aria-label="Fin de la promotion"
+ />
+
Fin
+
+
+
+ {badRange
+ ? "La date de fin est antérieure à la date de début."
+ : validity
+ ? `Affiché sur le site et dans la publication : « ${validity} »`
+ : "Laissez vide si votre offre n'a pas de date limite : rien ne sera affiché."}
+
+
+
Texte de la promotion
+ {validity && (
+ {validity}
+ )}
{memberName}
Voir l'offre →
diff --git a/src/app/backend/espace/promotions/page.tsx b/src/app/backend/espace/promotions/page.tsx
index 5546dc9..57bf8a7 100644
--- a/src/app/backend/espace/promotions/page.tsx
+++ b/src/app/backend/espace/promotions/page.tsx
@@ -7,6 +7,7 @@ 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 } from "@/lib/promo-validity";
import { setOwnPromoShareTargets, setOwnPromoSuspension } from "../../actions";
import { EspaceHeader } from "../EspaceHeader";
import { MemberSpaceForm } from "../MemberSpaceForm";
@@ -28,6 +29,8 @@ type MyPromo = {
category: string | null;
badge: string | null;
validUntil: string | null;
+ startsOn: string | null;
+ endsOn: string | null;
createdAt: Date;
imageUrl: string | null;
suspendedBy: "member" | "staff" | null;
@@ -79,6 +82,8 @@ export default async function EspacePromotionsPage() {
category: promotions.category,
badge: promotions.badge,
validUntil: promotions.validUntil,
+ startsOn: promotions.startsOn,
+ endsOn: promotions.endsOn,
createdAt: promotions.createdAt,
imageUrl: promotions.imageUrl,
suspendedBy: promotions.suspendedBy,
@@ -237,7 +242,7 @@ function PromoSection({
function PromoRow({ promo: mp, networks, lastShare }: { promo: MyPromo; networks: SocialNetwork[]; lastShare: ShareState }) {
const st = PROMO_STATUS[mp.status] ?? PROMO_STATUS.expired;
const suspendedByStaff = mp.status === "suspended" && mp.suspendedBy === "staff";
- const meta = [mp.category, mp.badge, mp.validUntil].filter(Boolean).join(" · ");
+ const meta = [mp.category, mp.badge, formatValidityShort(mp)].filter(Boolean).join(" · ");
return (
diff --git a/src/app/backend/promotions/page.tsx b/src/app/backend/promotions/page.tsx
index 2294d68..682fd45 100644
--- a/src/app/backend/promotions/page.tsx
+++ b/src/app/backend/promotions/page.tsx
@@ -8,6 +8,7 @@ import { configuredNetworks, SOCIAL_LABELS } from "@/lib/social";
import { moderatePromo, retryPromoShare } from "../actions";
import { PromoImage } from "@/components/PromoImage";
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
+import { formatValidity } from "@/lib/promo-validity";
export const dynamic = "force-dynamic";
@@ -41,6 +42,9 @@ export default async function PromotionsPage() {
text: promotions.text,
category: promotions.category,
status: promotions.status,
+ validUntil: promotions.validUntil,
+ startsOn: promotions.startsOn,
+ endsOn: promotions.endsOn,
imageUrl: promotions.imageUrl,
suspendedBy: promotions.suspendedBy,
suspendedAt: promotions.suspendedAt,
@@ -176,6 +180,14 @@ export default async function PromotionsPage() {
{p.text}
+ {/* 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) && (
+
+ {formatValidity(p)}
+
+ )}
+
{isSuspended && (
Suspendue par {p.suspendedBy === "member" ? "l'adhérent" : "l'association"}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index ce188b3..267a4b7 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -11,6 +11,7 @@ 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 { formatValidity } from "@/lib/promo-validity";
import { SITE_DESCRIPTION, SITE_TITLE, memberPath, pageMetadata } from "@/lib/seo";
export const dynamic = "force-dynamic";
@@ -315,7 +316,9 @@ export default async function AccueilPage() {
}}
>
{p.memberName}
-
{p.validUntil}
+ {formatValidity(p) && (
+
{formatValidity(p)}
+ )}
diff --git a/src/db/schema.ts b/src/db/schema.ts
index 326d292..c6b358d 100644
--- a/src/db/schema.ts
+++ b/src/db/schema.ts
@@ -6,6 +6,7 @@ import {
text,
varchar,
boolean,
+ date,
timestamp,
index,
uniqueIndex,
@@ -107,6 +108,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.
diff --git a/src/lib/promo-validity.ts b/src/lib/promo-validity.ts
new file mode 100644
index 0000000..b38145f
--- /dev/null
+++ b/src/lib/promo-validity.ts
@@ -0,0 +1,90 @@
+/**
+ * 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);
+}
diff --git a/src/lib/queries.ts b/src/lib/queries.ts
index bd37095..2c02060 100644
--- a/src/lib/queries.ts
+++ b/src/lib/queries.ts
@@ -67,6 +67,8 @@ export async function getLivePromotions(limit = 6) {
badge: promotions.badge,
imageUrl: promotions.imageUrl,
validUntil: promotions.validUntil,
+ startsOn: promotions.startsOn,
+ endsOn: promotions.endsOn,
memberId: promotions.memberId,
memberName: members.name,
memberCity: members.city,
@@ -119,6 +121,8 @@ export async function getMemberLivePromotions(memberId: number) {
badge: promotions.badge,
imageUrl: promotions.imageUrl,
validUntil: promotions.validUntil,
+ startsOn: promotions.startsOn,
+ endsOn: promotions.endsOn,
})
.from(promotions)
.where(and(eq(promotions.memberId, memberId), eq(promotions.status, "live")))
diff --git a/src/lib/social.ts b/src/lib/social.ts
index bceeda9..6449290 100644
--- a/src/lib/social.ts
+++ b/src/lib/social.ts
@@ -15,6 +15,7 @@ import {
publicBaseUrl,
type SocialNetwork,
} from "./social-accounts";
+import { formatValidity } from "./promo-validity";
import { memberPath } from "./seo";
export {
@@ -35,6 +36,8 @@ export type PromoForSharing = {
text: string | null;
badge: string | null;
validUntil: string | null;
+ startsOn?: string | null;
+ endsOn?: string | null;
imageUrl: string | null;
memberId: number | null;
memberName: string | null;
@@ -55,7 +58,9 @@ export async function buildPromoMessage(promo: PromoForSharing): Promise
lines.push(promo.badge ? `${promo.badge} — ${promo.title}` : promo.title);
if (promo.memberName) lines.push(`Chez ${promo.memberName}`);
if (promo.text?.trim()) lines.push("", promo.text.trim());
- if (promo.validUntil?.trim()) lines.push("", promo.validUntil.trim());
+ // « Valable du … au … » : la même phrase que sur la carte du site.
+ const validity = formatValidity(promo);
+ if (validity) lines.push("", validity);
const link = await promoLink(promo);
if (link) lines.push("", link);
lines.push("", "#PleinR #BassinDePompey #CommerceLocal");
diff --git a/tests/promo-validity.test.ts b/tests/promo-validity.test.ts
new file mode 100644
index 0000000..b7cbad4
--- /dev/null
+++ b/tests/promo-validity.test.ts
@@ -0,0 +1,76 @@
+import { strict as assert } from "node:assert";
+import { describe, it } from "node:test";
+
+import {
+ formatValidity,
+ formatValidityShort,
+ isExpired,
+ isRangeInvalid,
+} 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);
+ });
+});