diff --git a/CLAUDE.md b/CLAUDE.md index cb3c94a..0ecb77f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -154,6 +154,25 @@ site sans traitement supplémentaire. sans adhérent elle passe en `NOINDEX` et sort du sitemap. L'annuaire et ces pages listent les métiers via `CategoryLinks` pour le maillage interne. +## Adhérents + +- L'espace adhérent est en deux pages : `/backend/espace` (profil, inscriptions, + droit à l'image) et `/backend/espace/promotions` (dépôt et suivi des promos, + les offres **en ligne** en tête). `EspaceHeader` porte le bandeau et les + onglets ; toute action qui touche l'espace revalide les deux chemins. +- La catégorie d'une promotion est un **type de produit ou de service** + (`src/lib/promo-categories.ts`, groupes pour ``), pas le métier de + l'adhérent ; `defaultPromoCategory(slug)` pré-sélectionne depuis le métier. + +- `members.email` est l'e-mail **administratif** (identifiant de connexion à la + création, échanges avec l'association) ; `members.contact_email` est l'e-mail + **public** de la fiche, saisi par l'adhérent ou le staff. La fiche et le + JSON-LD affichent `contact_email || email` : ne montrez jamais `email` seul. +- `VitrineImage` rend la couverture en `cover`, sinon le logo en `contain` + (~60 % d'un cadre à hauteur fixe) sur un fond du logo flouté, sinon un + placeholder rayé. Ne pas l'entourer d'un conteneur sans hauteur : les + `max-height` en % ne seraient plus résolus et le logo déborderait. + ## Roles `admin` > `moderator` > `editor` are staff; `member` is an adhérent linked to a diff --git a/drizzle/0013_contact_email.sql b/drizzle/0013_contact_email.sql new file mode 100644 index 0000000..f46e854 --- /dev/null +++ b/drizzle/0013_contact_email.sql @@ -0,0 +1 @@ +ALTER TABLE "members" ADD COLUMN "contact_email" varchar(200); \ No newline at end of file diff --git a/drizzle/meta/0013_snapshot.json b/drizzle/meta/0013_snapshot.json new file mode 100644 index 0000000..68df5cf --- /dev/null +++ b/drizzle/meta/0013_snapshot.json @@ -0,0 +1,1377 @@ +{ + "id": "c6e431df-9534-471d-a107-6d149fe00a00", + "prevId": "eff0d401-f29b-4ea3-8c1e-edfdab3b556e", + "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'" + }, + "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 + }, + "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 bbfb540..d914b15 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -92,6 +92,13 @@ "when": 1788675288708, "tag": "0012_referentiel_categories", "breakpoints": true + }, + { + "idx": 13, + "version": "7", + "when": 1788680191881, + "tag": "0013_contact_email", + "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 400cd07..b2d5023 100644 --- a/src/app/adherents/[id]/page.tsx +++ b/src/app/adherents/[id]/page.tsx @@ -167,6 +167,8 @@ export default async function FicheAdherentPage({ const openStatus = getOpenStatus(hours); const normalizedWebsite = normalizeWebsite(member.website); const shortWebsite = websiteLabel(member.website); + // E-mail public : celui saisi pour la fiche, sinon l'e-mail du compte. + const contactEmail = (member.contactEmail ?? "").trim() || member.email; const phoneHref = member.phone ? `tel:${member.phone.replace(/[^\d+]/g, "")}` : null; const sectionCard: React.CSSProperties = { @@ -192,7 +194,7 @@ export default async function FicheAdherentPage({ { name: "Annuaire", path: "/annuaire" }, { name: member.name, path: canonical }, ]), - localBusinessJsonLd(baseUrl, { ...member, tags: formatTags(tags) }, hours, normalizedWebsite), + localBusinessJsonLd(baseUrl, { ...member, email: contactEmail, tags: formatTags(tags) }, hours, normalizedWebsite), ]} /> @@ -362,8 +364,8 @@ export default async function FicheAdherentPage({ {phoneHref && member.phone && ( )} - {member.email && ( - + {contactEmail && ( + )} {normalizedWebsite && shortWebsite && ( @@ -377,7 +379,7 @@ export default async function FicheAdherentPage({ external /> )} - {!phoneHref && !member.email && !normalizedWebsite && !fullAddress && ( + {!phoneHref && !contactEmail && !normalizedWebsite && !fullAddress && (
Coordonnées non communiquées.
)} @@ -445,18 +447,18 @@ export default async function FicheAdherentPage({ {/* contact CTA */} - {(member.email || phoneHref || normalizedWebsite) && ( + {(contactEmail || phoneHref || normalizedWebsite) && (

Une question pour ce commerçant ?

Contactez {member.name} directement.

- {member.email ? "Envoyer un message" : phoneHref ? "Appeler maintenant" : "Visiter le site"} + {contactEmail ? "Envoyer un message" : phoneHref ? "Appeler maintenant" : "Visiter le site"}
)} diff --git a/src/app/backend/BackendShell.tsx b/src/app/backend/BackendShell.tsx index 800a33e..c0f4fa0 100644 --- a/src/app/backend/BackendShell.tsx +++ b/src/app/backend/BackendShell.tsx @@ -24,7 +24,8 @@ const TITLES: Record = { "/backend/administrateurs": ["Administrateurs", "Gérer les accès à l'administration"], "/backend/categories": ["Catégories", "Gérer les métiers de l'annuaire"], "/backend/parametres": ["Paramètres du site", "Configurer l'association et les mentions légales"], - "/backend/espace": ["Mon espace adhérent", "Publiez et suivez vos promotions"], + "/backend/espace": ["Mon profil", "Votre fiche publique, vos inscriptions et votre droit à l'image"], + "/backend/espace/promotions": ["Mes promotions", "Proposez une offre et suivez celles en ligne"], "/backend/changer-mot-de-passe": ["Mot de passe", "Sécurisez votre compte"], }; @@ -85,7 +86,10 @@ const SECTIONS: NavSection[] = [ }, { label: "Côté adhérent", - items: [{ href: "/backend/espace", label: "Mon espace", icon: "space" }], + items: [ + { href: "/backend/espace", label: "Mon profil", icon: "space" }, + { href: "/backend/espace/promotions", label: "Mes promotions", icon: "promos" }, + ], }, ]; diff --git a/src/app/backend/actions.ts b/src/app/backend/actions.ts index bc78613..5144cad 100644 --- a/src/app/backend/actions.ts +++ b/src/app/backend/actions.ts @@ -117,6 +117,7 @@ function asImageDataUri(formData: FormData, key: string): string | null { function revalidatePromoPaths(memberId?: number | null) { revalidatePath("/backend/promotions"); revalidatePath("/backend/espace"); + revalidatePath("/backend/espace/promotions"); revalidatePath("/backend"); revalidatePath("/"); revalidatePath("/annuaire"); @@ -334,6 +335,7 @@ export async function retryPromoShare(formData: FormData) { await publishPromoShares(id, [network], userId); revalidatePath("/backend/promotions"); revalidatePath("/backend/espace"); + revalidatePath("/backend/espace/promotions"); } // ---- Member space: choisir les réseaux tant que la promo est en attente ---- @@ -355,6 +357,7 @@ export async function setOwnPromoShareTargets(formData: FormData) { await db.update(promotions).set(readShareTargets(formData)).where(eq(promotions.id, id)); revalidatePath("/backend/espace"); + revalidatePath("/backend/espace/promotions"); revalidatePath("/backend/promotions"); } @@ -385,6 +388,7 @@ export async function publishPromo(formData: FormData) { await logActivity(`${name} a soumis une promotion « ${title} »`, "#E0A63C"); revalidatePath("/backend/espace"); + revalidatePath("/backend/espace/promotions"); revalidatePath("/backend"); revalidatePath("/backend/promotions"); } @@ -555,6 +559,7 @@ export async function updateMember(formData: FormData) { .set({ name: String(formData.get("name") ?? "").trim(), email: String(formData.get("email") ?? "").trim(), + contactEmail: String(formData.get("contactEmail") ?? "").trim().toLowerCase() || null, categoryId, city, address: String(formData.get("address") ?? "").trim() || null, @@ -860,6 +865,7 @@ function revalidateMeetingPaths(meetingId?: number) { revalidatePath("/backend/inscriptions"); revalidatePath("/backend/rencontres-passees"); revalidatePath("/backend/espace"); + revalidatePath("/backend/espace/promotions"); revalidatePath("/rencontres-passees"); if (meetingId) revalidatePath(`/inscription/${meetingId}`); } @@ -1052,6 +1058,7 @@ export async function saveImageConsent(formData: FormData) { revalidatePath("/backend"); revalidatePath("/backend/espace"); + revalidatePath("/backend/espace/promotions"); redirect("/backend/espace"); } @@ -1120,6 +1127,7 @@ export async function updateOwnProfile(formData: FormData) { .update(members) .set({ name, + contactEmail: String(formData.get("contactEmail") ?? "").trim().toLowerCase() || null, description, address: String(formData.get("address") ?? "").trim() || null, postalCode: String(formData.get("postalCode") ?? "").trim() || null, @@ -1137,6 +1145,7 @@ export async function updateOwnProfile(formData: FormData) { .where(eq(members.id, memberId)); revalidatePath("/backend/espace"); + revalidatePath("/backend/espace/promotions"); revalidatePath("/adherents/[id]", "page"); revalidatePath("/annuaire"); revalidatePath("/"); @@ -1304,6 +1313,7 @@ export async function disconnectSocial(formData: FormData) { revalidatePath("/backend/reseaux"); revalidatePath("/backend/promotions"); revalidatePath("/backend/espace"); + revalidatePath("/backend/espace/promotions"); } // ---- Sign out ---- diff --git a/src/app/backend/adherents/[id]/page.tsx b/src/app/backend/adherents/[id]/page.tsx index 8b1cce4..2941b8a 100644 --- a/src/app/backend/adherents/[id]/page.tsx +++ b/src/app/backend/adherents/[id]/page.tsx @@ -73,9 +73,14 @@ export default async function EditMemberPage({
- +
+
+ + +

Laissé vide, la fiche publique affiche l'e-mail du compte.

+
`. */ + categoryGroups: PromoCategoryGroup[]; + /** Pré-sélection déduite du métier de l'adhérent. */ + defaultCategory?: string; networks?: SocialNetwork[]; }) { - const CATEGORIES = categories.length > 0 ? categories : ["Autre"]; + const allCategories = categoryGroups.flatMap((g) => g.items); + const initialCategory = defaultCategory && allCategories.includes(defaultCategory) ? defaultCategory : allCategories[0] ?? "Autre"; const router = useRouter(); const [title, setTitle] = useState(""); const [text, setText] = useState(""); - const [cat, setCat] = useState(CATEGORIES[0]); + const [cat, setCat] = useState(initialCategory); const [badge, setBadge] = useState(""); const [imgData, setImgData] = useState(""); const [shares, setShares] = useState([]); @@ -144,10 +150,16 @@ export function MemberSpaceForm({
- +
diff --git a/src/app/backend/espace/page.tsx b/src/app/backend/espace/page.tsx index b857a82..4c3502e 100644 --- a/src/app/backend/espace/page.tsx +++ b/src/app/backend/espace/page.tsx @@ -1,37 +1,21 @@ import Link from "next/link"; -import { and, asc, desc, eq, gte, inArray, sql } from "drizzle-orm"; +import { and, asc, desc, eq, gte, sql } from "drizzle-orm"; import { getSession } from "@/lib/session"; import { db } from "@/db"; -import { categories, imageConsents, meetingRegistrations, meetings, members, promotions, socialPosts, type Member } from "@/db/schema"; -import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons"; -import { configuredNetworks, SOCIAL_LABELS, type SocialNetwork } from "@/lib/social"; +import { categories, imageConsents, meetingRegistrations, meetings, members, promotions } from "@/db/schema"; +import type { Member } from "@/db/schema"; +import { memberPath } from "@/lib/seo"; import { ImageField } from "@/components/ImageField"; import { ImageConsentForm } from "@/components/ImageConsentForm"; import { HoursEditor } from "@/components/HoursEditor"; import { TagsField } from "@/components/TagsField"; import { communeOptions } from "@/lib/communes"; -import { - saveImageConsent, - setOwnPromoShareTargets, - setOwnPromoSuspension, - updateOwnProfile, -} from "../actions"; -import { MemberSpaceForm } from "./MemberSpaceForm"; +import { saveImageConsent, updateOwnProfile } from "../actions"; +import { EspaceHeader } from "./EspaceHeader"; export const dynamic = "force-dynamic"; -const PROMO_STATUS: Record = { - live: { label: "En ligne", bg: "#e6f4ec", color: "#1f8a5b" }, - pending: { label: "En attente", bg: "#fbeede", color: "#9a6638" }, - rejected: { label: "Refusée", bg: "#fbe9e6", color: "#d8472b" }, - suspended: { label: "Suspendue", bg: "#fbe9e6", color: "#d8472b" }, - expired: { label: "Expirée", bg: "#f1efe7", color: "#a99c82" }, -}; - -function initialsOf(name: string) { - return name.split(/\s+/).map((w) => w[0]).join("").slice(0, 2).toUpperCase(); -} - +/** Espace adhérent, onglet Profil : fiche publique, inscriptions, droit à l'image. */ export default async function EspacePage() { const session = await getSession(); const memberId = session?.user.memberId ?? null; @@ -40,18 +24,7 @@ export default async function EspacePage() { let memberName = fallbackName; let subtitle = "Espace adhérent"; let profile: Member | null = null; - let myPromos: { - id: number; - title: string; - status: string; - createdAt: Date; - imageUrl: string | null; - suspendedBy: "member" | "staff" | null; - shareFacebook: boolean; - shareLinkedin: boolean; - }[] = []; - // Dernière tentative de publication par (promo, réseau). - let lastShare = new Map(); + let livePromoCount = 0; let myRegistrations: { meetingId: number; title: string; startsAt: Date; location: string | null; participants: number; confirmed: boolean }[] = []; let latestConsent: { decision: string; createdAt: Date } | null = null; let profileCategory: { slug: string; label: string } | null = null; @@ -67,39 +40,11 @@ export default async function EspacePage() { profileCategory = cat ?? null; subtitle = ["Espace adhérent", cat?.label, m.city].filter(Boolean).join(" · "); } - myPromos = await db - .select({ - id: promotions.id, - title: promotions.title, - status: promotions.status, - createdAt: promotions.createdAt, - imageUrl: promotions.imageUrl, - suspendedBy: promotions.suspendedBy, - shareFacebook: promotions.shareFacebook, - shareLinkedin: promotions.shareLinkedin, - }) + const [live] = await db + .select({ total: sql`count(*)` }) .from(promotions) - .where(eq(promotions.memberId, memberId)) - .orderBy(desc(promotions.createdAt)); - if (myPromos.length > 0) { - const rows = await db - .select({ - promotionId: socialPosts.promotionId, - network: socialPosts.network, - status: socialPosts.status, - url: socialPosts.url, - error: socialPosts.error, - }) - .from(socialPosts) - .where(inArray(socialPosts.promotionId, myPromos.map((p) => p.id))) - .orderBy(desc(socialPosts.createdAt)); - // Trié du plus récent au plus ancien : la première occurrence gagne. - lastShare = new Map(); - for (const row of rows) { - const key = `${row.promotionId}:${row.network}`; - if (!lastShare.has(key)) lastShare.set(key, row); - } - } + .where(and(eq(promotions.memberId, memberId), eq(promotions.status, "live"))); + livePromoCount = Number(live?.total ?? 0); myRegistrations = await db .select({ meetingId: meetings.id, @@ -123,13 +68,6 @@ export default async function EspacePage() { latestConsent = consent ?? null; } - const catRows = await db - .select({ label: categories.label }) - .from(categories) - .orderBy(asc(categories.sort)); - const categoryLabels = catRows.map((c) => c.label); - const networks = await configuredNetworks(); - function fmtDate(d: Date) { return new Date(d).toLocaleDateString("fr-FR", { day: "numeric", month: "long" }); } @@ -143,17 +81,13 @@ export default async function EspacePage() {
)} -
- - {initialsOf(memberName)} - -
-
- {memberName} -
-
{subtitle}
-
-
+ {memberId && (
@@ -197,6 +131,13 @@ export default async function EspacePage() {
+
+ + +

+ Indépendant de votre identifiant de connexion. Laissé vide, la fiche affiche l'e-mail de votre compte. +

+
- - - - )} - {mp.status === "suspended" && !suspendedByStaff && ( -
- - - -
- )} -
- {suspendedByStaff && ( -
- Cette promotion a été suspendue par l'association. Contactez-la pour la - remettre en ligne. -
- )} - {networks.length > 0 && ( - - )} - - ); - })} - - - ); -} - -/** - * État de diffusion d'une promo côté adhérent : modifiable tant qu'elle est en - * attente, purement informatif dès qu'elle est validée. - */ -function PromoShareState({ - promo, - networks, - lastShare, - editable, -}: { - promo: { id: number; shareFacebook: boolean; shareLinkedin: boolean }; - networks: SocialNetwork[]; - lastShare: Map; - editable: boolean; -}) { - const wanted = (n: SocialNetwork) => (n === "facebook" ? promo.shareFacebook : promo.shareLinkedin); - const chosen = networks.filter(wanted); - - if (editable) { - return ( -
- -
- Diffusion prévue après validation — modifiable tant que la promotion est en attente. -
-
- {networks.map((network) => ( - - ))} - -
-
- ); - } - - if (chosen.length === 0) return null; - - return ( -
- {chosen.map((network) => { - const last = lastShare.get(`${promo.id}:${network}`); - const posted = last?.status === "posted"; - const failed = last?.status === "failed"; - const detail = posted ? "publié" : failed ? "échec de publication" : "en attente"; - const color = posted ? "#1f8a5b" : failed ? "#d8472b" : "#9a6638"; - const chip = ( - - - {SOCIAL_LABELS[network]} · {detail} - - ); - return posted && last?.url ? ( - - {chip} - - ) : ( - {chip} - ); - })}
); } diff --git a/src/app/backend/espace/promotions/page.tsx b/src/app/backend/espace/promotions/page.tsx new file mode 100644 index 0000000..5546dc9 --- /dev/null +++ b/src/app/backend/espace/promotions/page.tsx @@ -0,0 +1,366 @@ +import Link from "next/link"; +import { and, desc, eq, inArray } from "drizzle-orm"; +import { getSession } from "@/lib/session"; +import { db } from "@/db"; +import { categories, members, promotions, socialPosts } from "@/db/schema"; +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 { setOwnPromoShareTargets, setOwnPromoSuspension } from "../../actions"; +import { EspaceHeader } from "../EspaceHeader"; +import { MemberSpaceForm } from "../MemberSpaceForm"; + +export const dynamic = "force-dynamic"; + +const PROMO_STATUS: Record = { + live: { label: "En ligne", bg: "#e6f4ec", color: "#1f8a5b" }, + pending: { label: "En attente de validation", bg: "#fbeede", color: "#9a6638" }, + rejected: { label: "Refusée", bg: "#fbe9e6", color: "#d8472b" }, + suspended: { label: "Suspendue", bg: "#fbe9e6", color: "#d8472b" }, + expired: { label: "Expirée", bg: "#f1efe7", color: "#a99c82" }, +}; + +type MyPromo = { + id: number; + title: string; + status: string; + category: string | null; + badge: string | null; + validUntil: string | null; + createdAt: Date; + imageUrl: string | null; + suspendedBy: "member" | "staff" | null; + shareFacebook: boolean; + shareLinkedin: boolean; +}; + +type ShareState = Map; + +function fmtDate(d: Date) { + return new Date(d).toLocaleDateString("fr-FR", { day: "numeric", month: "long", year: "numeric" }); +} + +/** Espace adhérent, onglet Promotions : proposer une offre et suivre les siennes. */ +export default async function EspacePromotionsPage() { + const session = await getSession(); + const memberId = session?.user.memberId ?? null; + let memberName = session?.user.name ?? "Adhérent"; + let subtitle = "Espace adhérent · Promotions"; + let publicPath: string | null = null; + let categorySlug: string | null = null; + let myPromos: MyPromo[] = []; + let lastShare: ShareState = new Map(); + + if (memberId) { + const [m] = await db + .select({ + id: members.id, + name: members.name, + city: members.city, + status: members.status, + categorySlug: categories.slug, + categoryLabel: categories.label, + }) + .from(members) + .leftJoin(categories, eq(members.categoryId, categories.id)) + .where(eq(members.id, memberId)); + if (m) { + memberName = m.name; + categorySlug = m.categorySlug; + subtitle = ["Espace adhérent", m.categoryLabel, m.city].filter(Boolean).join(" · "); + publicPath = m.status === "active" ? memberPath(m) : null; + } + myPromos = await db + .select({ + id: promotions.id, + title: promotions.title, + status: promotions.status, + category: promotions.category, + badge: promotions.badge, + validUntil: promotions.validUntil, + createdAt: promotions.createdAt, + imageUrl: promotions.imageUrl, + suspendedBy: promotions.suspendedBy, + shareFacebook: promotions.shareFacebook, + shareLinkedin: promotions.shareLinkedin, + }) + .from(promotions) + .where(eq(promotions.memberId, memberId)) + .orderBy(desc(promotions.createdAt)); + if (myPromos.length > 0) { + const rows = await db + .select({ + promotionId: socialPosts.promotionId, + network: socialPosts.network, + status: socialPosts.status, + url: socialPosts.url, + error: socialPosts.error, + }) + .from(socialPosts) + .where(inArray(socialPosts.promotionId, myPromos.map((p) => p.id))) + .orderBy(desc(socialPosts.createdAt)); + // Trié du plus récent au plus ancien : la première occurrence gagne. + for (const row of rows) { + const key = `${row.promotionId}:${row.network}`; + if (!lastShare.has(key)) lastShare.set(key, row); + } + } + } + + const networks = await configuredNetworks(); + const live = myPromos.filter((p) => p.status === "live"); + const pending = myPromos.filter((p) => p.status === "pending"); + const others = myPromos.filter((p) => p.status !== "live" && p.status !== "pending"); + + return ( +
+ {!memberId && ( +
+ Votre compte n'est pas encore relié à une fiche adhérent. Les promotions publiées ne + seront pas rattachées à un commerce. +
+ )} + + + + {/* Résumé : ce qui est visible par le public aujourd'hui */} +
+ + + +
+ + 0 + ? "Ces offres sont visibles sur le site et sur votre fiche. Vous pouvez en suspendre une à tout moment : elle disparaît aussitôt, et vous la remettez en ligne quand vous voulez." + : "Aucune promotion en ligne pour le moment. Proposez une offre ci-dessous : elle apparaîtra ici dès sa validation par l'association." + } + promos={live} + networks={networks} + lastShare={lastShare} + highlight + /> + + + + {pending.length > 0 && ( + + )} + + {others.length > 0 && ( + + )} + + {myPromos.length > 0 && ( +

+ Les offres en ligne apparaissent sur la page d'accueil + {publicPath ? ( + <> + {" "}et sur votre fiche + + ) : null} + . +

+ )} +
+ ); +} + +function StatCard({ label, value, color, bg }: { label: string; value: number; color: string; bg: string }) { + return ( +
+ + {value} + + {label} +
+ ); +} + +function PromoSection({ + title, + intro, + promos, + networks, + lastShare, + highlight = false, +}: { + title: string; + intro: string; + promos: MyPromo[]; + networks: SocialNetwork[]; + lastShare: ShareState; + highlight?: boolean; +}) { + return ( +
+

+ {title} + {promos.length} +

+

{intro}

+ {promos.map((mp) => ( + + ))} +
+ ); +} + +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(" · "); + return ( +
+
+ +
+
{mp.title}
+
+ {meta ? `${meta} · ` : ""}Déposée le {fmtDate(mp.createdAt)} +
+
+ + {st.label} + + {mp.status === "live" && ( +
+ + + +
+ )} + {mp.status === "suspended" && !suspendedByStaff && ( +
+ + + +
+ )} +
+ {suspendedByStaff && ( +
+ Cette promotion a été suspendue par l'association. Contactez-la pour la remettre en ligne. +
+ )} + {networks.length > 0 && } +
+ ); +} + +/** + * État de diffusion d'une promo côté adhérent : modifiable tant qu'elle est en + * attente, purement informatif dès qu'elle est validée. + */ +function PromoShareState({ + promo, + networks, + lastShare, + editable, +}: { + promo: { id: number; shareFacebook: boolean; shareLinkedin: boolean }; + networks: SocialNetwork[]; + lastShare: ShareState; + editable: boolean; +}) { + const wanted = (n: SocialNetwork) => (n === "facebook" ? promo.shareFacebook : promo.shareLinkedin); + const chosen = networks.filter(wanted); + + if (editable) { + return ( +
+ +
+ Diffusion prévue après validation — modifiable tant que la promotion est en attente. +
+
+ {networks.map((network) => ( + + ))} + +
+
+ ); + } + + if (chosen.length === 0) return null; + + return ( +
+ {chosen.map((network) => { + const last = lastShare.get(`${promo.id}:${network}`); + const posted = last?.status === "posted"; + const failed = last?.status === "failed"; + const detail = posted ? "publié" : failed ? "échec de publication" : "en attente"; + const color = posted ? "#1f8a5b" : failed ? "#d8472b" : "#9a6638"; + const chip = ( + + + {SOCIAL_LABELS[network]} · {detail} + + ); + return posted && last?.url ? ( + + {chip} + + ) : ( + {chip} + ); + })} +
+ ); +} diff --git a/src/app/globals.css b/src/app/globals.css index 8e03ad5..5332c88 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -948,6 +948,56 @@ img { color: var(--muted); margin-bottom: 6px; } +/* Onglets de l'espace adhérent (Profil / Promotions), sous le bandeau. */ +.espace-tabs { + display: flex; + gap: 4px; + background: #fff; + border: 1px solid #e6dcc6; + border-top: none; + border-radius: 0 0 16px 16px; + padding: 6px; +} +.espace-tab { + flex: 1; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 11px 14px; + border-radius: 11px; + font-family: "Bricolage Grotesque", "Public Sans", system-ui, sans-serif; + font-weight: 700; + font-size: 14.5px; + color: var(--muted); + text-decoration: none; + transition: background 0.15s ease, color 0.15s ease; +} +.espace-tab:hover { + background: #faf7ef; + color: var(--brown); +} +.espace-tab.active { + background: var(--gold); + color: #33291D; +} +.espace-tab__badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 22px; + height: 22px; + padding: 0 7px; + border-radius: 999px; + background: #1f8a5b; + color: #fff; + font-size: 12px; + font-weight: 800; +} +.espace-tab.active .espace-tab__badge { + background: #33291D; + color: #fff; +} /* Suggestions de tags : pastilles cliquables sous le champ. */ .tag-suggestions { display: flex; diff --git a/src/components/VitrineImage.tsx b/src/components/VitrineImage.tsx index 52b1301..7b29ea3 100644 --- a/src/components/VitrineImage.tsx +++ b/src/components/VitrineImage.tsx @@ -6,7 +6,9 @@ const STRIPE_WARM = /** * Zone d'image "vitrine" d'un adhérent. * - Photo de couverture présente -> affichée en plein cadre (cover). - * - Sinon, le logo est affiché centré et réduit (contain ~80%), pas étiré. + * - Sinon, le logo est affiché centré et réduit (contain, ~60 % du cadre) sur + * un fond fait du logo lui-même flouté : le cadre prend les couleurs de la + * marque sans que le logo soit étiré ni ne déborde. * - Sinon, fond rayé + texte placeholder. */ export function VitrineImage({ @@ -43,30 +45,43 @@ export function VitrineImage({ } if (logoOnly) { + // `height` est fixe sur le cadre : les max-height en % du logo sont donc + // résolus, là où un conteneur intermédiaire sans hauteur les laissait sans + // effet et le logo débordait. return ( -
- {/* Cadre thème autour du logo (évite le bloc blanc nu) */} +
- {/* eslint-disable-next-line @next/next/no-img-element */} - -
+ /> +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {children}
); diff --git a/src/db/schema.ts b/src/db/schema.ts index 126bac8..b04d79e 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -52,7 +52,11 @@ export const categories = pgTable("categories", { export const members = pgTable("members", { id: serial("id").primaryKey(), name: varchar("name", { length: 200 }).notNull(), + // E-mail administratif : identifiant de connexion à la création, échanges + // avec l'association. Jamais forcément celui que le commerce veut afficher. email: varchar("email", { length: 200 }).notNull(), + // E-mail public de la fiche ; à vide, la fiche retombe sur `email`. + contactEmail: varchar("contact_email", { length: 200 }), categoryId: integer("category_id").references(() => categories.id), city: varchar("city", { length: 120 }), address: varchar("address", { length: 240 }), diff --git a/src/lib/promo-categories.ts b/src/lib/promo-categories.ts new file mode 100644 index 0000000..0966877 --- /dev/null +++ b/src/lib/promo-categories.ts @@ -0,0 +1,262 @@ +/** + * Catégories de promotion : le **type de produit ou de service** mis en avant, + * indépendant du métier de l'adhérent (une boulangerie peut promouvoir un + * goûter, un garage des pneus). Affichées en pastille sur la carte de la promo. + * + * Module pur, groupé pour un `