Skip to content

#53 — Espace Bannières (Gestion Publicités Revendeur)

PLANIFIÉ

Priorité: 🟠 HAUTE · Type: TYPE D · Conteneur: rgz-web · Code: web/src/pages/BannersAdmin.tsx

Dépendances: #2 rgz-web, #1 rgz-api


Description

Interface web permettant aux revendeurs de gérer leurs bannières publicitaires affichées sur le portail captif #3. Upload images (JPEG/PNG max 5MB), définir durée affichage (3-30 jours), période de validité, ciblage par NAS-ID ou site. Tracking impressions : nombre de fois bannière affichée au portail vs clics.

Les bannières sont stockées dans S3 (MinIO) avec URL CDN cachée 24h. Le portail appelle API GET /api/v1/banners/for-nas?nas_id=access_kossou pour récupérer bannières actives, puis rotation toutes les 8 secondes (#16 bannieres).

Chaque revendeur voit uniquement ses bannières (IDOR sécurisé). Dashboard affiche stats impressions/clics par semaine, ROI estimé (clics/impressions). Modération RGZ possible (flag inapproprié).

Architecture Interne

Banner Management Dataflow:
  1. Frontend (React BannersAdmin.tsx):
     └─ useAuth() → JWT revendeur
     └─ useQuery("banners", {reseller_id}) → liste banners du revendeur
     └─ Form upload: file input + metadata (duration, period, targeting)

  2. File Upload Flow:
     ├─ Client validation:
     │  • File type check: mimetype JPEG/PNG
     │  • Magic bytes check (client-side preview)
     │  • File size <5MB
     │  • Image dimensions: 1200x400px (responsive)
     ├─ POST /api/v1/banners/upload
     │  └─ Multipart form-data
     │  └─ Backend (app/api/v1/endpoints/banners.py):
     │     • Magic bytes server-side check (PIL image open)
     │     • Virus scan (ClamAV optional)
     │     • Image resize/optimize (Pillow → max 200KB)
     │     • Upload to MinIO S3:
     │       s3://rgz-banners/reseller_{reseller_id}/{banner_id}.webp
     │     • Generate signed URL CDN (valid 24h)
     │     • Return: {banner_id, url, thumbnail_url, size_bytes}
     └─ SEC-12: Content-Type validation + magic bytes

  3. Banner Metadata (Database):
     ├─ Table banners:
     │  id UUID PK, reseller_id UUID FK, title, description,
     │  image_url, thumbnail_url, s3_key,
     │  display_duration_days INT CHECK(3-30),
     │  valid_from TIMESTAMP, valid_to TIMESTAMP,
     │  targeting_nas_id TEXT[], targeting_city TEXT[],
     │  status CHECK(draft|active|paused|expired),
     │  created_at, updated_at,
     │  UNIQUE(reseller_id, s3_key)

     └─ Table banner_impressions (hypertable TimescaleDB):
     │  time BIGINT, banner_id UUID, nas_id TEXT, subscriber_id UUID,
     │  impression_count INT, click_count INT

     └─ Table banner_clicks:
        id UUID PK, banner_id UUID FK, subscriber_id UUID,
        click_timestamp TIMESTAMP, redirect_url, utm_params

  4. Banner Retrieval (Portail #3):
     └─ GET /api/v1/banners/for-nas?nas_id=access_kossou&limit=3
     └─ Response:
        {
          "items": [
            {
              "banner_id": "uuid",
              "image_url": "https://cdn.rgz/banners/reseller_xxx/banner_1.webp",
              "redirect_url": "https://techconnect.com/promo",
              "display_duration_seconds": 8,
              "rotation_priority": 1
            },
            ...
          ],
          "total": 2
        }
     └─ Conditions: valid_from <= NOW() <= valid_to AND active
     └─ Cache Redis: rgz:banners:{nas_id} TTL=3600s

  5. Impression Tracking:
     └─ Portail JS: banner shown → POST /api/v1/banners/{banner_id}/impression
     └─ Backend: INSERT into banner_impressions (timestamp, banner_id, nas_id, subscriber_id, count=1)
     └─ Aggregate: SUM impressions per 1h bucket

  6. Click Tracking:
     └─ User clicks banner → POST /api/v1/banners/{banner_id}/click?redirect_url=
     └─ Backend: INSERT banner_clicks, then 302 redirect to original URL
     └─ Track UTM params if present

  7. Admin Dashboard (ResellerAdmin):
     └─ Cards KPI: total impressions (week), total clicks, click-through rate %
     └─ Charts: impressions timeline, clicks timeline, top performing banners
     └─ Table: active banners with status, valid period, impressions/clicks count
     └─ Bulk actions: pause, delete, extend validity

  8. RGZ Moderation (Optional):
     └─ Flag inappropriate: POST /api/v1/banners/{banner_id}/report
     └─ Admin review: dashboard flagged banners
     └─ Action: auto-suspend or reject

Configuration

env
# Frontend (web/.env)
VITE_BANNER_MAX_SIZE_MB=5
VITE_BANNER_RECOMMENDED_WIDTH=1200
VITE_BANNER_RECOMMENDED_HEIGHT=400

# Backend (app/config.py)
S3_ENDPOINT=http://rgz-minio:9000  # ou AWS S3
S3_BUCKET=rgz-banners
S3_ACCESS_KEY=${AWS_ACCESS_KEY_ID}
S3_SECRET_KEY=${AWS_SECRET_ACCESS_KEY}
S3_REGION=us-east-1

# CDN signed URL
CDN_SIGN_DURATION_HOURS=24
CDN_BASE_URL=https://cdn-rgz.duckdns.org/banners  # Cloudflare workers optional

# Image optimization
BANNER_IMAGE_MAX_KB=200
BANNER_IMAGE_FORMAT=webp  # Pillow PIL format
BANNER_IMAGE_QUALITY=85

# MinIO/S3 upload validation
MAX_UPLOAD_FILESIZE=5242880  # 5MB

# Virus scanning (optional)
CLAMAV_ENABLED=false
CLAMAV_HOST=clamav.rgz.svc.cluster.local:3310

# Redis caching
BANNER_CACHE_TTL=3600  # 1h

Endpoints API

MéthodeRouteRéponse
POST/api/v1/banners/uploadMultipart form-data → {banner_id, url, thumbnail_url, size_bytes}
GET/api/v1/banners?reseller_id=&limit=20{items: [{id, title, image_url, created_at, status, impressions, clicks}], total, pages}
GET/api/v1/banners/{banner_id}Full banner object avec metadata
PUT/api/v1/banners/{banner_id}Update title, duration, period, targeting, status
DELETE/api/v1/banners/{banner_id}Soft delete (set status=deleted, keep S3 object)
PATCH/api/v1/banners/{banner_id}/status{status: active|paused|expired}
GET/api/v1/banners/for-nas?nas_id=&limit=3Cached list pour portail
POST/api/v1/banners/{banner_id}/impressionTrack impression : {banner_id, nas_id, subscriber_id}
POST/api/v1/banners/{banner_id}/clickTrack click + redirect : {redirect_url} → 302 redirect
GET/api/v1/banners/{banner_id}/stats?from=&to={impressions, clicks, ctr%, impressions_by_day[], clicks_by_day[]}
POST/api/v1/banners/{banner_id}/reportModeration flag : {reason, reported_by}

Composants React

typescript
// web/src/pages/BannersAdmin.tsx

import React, { useState } from 'react';
import { useAuth } from '@/hooks/useAuth';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import DragDropZone from '@/components/Forms/DragDropZone';
import BannerTable from '@/components/Tables/BannerTable';
import BannerStatsChart from '@/components/Charts/BannerStatsChart';
import api from '@/services/api';

export default function BannersAdmin() {
  const { user } = useAuth();
  const queryClient = useQueryClient();
  const [selectedFile, setSelectedFile] = useState(null);
  const [uploadProgress, setUploadProgress] = useState(0);

  // Query: List banners
  const { data: banners, isLoading } = useQuery({
    queryKey: ['banners', user?.reseller_id],
    queryFn: () => api.get(`/api/v1/banners?reseller_id=${user?.reseller_id}`),
    staleTime: 300000,  // 5 min
  });

  // Mutation: Upload banner
  const uploadMutation = useMutation({
    mutationFn: async (formData) => {
      return api.post('/api/v1/banners/upload', formData, {
        headers: { 'Content-Type': 'multipart/form-data' },
        onUploadProgress: (e) => setUploadProgress(Math.round(100 * e.loaded / e.total)),
      });
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['banners'] });
      setSelectedFile(null);
      setUploadProgress(0);
    },
  });

  // Mutation: Delete banner
  const deleteMutation = useMutation({
    mutationFn: (bannerId) => api.delete(`/api/v1/banners/${bannerId}`),
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ['banners'] }),
  });

  // File handling
  const handleFileSelect = async (files) => {
    const file = files[0];
    if (!file) return;

    // Client-side validation
    if (file.size > 5242880) {
      alert('Fichier trop gros (max 5MB)');
      return;
    }

    if (!['image/jpeg', 'image/png'].includes(file.type)) {
      alert('Format accepté: JPEG, PNG');
      return;
    }

    setSelectedFile(file);
  };

  const handleUpload = async () => {
    if (!selectedFile) return;

    const formData = new FormData();
    formData.append('file', selectedFile);
    formData.append('title', document.querySelector('#banner-title').value);
    formData.append('duration_days', document.querySelector('#duration-days').value);
    formData.append('valid_from', document.querySelector('#valid-from').value);
    formData.append('valid_to', document.querySelector('#valid-to').value);
    formData.append('targeting_nas_id', document.querySelector('#targeting-nas').value);

    uploadMutation.mutate(formData);
  };

  return (
    <div className="space-y-6">
      {/* Header */}
      <header className="flex justify-between items-center">
        <h1 className="text-3xl font-bold text-access-dark">Bannières Publicitaires</h1>
        <span className="text-sm text-gray-600">{banners?.data?.total || 0} bannières actives</span>
      </header>

      {/* Upload Section */}
      <section className="bg-white rounded-lg shadow p-6 border border-gray-200">
        <h2 className="text-xl font-bold mb-4">Ajouter une Bannière</h2>

        <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
          {/* Drag & Drop Zone */}
          <div>
            <DragDropZone
              onFileSelect={handleFileSelect}
              acceptedTypes={['image/jpeg', 'image/png']}
              maxSize={5242880}
              label="Déposer image ici (ou cliquer)"
            />
            {selectedFile && (
              <div className="mt-4 p-4 bg-blue-50 rounded-lg">
                <p className="text-sm font-semibold">{selectedFile.name}</p>
                <p className="text-xs text-gray-600">{(selectedFile.size / 1024).toFixed(1)} KB</p>
              </div>
            )}
            {uploadProgress > 0 && uploadProgress < 100 && (
              <div className="mt-4">
                <progress value={uploadProgress} max="100" className="w-full" />
                <p className="text-xs text-gray-600 mt-1">{uploadProgress}%</p>
              </div>
            )}
          </div>

          {/* Metadata Form */}
          <form className="space-y-4">
            <div>
              <label className="block text-sm font-semibold mb-1">Titre</label>
              <input
                id="banner-title"
                type="text"
                placeholder="Promo Pass Unlimited"
                className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-access-yellow"
              />
            </div>

            <div>
              <label className="block text-sm font-semibold mb-1">Durée d'affichage (jours)</label>
              <select id="duration-days" className="w-full px-3 py-2 border border-gray-300 rounded-lg">
                {[3, 7, 14, 21, 30].map(d => <option key={d} value={d}>{d} jours</option>)}
              </select>
            </div>

            <div className="grid grid-cols-2 gap-2">
              <div>
                <label className="block text-sm font-semibold mb-1">Valide de</label>
                <input
                  id="valid-from"
                  type="date"
                  className="w-full px-3 py-2 border border-gray-300 rounded-lg"
                  defaultValue={new Date().toISOString().split('T')[0]}
                />
              </div>
              <div>
                <label className="block text-sm font-semibold mb-1">à</label>
                <input
                  id="valid-to"
                  type="date"
                  className="w-full px-3 py-2 border border-gray-300 rounded-lg"
                />
              </div>
            </div>

            <div>
              <label className="block text-sm font-semibold mb-1">Cibler NAS-ID (optionnel)</label>
              <input
                id="targeting-nas"
                type="text"
                placeholder="access_kossou, access_parakou"
                className="w-full px-3 py-2 border border-gray-300 rounded-lg text-xs"
              />
            </div>

            <button
              type="button"
              onClick={handleUpload}
              disabled={!selectedFile || uploadMutation.isPending}
              className="w-full px-4 py-2 bg-access-yellow text-access-dark rounded-lg font-semibold hover:bg-opacity-90 disabled:opacity-50"
            >
              {uploadMutation.isPending ? 'Upload en cours...' : 'Publier'}
            </button>
          </form>
        </div>
      </section>

      {/* Banners List */}
      <section className="bg-white rounded-lg shadow p-6 border border-gray-200">
        <h2 className="text-xl font-bold mb-4">Mes Bannières</h2>

        {isLoading ? (
          <p className="text-gray-600">Chargement...</p>
        ) : (
          <BannerTable
            banners={banners?.data?.items || []}
            onDelete={(id) => deleteMutation.mutate(id)}
            onPause={(id) => api.patch(`/api/v1/banners/${id}/status`, { status: 'paused' })}
          />
        )}
      </section>

      {/* Stats */}
      <section className="bg-white rounded-lg shadow p-6 border border-gray-200">
        <h2 className="text-xl font-bold mb-4">Statistiques</h2>
        <BannerStatsChart bannerId={banners?.data?.items?.[0]?.id} />
      </section>
    </div>
  );
}

Commandes Utiles

bash
# Tester upload bannière
curl -X POST https://api-rgz.duckdns.org/api/v1/banners/upload \
  -H "Authorization: Bearer ${JWT_TOKEN}" \
  -F "file=@/path/to/banner.png" \
  -F "title=Promo" \
  -F "duration_days=7" \
  -F "targeting_nas_id=access_kossou"

# Récupérer bannières pour portail
curl 'https://api-rgz.duckdns.org/api/v1/banners/for-nas?nas_id=access_kossou&limit=3'

# Tracker impression
curl -X POST https://api-rgz.duckdns.org/api/v1/banners/uuid/impression \
  -H "Content-Type: application/json" \
  -d '{"nas_id": "access_kossou", "subscriber_id": "uuid"}'

# Tracker click
curl -X POST 'https://api-rgz.duckdns.org/api/v1/banners/uuid/click?redirect_url=https://promo.com' \
  -H "Authorization: Bearer ${JWT_TOKEN}"

# Vérifier stats bannière
curl https://api-rgz.duckdns.org/api/v1/banners/uuid/stats?from=2026-02-14&to=2026-02-21

# Lister dans MinIO
docker exec rgz-minio mc ls rgz/rgz-banners/

Implémentation TODO

  • [ ] Créer composant React BannersAdmin.tsx avec file upload + metadata form
  • [ ] Implémenter DragDropZone composant (acceptFiles, maxSize)
  • [ ] Créer endpoint POST /api/v1/banners/upload avec magic bytes check (PIL)
  • [ ] Ajouter image optimization : Pillow resize/compress webp (max 200KB)
  • [ ] Configurer MinIO S3 bucket rgz-banners avec lifecycle policies
  • [ ] Implémenter signed URL generation (24h validity)
  • [ ] Créer table banners + banner_impressions (hypertable) + banner_clicks
  • [ ] Ajouter endpoint GET /api/v1/banners/for-nas avec Redis cache
  • [ ] Implémenter impression/click tracking endpoints
  • [ ] Créer BannerStatsChart (impressions + clicks timeline)
  • [ ] Tests : file upload validation, magic bytes, S3 upload, click tracking

Dernière mise à jour: 2026-02-21

PROJET MOSAÏQUE — 81 outils, 22 conteneurs, 500+ revendeurs WiFi Zone