From f8aa8eb5951c448e6a301ca7d80e46611365cf31 Mon Sep 17 00:00:00 2001 From: diallolatoile Date: Sun, 11 Jan 2026 04:14:08 +0000 Subject: [PATCH] feat: Manage Images using Minio Service --- src/app/app.scss | 463 +++++++++++++++++ src/app/core/services/minio.service.ts | 432 +++++++++++++--- .../merchant-config-list.html | 49 +- .../merchant-config-list.ts | 167 ++++-- .../merchant-config-view.html | 167 +++--- .../merchant-config-view.ts | 150 ++++-- .../merchant-config/merchant-config.html | 202 +++++--- .../merchant-config/merchant-config.ts | 474 +++++++++++------- .../merchant-data-adapter.service.ts | 2 +- src/assets/images/01.svg | 1 + 10 files changed, 1632 insertions(+), 475 deletions(-) create mode 100644 src/assets/images/01.svg diff --git a/src/app/app.scss b/src/app/app.scss index e0c0ebd..66507b1 100644 --- a/src/app/app.scss +++ b/src/app/app.scss @@ -646,3 +646,466 @@ font-size: 0.8125rem; } } + +// ==================== STYLES POUR LA GESTION DES LOGOS ==================== +// merchant-config.component.scss + +// Variables +$logo-size-sm: 48px; +$logo-size-md: 80px; +$logo-size-lg: 120px; +$logo-size-xl: 200px; + +$border-radius-sm: 0.25rem; +$border-radius-md: 0.5rem; +$border-radius-lg: 0.75rem; + +$transition-base: all 0.3s ease; + +// ==================== UPLOAD DE LOGO ==================== +.logo-upload-section { + margin-bottom: 1.5rem; + + .logo-upload-container { + display: flex; + flex-direction: column; + gap: 1rem; + } + + .logo-preview-area { + display: flex; + justify-content: center; + align-items: center; + min-height: 200px; + } + + .logo-preview { + position: relative; + width: 200px; + height: 200px; + border: 2px solid #e9ecef; + border-radius: 0.5rem; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + background: #f8f9fa; + + img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + } + + .btn-remove-preview { + position: absolute; + top: 0.5rem; + right: 0.5rem; + background: rgba(220, 53, 69, 0.9); + color: white; + border: none; + border-radius: 50%; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.3s ease; + + &:hover { + background: rgba(220, 53, 69, 1); + transform: scale(1.1); + } + + i { + font-size: 16px; + } + } + } + + .logo-placeholder { + width: 200px; + height: 200px; + border: 2px dashed #dee2e6; + border-radius: 0.5rem; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: #6c757d; + background: #f8f9fa; + + i { + opacity: 0.5; + margin-bottom: 1rem; + } + + p { + margin: 0; + font-size: 0.875rem; + } + } + + .logo-upload-actions { + text-align: center; + + .btn-select-logo { + cursor: pointer; + display: inline-block; + } + } + + .upload-progress { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem; + background: #e7f3ff; + border-radius: 0.25rem; + margin-top: 0.5rem; + + .spinner-border { + width: 1rem; + height: 1rem; + } + } +} + +// ==================== ÉDITION DE LOGO ==================== +.logo-edit-section { + .logo-edit-container { + display: flex; + flex-direction: column; + gap: 1rem; + } + + .logo-display-area { + display: flex; + justify-content: center; + align-items: center; + min-height: 150px; + } + + .logo-preview { + position: relative; + width: 150px; + height: 150px; + border: 2px solid #e9ecef; + border-radius: 0.5rem; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + background: #f8f9fa; + + img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + } + + .logo-badge { + position: absolute; + top: 0.5rem; + left: 0.5rem; + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + font-size: 0.75rem; + font-weight: 600; + + &.badge-new { + background: #28a745; + color: white; + } + + &.badge-current { + background: #007bff; + color: white; + } + } + + .btn-remove-preview { + position: absolute; + top: 0.5rem; + right: 0.5rem; + background: rgba(220, 53, 69, 0.9); + color: white; + border: none; + border-radius: 50%; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.3s ease; + + &:hover { + background: rgba(220, 53, 69, 1); + transform: scale(1.1); + } + } + } + + .logo-placeholder { + width: 150px; + height: 150px; + border: 2px dashed #dee2e6; + border-radius: 0.5rem; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: #6c757d; + background: #f8f9fa; + + i { + opacity: 0.5; + margin-bottom: 0.5rem; + } + } + + .logo-edit-actions { + display: flex; + gap: 0.5rem; + justify-content: center; + flex-wrap: wrap; + + .btn-change-logo, + .btn-remove-logo { + cursor: pointer; + } + } +} + +// ==================== AFFICHAGE DES LOGOS DANS LA LISTE ==================== +.merchant-logo { + width: 48px; + height: 48px; + object-fit: cover; + border-radius: 0.25rem; + border: 1px solid #dee2e6; + background: #f8f9fa; +} + +.merchant-logo-large { + max-width: 200px; + max-height: 200px; + object-fit: contain; + border-radius: 0.5rem; + border: 2px solid #e9ecef; + background: #f8f9fa; +} + +.avatar-container { + position: relative; + display: inline-block; + + .merchant-logo { + display: block; + } +} + +// ==================== ÉTATS DE CHARGEMENT ==================== +.logo-loading { + position: relative; + + &::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 24px; + height: 24px; + border: 3px solid rgba(0, 0, 0, 0.1); + border-top-color: #007bff; + border-radius: 50%; + animation: spin 0.8s linear infinite; + } +} + +@keyframes spin { + to { + transform: translate(-50%, -50%) rotate(360deg); + } +} + +// ==================== RESPONSIVE ==================== +@media (max-width: 768px) { + .logo-upload-section, + .logo-edit-section { + .logo-preview, + .logo-placeholder { + width: 150px; + height: 150px; + } + } + + .merchant-logo-large { + max-width: 150px; + max-height: 150px; + } +} + +// ==================== ANIMATIONS ==================== +.logo-preview img, +.merchant-logo { + transition: transform 0.3s ease; + + &:hover { + transform: scale(1.05); + } +} + +// ==================== ERREURS ==================== +.alert-danger { + margin-top: 0.5rem; + padding: 0.5rem 1rem; + font-size: 0.875rem; + + i { + margin-right: 0.5rem; + } +} + +.merchant-logo-container { + width: 120px; + height: 120px; + flex-shrink: 0; +} + +.merchant-logo-large { + width: 100%; + height: 100%; + object-fit: cover; + border: 3px solid rgba(255, 255, 255, 0.3); +} + +.merchant-logo-large-placeholder { + width: 100%; + height: 100%; + border: 3px solid rgba(255, 255, 255, 0.3); +} + +.profile-header { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + padding: 2rem; +} + +.stats-card { + text-align: center; + padding: 1.5rem; + border-radius: 0.5rem; + background: white; + border: 1px solid #e9ecef; + transition: all 0.3s ease; +} + +.stats-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +.stats-icon { + font-size: 1.5rem; +} + +.stats-number { + font-size: 2rem; + font-weight: bold; + color: #495057; +} + +.stats-label { + color: #6c757d; + font-size: 0.875rem; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +@media (max-width: 768px) { + .merchant-logo-container { + width: 80px; + height: 80px; + } + + .profile-header { + padding: 1.5rem; + } + + .profile-header h2 { + font-size: 1.5rem; + } +} + + +/* Styles pour les logos dans la liste des marchands (TABLE) */ +.merchant-logo-list { + width: 40px; + height: 40px; + min-width: 40px; +} + +.merchant-logo-list img { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 50%; + border: 2px solid #f0f0f0; +} + +.merchant-logo-list-placeholder { + width: 40px; + height: 40px; + min-width: 40px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + border: 2px solid #f0f0f0; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; +} + +/* Gardez vos styles existants pour le profil */ +.merchant-logo-container { + width: 120px; + height: 120px; + flex-shrink: 0; +} + +.merchant-logo-large { + width: 100%; + height: 100%; + object-fit: cover; + border: 3px solid rgba(255, 255, 255, 0.3); +} + +.merchant-logo-large-placeholder { + width: 100%; + height: 100%; + border: 3px solid rgba(255, 255, 255, 0.3); +} + +/* Responsive */ +@media (max-width: 768px) { + .merchant-logo-list { + width: 32px; + height: 32px; + min-width: 32px; + } + + .merchant-logo-list-placeholder { + width: 32px; + height: 32px; + min-width: 32px; + } + + .merchant-logo-container { + width: 80px; + height: 80px; + } +} \ No newline at end of file diff --git a/src/app/core/services/minio.service.ts b/src/app/core/services/minio.service.ts index e4dd573..aafc9d7 100644 --- a/src/app/core/services/minio.service.ts +++ b/src/app/core/services/minio.service.ts @@ -1,98 +1,271 @@ -import { Injectable } from '@angular/core'; -import { HttpClient, HttpHeaders } from '@angular/common/http'; -import { Observable } from 'rxjs'; -import { map } from 'rxjs/operators'; +import { Injectable, inject } from '@angular/core'; +import { HttpClient, HttpHeaders, HttpEventType, HttpResponse } from '@angular/common/http'; +import { Observable, throwError, of, Subject } from 'rxjs'; +import { map, catchError, tap, filter } from 'rxjs/operators'; import { environment } from '@environments/environment'; -export interface UploadLogoResponse { +export interface ImageUploadResponse { + message : string; success: boolean; - fileName: string; - url: string; - size: number; + merchant: { + id: string, + name: string + }; + data: { + fileName: string; + url: string; + publicUrl: string; + downloadUrl: string; + size: number; + contentType: string; + uploadedAt: Date; + }; +} + +export interface ImageValidationResult { + valid: boolean; + error?: string; +} + +export interface UploadProgress { + loaded: number; + total: number; + percentage: number; +} + +export interface LogoUrlResponse { + success: boolean; + data: { + fileName: string; + url: string; + merchantId: string; + merchantName: string; + }; +} + +export interface LogoUrlOptions { + signed?: boolean; + expirySeconds?: number; } @Injectable({ providedIn: 'root' }) export class MinioService { - private apiUrl = `${environment.configApiUrl}/minio`; // URL de votre backend - - constructor(private http: HttpClient) {} + private http = inject(HttpClient); + private baseUrl = `${environment.configApiUrl}/images`; + + // Sujet pour les événements de progression + private uploadProgressSubject = new Subject(); + public uploadProgress$ = this.uploadProgressSubject.asObservable(); + + // Types MIME autorisés + private readonly ALLOWED_MIME_TYPES = [ + 'image/jpeg', + 'image/jpg', + 'image/png', + 'image/gif', + 'image/webp', + 'image/svg+xml' + ]; + + // Taille maximale : 5MB + private readonly MAX_FILE_SIZE = 5 * 1024 * 1024; /** - * Upload un logo de marchand vers MinIO + * Valide un fichier image */ - uploadMerchantLogo(file: File, merchantId?: number): Observable { + validateImageFile(file: File): ImageValidationResult { + // Vérifier le type MIME + if (!this.ALLOWED_MIME_TYPES.includes(file.type)) { + return { + valid: false, + error: `Type de fichier non autorisé. Formats acceptés : JPG, PNG, GIF, WebP, SVG` + }; + } + + // Vérifier la taille + if (file.size > this.MAX_FILE_SIZE) { + const maxSizeMB = this.MAX_FILE_SIZE / (1024 * 1024); + return { + valid: false, + error: `Fichier trop volumineux. Taille maximum : ${maxSizeMB}MB` + }; + } + + return { valid: true }; + } + + /** + * MÉTHODE PRINCIPALE - Upload via le serveur + * préparation, upload et vérification + */ + uploadMerchantLogo( merchantId: number, merchantName: string, file: File): Observable { + // Validation + const validation = this.validateImageFile(file); + if (!validation.valid) { + return throwError(() => new Error(validation.error)); + } + const formData = new FormData(); formData.append('file', file); - formData.append('bucketName', 'bo-assets'); - - // Générer un nom unique pour le logo - const timestamp = Date.now(); - const extension = file.name.split('.').pop(); - const fileName = merchantId - ? `merchant_${merchantId}_${timestamp}.${extension}` - : `logo_${timestamp}.${extension}`; - - formData.append('objectName', fileName); - return this.http.post(`${this.apiUrl}/upload-logo`, formData); - } + // Ajouter des métadonnées optionnelles + if (merchantId) { + formData.append('merchantId', merchantId.toString()); + } + if (merchantName) { + formData.append('merchantName', merchantName); + } - /** - * Récupère l'URL présignée pour afficher un logo - * URL valide pour 7 jours - */ - getMerchantLogoUrl(logoFileName: string): Observable { - const expiry = 7 * 24 * 60 * 60; // 7 jours en secondes - return this.http.get<{ url: string }>( - `${this.apiUrl}/presigned-url`, - { - params: { - bucketName: 'bo-assets', - objectName: logoFileName, - expiry: expiry.toString() - } - } - ).pipe(map(response => response.url)); - } - - /** - * Supprime un logo de marchand - */ - deleteMerchantLogo(logoFileName: string): Observable { - return this.http.delete(`${this.apiUrl}/delete`, { - params: { - bucketName: 'bo-assets', - objectName: logoFileName - } + // Headers + const headers = new HttpHeaders({ + 'Accept': 'application/json' }); + + return this.http.post( + `${this.baseUrl}/merchants/${merchantId}/logos/upload`, + formData, + { + headers, + reportProgress: true, + observe: 'events' + } + ).pipe( + + filter(event => event.type === HttpEventType.Response), + + // Extraire le body + map(event => event.body as ImageUploadResponse), + + catchError(error => { + console.error('❌ Error uploading image:', error); + return throwError(() => new Error( + error.error?.message || + error.error?.error || + error.message || + 'Erreur lors de l\'upload du logo' + )); + }) + ); } /** - * Valide qu'un fichier est une image valide + * Récupère l'URL (presigned) d'un logo marchand */ - validateImageFile(file: File): { valid: boolean; error?: string } { - // Vérifier le type MIME - const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml']; - if (!allowedTypes.includes(file.type)) { - return { - valid: false, - error: 'Format non supporté. Utilisez JPG, PNG, GIF, WebP ou SVG.' - }; + getMerchantLogoUrl( + merchantId: string, + fileName: string, + options: LogoUrlOptions = {} + ): Observable { + if (!merchantId || !fileName || fileName.trim() === '') { + return throwError(() => new Error('Paramètres invalides')); } - // Vérifier la taille (2MB max pour un logo) - const maxSize = 2 * 1024 * 1024; // 2MB - if (file.size > maxSize) { - return { - valid: false, - error: 'Le fichier est trop volumineux (max 2MB pour un logo)' - }; + const params: any = { + fileName + }; + + if (options.signed) { + params.signed = 'true'; } - // Vérifier les dimensions si possible (optionnel) - return { valid: true }; + if (options.expirySeconds) { + params.expiry = options.expirySeconds.toString(); + } + + return this.http.get( + `${this.baseUrl}/merchants/${merchantId}/logos/url`, + { params } + ).pipe( + map(response => { + if (response?.success && response.data?.url) { + return response; + } + throw new Error('Logo non trouvé'); + }), + catchError(error => { + console.error('❌ Error getting logo URL:', error); + return throwError(() => new Error( + error.error?.message || + error.message || + 'Logo non trouvé' + )); + }) + ); + } + + /** + * Récupère les informations complètes d'une image + */ + getImageInfo(fileName: string): Observable { + if (!fileName || fileName.trim() === '') { + return throwError(() => new Error('Nom de fichier invalide')); + } + + return this.http.get<{ + success: boolean; + data?: ImageUploadResponse + }>(`${this.baseUrl}/info/${encodeURIComponent(fileName)}`).pipe( + map(response => { + if (response.success && response.data) { + return response.data; + } + throw new Error('Image not found or error in response'); + }), + catchError(this.handleError('get image info')) + ); + } + + /** + * Supprime un logo + */ + deleteMerchantLogo(fileName: string): Observable { + if (!fileName || fileName.trim() === '') { + return throwError(() => new Error('Nom de fichier invalide')); + } + + return this.http.delete<{ + success: boolean; + message: string + }>( + `${this.baseUrl}/${encodeURIComponent(fileName)}` + ).pipe( + map(response => { + if (response.success) { + return void 0; + } + throw new Error(response.message || 'Erreur lors de la suppression'); + }), + catchError(error => { + console.error('❌ Error deleting logo:', error); + return throwError(() => new Error( + error.error?.message || 'Erreur lors de la suppression du logo' + )); + }) + ); + } + + /** + * Liste les images de l'utilisateur courant + */ + getMyImages(): Observable { + return this.http.get<{ + success: boolean; + count: number; + images: ImageUploadResponse[] + }>(`${this.baseUrl}/my-images`).pipe( + map(response => { + if (response.success) { + return response.images || []; + } + return []; + }), + catchError(error => { + console.error('❌ Error getting user images:', error); + return of([]); + }) + ); } /** @@ -101,9 +274,124 @@ export class MinioService { previewImage(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); - reader.onload = (e: any) => resolve(e.target.result); - reader.onerror = () => reject(new Error('Erreur lors de la lecture du fichier')); + + reader.onload = (e: ProgressEvent) => { + if (e.target?.result) { + resolve(e.target.result as string); + } else { + reject(new Error('Impossible de lire le fichier')); + } + }; + + reader.onerror = () => { + reject(new Error('Erreur lors de la lecture du fichier')); + }; + reader.readAsDataURL(file); }); } + + /** + * Formate la taille d'un fichier + */ + formatFileSize(bytes: number): string { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]; + } + + /** + * Vérifie si un nom de fichier est valide + */ + isValidFileName(fileName: string): boolean { + return typeof fileName === 'string' && fileName.trim().length > 0; + } + + /** + * Extrait le nom de fichier depuis le chemin complet + */ + extractFileNameFromPath(filePath: string): string { + return filePath.split('/').pop() || filePath; + } + + /** + * Crée une URL d'objet blob pour prévisualisation + */ + createObjectUrl(file: File): string { + return URL.createObjectURL(file); + } + + /** + * Révoque une URL d'objet blob + */ + revokeObjectUrl(url: string): void { + URL.revokeObjectURL(url); + } + + /** + * Réinitialise la progression + */ + resetUploadProgress(): void { + this.uploadProgressSubject.next({ loaded: 0, total: 0, percentage: 0 }); + } + + /** + * Gestionnaire d'erreurs générique + */ + private handleError(operation: string) { + return (error: any): Observable => { + console.error(`❌ Error in ${operation}:`, error); + + let errorMessage = 'Une erreur est survenue'; + + if (error.error instanceof ErrorEvent) { + // Erreur côté client + errorMessage = error.error.message; + } else { + // Erreur côté serveur + errorMessage = error.error?.message || + error.error?.error || + error.message || + `Error ${error.status}: ${error.statusText}`; + } + + return throwError(() => new Error(errorMessage)); + }; + } + + /** + * Vérifie si le type MIME est une image + */ + isImageMimeType(mimeType: string): boolean { + return this.ALLOWED_MIME_TYPES.includes(mimeType); + } + + /** + * Génère un nom de fichier sécurisé côté client (optionnel) + */ + generateSafeFileName(originalName: string, userId?: string): string { + const timestamp = Date.now(); + const randomString = Math.random().toString(36).substring(2, 10); + const extension = originalName.includes('.') + ? originalName.substring(originalName.lastIndexOf('.')) + : ''; + + const baseName = originalName.includes('.') + ? originalName.substring(0, originalName.lastIndexOf('.')) + : originalName; + + const safeBaseName = baseName + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-zA-Z0-9-]/g, '_') + .replace(/_+/g, '_') + .toLowerCase() + .substring(0, 100); + + const prefix = userId ? `${userId}/` : ''; + + return `${prefix}${timestamp}_${randomString}_${safeBaseName}${extension}`; + } } \ No newline at end of file diff --git a/src/app/modules/merchant-config/merchant-config-list/merchant-config-list.html b/src/app/modules/merchant-config/merchant-config-list/merchant-config-list.html index a8307df..d79ff76 100644 --- a/src/app/modules/merchant-config/merchant-config-list/merchant-config-list.html +++ b/src/app/modules/merchant-config/merchant-config-list/merchant-config-list.html @@ -180,23 +180,40 @@
-
- + +
+ +
+ +
+ + +
+ @if (merchant.logo && merchant.logo.trim() !== '') { + + } @else { + + } +
- -
- - - - -
-
- {{ merchant.name }} - {{ merchant.adresse }} + + +
+ {{ merchant.name }} + {{ merchant.adresse }}
diff --git a/src/app/modules/merchant-config/merchant-config-list/merchant-config-list.ts b/src/app/modules/merchant-config/merchant-config-list/merchant-config-list.ts index cb744a8..b92cc2f 100644 --- a/src/app/modules/merchant-config/merchant-config-list/merchant-config-list.ts +++ b/src/app/modules/merchant-config/merchant-config-list/merchant-config-list.ts @@ -4,7 +4,7 @@ import { FormsModule } from '@angular/forms'; import { NgIcon } from '@ng-icons/core'; import { NgbPaginationModule } from '@ng-bootstrap/ng-bootstrap'; import { Observable, Subject, of } from 'rxjs'; -import { catchError, takeUntil, tap } from 'rxjs/operators'; +import { catchError, map, takeUntil, tap } from 'rxjs/operators'; import { Merchant, @@ -43,7 +43,6 @@ export class MerchantConfigsList implements OnInit, OnDestroy { private destroy$ = new Subject(); private minioService = inject(MinioService); - private sanitizer = inject(DomSanitizer); // Cache des URLs de logos private logoUrlCache = new Map(); @@ -113,11 +112,6 @@ export class MerchantConfigsList implements OnInit, OnDestroy { this.loadCurrentUserPermissions(); } - ngOnDestroy(): void { - this.destroy$.next(); - this.destroy$.complete(); - } - private loadCurrentUserPermissions() { this.authService.getUserProfile() .pipe(takeUntil(this.destroy$)) @@ -219,83 +213,175 @@ export class MerchantConfigsList implements OnInit, OnDestroy { } // ==================== AFFICHAGE DU LOGO ==================== + /** * Récupère l'URL du logo avec fallback automatique */ - getMerchantLogoUrl(logoFileName: string, merchantName?: string): Observable { + getMerchantLogoUrl( + merchantId: number | undefined, + logoFileName: string, + merchantName: string + ): Observable { + + const newMerchantId = String(merchantId); + + const cacheKey = `${merchantId}_${logoFileName}`; + // Vérifier si le logo est en cache d'erreur - if (this.logoErrorCache.has(logoFileName)) { - const defaultLogo = this.getDefaultLogoUrl(merchantName || logoFileName); + if (this.logoErrorCache.has(cacheKey)) { + const defaultLogo = this.getDefaultLogoUrl(merchantName); return of(defaultLogo); } // Vérifier le cache normal - if (this.logoUrlCache.has(logoFileName)) { - return of(this.logoUrlCache.get(logoFileName)!); + if (this.logoUrlCache.has(cacheKey)) { + return of(this.logoUrlCache.get(cacheKey)!); } - - // Récupérer l'URL depuis MinIO - return this.minioService.getMerchantLogoUrl(logoFileName).pipe( - tap(url => { - // Mettre en cache - this.logoUrlCache.set(logoFileName, url); + + // Récupérer l'URL depuis l'API avec la nouvelle structure + return this.minioService.getMerchantLogoUrl( + newMerchantId, + logoFileName, + { signed: true, expirySeconds: 3600 } + ).pipe( + map(response => { + // Extraire l'URL de la réponse + const url = response.data.url ; + + // Mettre en cache avec la clé composite + this.logoUrlCache.set(cacheKey, url); + + return url; }), catchError(error => { - // En cas d'erreur, ajouter au cache d'erreur et retourner le logo par défaut - this.logoErrorCache.add(logoFileName); - const defaultLogo = this.getDefaultLogoUrl(merchantName || logoFileName); + console.warn(`⚠️ Logo not found for merchant ${merchantId}: ${logoFileName}`, error); + + // En cas d'erreur, ajouter au cache d'erreur + this.logoErrorCache.add(cacheKey); + + // Générer un logo par défaut + const defaultLogo = this.getDefaultLogoUrl(merchantName); + + // Mettre le logo par défaut dans le cache normal aussi + this.logoUrlCache.set(cacheKey, defaultLogo); + return of(defaultLogo); }) ); } - + /** * Génère une URL de logo par défaut basée sur les initiales */ getDefaultLogoUrl(merchantName: string): string { - // Créer des initiales significatives const initials = this.extractInitials(merchantName); // Palette de couleurs agréables const colors = [ - 'FF6B6B', '4ECDC4', '45B7D1', '96CEB4', 'FFEAA7', - 'DDA0DD', '98D8C8', 'F7DC6F', 'BB8FCE', '85C1E9' + '667eea', // Violet + '764ba2', // Violet foncé + 'f56565', // Rouge + '4299e1', // Bleu + '48bb78', // Vert + 'ed8936', // Orange + 'FF6B6B', // Rouge clair + '4ECDC4', // Turquoise + '45B7D1', // Bleu clair + '96CEB4' // Vert menthe ]; const colorIndex = merchantName.length % colors.length; const backgroundColor = colors[colorIndex]; - const textColor = 'FFFFFF'; // Blanc pour contraste - return `https://ui-avatars.com/api/?name=${encodeURIComponent(initials)}&background=${backgroundColor}&color=${textColor}&size=200&bold=true&font-size=0.5`; + // Taille fixe à 80px (l'API génère un carré de cette taille) + // L'image sera redimensionnée à 40px via CSS + return `https://ui-avatars.com/api/?name=${encodeURIComponent(initials)}&background=${backgroundColor}&color=FFFFFF&size=80`; } /** * Extrait les initiales de manière intelligente */ private extractInitials(name: string): string { + if (!name || name.trim() === '') { + return '??'; + } + // Nettoyer le nom const cleanedName = name.trim().toUpperCase(); - // Extraire les mots (ignorer les articles, prépositions courtes) + // Extraire les mots const words = cleanedName.split(/\s+/); // Si un seul mot, prendre les deux premières lettres if (words.length === 1) { - return words[0].substring(0, 2); + return words[0].substring(0, 2) || '??'; } - // Prendre la première lettre des deux premiers mots significatifs + // Prendre la première lettre des deux premiers mots const initials = words - .filter(word => word.length > 2) // Ignorer les mots courts - .slice(0, 2) // Prendre maximum 2 mots - .map(word => word[0]) + .slice(0, 2) // Prendre les 2 premiers mots + .map(word => word[0] || '') .join(''); - return initials || name.substring(0, 2).toUpperCase(); + return initials || name.substring(0, 2).toUpperCase() || '??'; + } + + /** + * Gère les erreurs de chargement des logos MinIO + */ + onLogoError(event: Event, merchantName: string): void { + const img = event.target as HTMLImageElement; + + if (!img) return; + + console.warn('Logo MinIO failed to load, using default for:', merchantName); + + img.onerror = null; + img.src = this.getDefaultLogoUrl(merchantName); + } + + /** + * Gère les erreurs de chargement des logos par défaut + */ + onDefaultLogoError(event: Event | string): void { + if (!(event instanceof Event)) { + console.error('Default logo error (non-event):', event); + return; + } + + const img = event.target as HTMLImageElement | null; + if (!img) return; + + console.error('Default logo also failed to load, using fallback SVG'); + + // SVG local + img.onerror = null; // éviter boucle infinie + img.src = 'assets/images/default-merchant-logo.svg'; + + // Dernier recours + img.onerror = (e) => { + if (!(e instanceof Event)) return; + const fallbackImg = e.target as HTMLImageElement | null; + if (!fallbackImg) return; + + fallbackImg.onerror = null; + fallbackImg.src = this.generateFallbackDataUrl(); + }; + } + + /** + * Génère un fallback SVG en data URL + */ + private generateFallbackDataUrl(): string { + const svg = ` + + ? + `; + + return 'data:image/svg+xml;base64,' + btoa(svg); } - private buildSearchParams(): SearchMerchantsParams { const params: SearchMerchantsParams = {}; @@ -459,4 +545,15 @@ export class MerchantConfigsList implements OnInit, OnDestroy { shouldDisplayMerchantList(): boolean { return this.isHubUser; } + + + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + + // Nettoyer les caches + this.logoUrlCache.clear(); + this.logoErrorCache.clear(); + } } \ No newline at end of file diff --git a/src/app/modules/merchant-config/merchant-config-view/merchant-config-view.html b/src/app/modules/merchant-config/merchant-config-view/merchant-config-view.html index 63b156c..ff44387 100644 --- a/src/app/modules/merchant-config/merchant-config-view/merchant-config-view.html +++ b/src/app/modules/merchant-config/merchant-config-view/merchant-config-view.html @@ -106,72 +106,119 @@
- -
-
-
-
-

{{ merchant.name }}

-

{{ merchant.description || 'Aucune description' }}

-
- -
- - - -
- -
- @if (canEditMerchant()) { - - } + +
+
+
+ +
+
+ +
+ @if (merchant.logo && merchant.logo.trim() !== '') { + + } @else { + + } +
+ + +
+

{{ merchant.name }}

+ @if (merchant.description) { +

{{ merchant.description }}

+ } @else { +

Aucune description

+ } + + +
+
+ + {{ merchant.adresse }} +
+
+ + {{ merchant.phone }} +
+
+
-
- - -
-
- @if (getMerchantStats(); as stats) { -
-
-
{{ stats.configs.total }}
-
Configurations
-
-
-
-
-
{{ stats.contacts.total }}
-
Contacts
-
-
-
-
-
{{ stats.users.total }}
-
Utilisateurs
-
-
-
-
-
{{ stats.configs.sensitive }}
-
Configs sensibles
-
-
+ + +
+ @if (canEditMerchant()) { + }
- + + +
+
+ @if (getMerchantStats(); as stats) { +
+
+
+ +
+
{{ stats.configs.total }}
+
Configurations
+
+
+
+
+
+ +
+
{{ stats.contacts.total }}
+
Contacts
+
+
+
+
+
+ +
+
{{ stats.users.total }}
+
Utilisateurs
+
+
+
+
+
+ +
+
{{ stats.configs.sensitive }}
+
Configs sensibles
+
+
+ } +
+
+
diff --git a/src/app/modules/merchant-config/merchant-config-view/merchant-config-view.ts b/src/app/modules/merchant-config/merchant-config-view/merchant-config-view.ts index 4131ac8..36d15c2 100644 --- a/src/app/modules/merchant-config/merchant-config-view/merchant-config-view.ts +++ b/src/app/modules/merchant-config/merchant-config-view/merchant-config-view.ts @@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { NgIcon } from '@ng-icons/core'; import { NgbAlertModule, NgbPaginationModule, NgbNavModule, NgbModal } from '@ng-bootstrap/ng-bootstrap'; -import { catchError, Observable, of, Subject, takeUntil, tap } from 'rxjs'; +import { catchError, map, Observable, of, Subject, takeUntil, tap } from 'rxjs'; import { Merchant, @@ -296,79 +296,171 @@ export class MerchantConfigView implements OnInit, OnDestroy { /** * Récupère l'URL du logo avec fallback automatique */ - getMerchantLogoUrl(logoFileName: string, merchantName?: string): Observable { + getMerchantLogoUrl( + merchantId: number | undefined, + logoFileName: string, + merchantName: string + ): Observable { + + const newMerchantId = String(merchantId); + + const cacheKey = `${merchantId}_${logoFileName}`; + // Vérifier si le logo est en cache d'erreur - if (this.logoErrorCache.has(logoFileName)) { - const defaultLogo = this.getDefaultLogoUrl(merchantName || logoFileName); + if (this.logoErrorCache.has(cacheKey)) { + const defaultLogo = this.getDefaultLogoUrl(merchantName); return of(defaultLogo); } // Vérifier le cache normal - if (this.logoUrlCache.has(logoFileName)) { - return of(this.logoUrlCache.get(logoFileName)!); + if (this.logoUrlCache.has(cacheKey)) { + return of(this.logoUrlCache.get(cacheKey)!); } - - // Récupérer l'URL depuis MinIO - return this.minioService.getMerchantLogoUrl(logoFileName).pipe( - tap(url => { - // Mettre en cache - this.logoUrlCache.set(logoFileName, url); + + // Récupérer l'URL depuis l'API avec la nouvelle structure + return this.minioService.getMerchantLogoUrl( + newMerchantId, + logoFileName, + { signed: true, expirySeconds: 3600 } + ).pipe( + map(response => { + // Extraire l'URL de la réponse + const url = response.data.url ; + + // Mettre en cache avec la clé composite + this.logoUrlCache.set(cacheKey, url); + + return url; }), catchError(error => { - // En cas d'erreur, ajouter au cache d'erreur et retourner le logo par défaut - this.logoErrorCache.add(logoFileName); - const defaultLogo = this.getDefaultLogoUrl(merchantName || logoFileName); + console.warn(`⚠️ Logo not found for merchant ${merchantId}: ${logoFileName}`, error); + + // En cas d'erreur, ajouter au cache d'erreur + this.logoErrorCache.add(cacheKey); + + // Générer un logo par défaut + const defaultLogo = this.getDefaultLogoUrl(merchantName); + + // Mettre le logo par défaut dans le cache normal aussi + this.logoUrlCache.set(cacheKey, defaultLogo); + return of(defaultLogo); }) ); } - + /** * Génère une URL de logo par défaut basée sur les initiales */ getDefaultLogoUrl(merchantName: string): string { - // Créer des initiales significatives const initials = this.extractInitials(merchantName); // Palette de couleurs agréables const colors = [ - 'FF6B6B', '4ECDC4', '45B7D1', '96CEB4', 'FFEAA7', - 'DDA0DD', '98D8C8', 'F7DC6F', 'BB8FCE', '85C1E9' + '667eea', // Violet + '764ba2', // Violet foncé + 'f56565', // Rouge + '4299e1', // Bleu + '48bb78', // Vert + 'ed8936', // Orange + 'FF6B6B', // Rouge clair + '4ECDC4', // Turquoise + '45B7D1', // Bleu clair + '96CEB4' // Vert menthe ]; const colorIndex = merchantName.length % colors.length; const backgroundColor = colors[colorIndex]; - const textColor = 'FFFFFF'; // Blanc pour contraste - return `https://ui-avatars.com/api/?name=${encodeURIComponent(initials)}&background=${backgroundColor}&color=${textColor}&size=200&bold=true&font-size=0.5`; + // Taille fixe à 80px (l'API génère un carré de cette taille) + // L'image sera redimensionnée à 40px via CSS + return `https://ui-avatars.com/api/?name=${encodeURIComponent(initials)}&background=${backgroundColor}&color=FFFFFF&size=80`; } /** * Extrait les initiales de manière intelligente */ private extractInitials(name: string): string { + if (!name || name.trim() === '') { + return '??'; + } + // Nettoyer le nom const cleanedName = name.trim().toUpperCase(); - // Extraire les mots (ignorer les articles, prépositions courtes) + // Extraire les mots const words = cleanedName.split(/\s+/); // Si un seul mot, prendre les deux premières lettres if (words.length === 1) { - return words[0].substring(0, 2); + return words[0].substring(0, 2) || '??'; } - // Prendre la première lettre des deux premiers mots significatifs + // Prendre la première lettre des deux premiers mots const initials = words - .filter(word => word.length > 2) // Ignorer les mots courts - .slice(0, 2) // Prendre maximum 2 mots - .map(word => word[0]) + .slice(0, 2) // Prendre les 2 premiers mots + .map(word => word[0] || '') .join(''); - return initials || name.substring(0, 2).toUpperCase(); + return initials || name.substring(0, 2).toUpperCase() || '??'; } - + + /** + * Gère les erreurs de chargement des logos MinIO + */ + onLogoError(event: Event, merchantName: string): void { + const img = event.target as HTMLImageElement; + + if (!img) return; + + console.warn('Logo MinIO failed to load, using default for:', merchantName); + + img.onerror = null; + img.src = this.getDefaultLogoUrl(merchantName); + } + + /** + * Gère les erreurs de chargement des logos par défaut + */ + onDefaultLogoError(event: Event | string): void { + if (!(event instanceof Event)) { + console.error('Default logo error (non-event):', event); + return; + } + + const img = event.target as HTMLImageElement | null; + if (!img) return; + + console.error('Default logo also failed to load, using fallback SVG'); + + // SVG local + img.onerror = null; // éviter boucle infinie + img.src = 'assets/images/default-merchant-logo.svg'; + + // Dernier recours + img.onerror = (e) => { + if (!(e instanceof Event)) return; + const fallbackImg = e.target as HTMLImageElement | null; + if (!fallbackImg) return; + + fallbackImg.onerror = null; + fallbackImg.src = this.generateFallbackDataUrl(); + }; + } + + /** + * Génère un fallback SVG en data URL + */ + private generateFallbackDataUrl(): string { + const svg = ` + + ? + `; + + return 'data:image/svg+xml;base64,' + btoa(svg); + } + /** * Charge les permissions de l'utilisateur courant */ diff --git a/src/app/modules/merchant-config/merchant-config.html b/src/app/modules/merchant-config/merchant-config.html index 20f1559..3169679 100644 --- a/src/app/modules/merchant-config/merchant-config.html +++ b/src/app/modules/merchant-config/merchant-config.html @@ -176,34 +176,37 @@ }
- - -
-
-
- Prévisualisation du logo - -
- -
- -

Aucun logo sélectionné

-
+
+ @if (logoPreviewUrl) { + +
+ Prévisualisation du logo + +
+ } @else { + +
+ +

Aucun logo sélectionné

+
+ }
@@ -211,33 +214,53 @@ -
-
- {{ logoUploadError }} -
+ @if (logoUploadError) { +
+ + {{ logoUploadError }} +
+ } -
-
- Upload en cours... + @if (uploadingLogo) { +
+
+ Upload en cours... +
+ Upload du logo en cours...
- Upload du logo en cours... -
+ }
@@ -572,37 +595,41 @@
- -
- Nouveau logo -
Nouveau
- -
- - -
- Logo actuel -
Actuel
-
- - -
- -

Aucun logo

-
+ @if (editLogoPreviewUrl) { + +
+ Nouveau logo +
Nouveau
+ +
+ } @else if (currentLogoUrl) { + +
+ Logo actuel +
Actuel
+
+ } @else { + +
+ +

Aucun logo

+
+ }
@@ -610,41 +637,60 @@ -
-
-
- Upload en cours... + @if (uploadingLogo) { +
+
+ Upload en cours... +
+ Upload du nouveau logo en cours...
- Upload du nouveau logo en cours... -
+ }
- +