#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 rejectConfiguration
# 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 # 1hEndpoints API
| Méthode | Route | Réponse |
|---|---|---|
| POST | /api/v1/banners/upload | Multipart 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=3 | Cached list pour portail |
| POST | /api/v1/banners/{banner_id}/impression | Track impression : {banner_id, nas_id, subscriber_id} |
| POST | /api/v1/banners/{banner_id}/click | Track 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}/report | Moderation flag : {reason, reported_by} |
Composants React
// 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
# 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.tsxavec file upload + metadata form - [ ] Implémenter DragDropZone composant (acceptFiles, maxSize)
- [ ] Créer endpoint POST
/api/v1/banners/uploadavec magic bytes check (PIL) - [ ] Ajouter image optimization : Pillow resize/compress webp (max 200KB)
- [ ] Configurer MinIO S3 bucket
rgz-bannersavec 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-nasavec 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