feat: Manage Images using Minio Service

This commit is contained in:
diallolatoile 2026-01-11 04:14:08 +00:00
parent 47f09b3c4e
commit f8aa8eb595
10 changed files with 1632 additions and 475 deletions

View File

@ -646,3 +646,466 @@
font-size: 0.8125rem; 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;
}
}

View File

@ -1,98 +1,271 @@
import { Injectable } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http'; import { HttpClient, HttpHeaders, HttpEventType, HttpResponse } from '@angular/common/http';
import { Observable } from 'rxjs'; import { Observable, throwError, of, Subject } from 'rxjs';
import { map } from 'rxjs/operators'; import { map, catchError, tap, filter } from 'rxjs/operators';
import { environment } from '@environments/environment'; import { environment } from '@environments/environment';
export interface UploadLogoResponse { export interface ImageUploadResponse {
message : string;
success: boolean; success: boolean;
fileName: string; merchant: {
url: string; id: string,
size: number; 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({ @Injectable({
providedIn: 'root' providedIn: 'root'
}) })
export class MinioService { export class MinioService {
private apiUrl = `${environment.configApiUrl}/minio`; // URL de votre backend private http = inject(HttpClient);
private baseUrl = `${environment.configApiUrl}/images`;
constructor(private http: HttpClient) {}
// Sujet pour les événements de progression
private uploadProgressSubject = new Subject<UploadProgress>();
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<UploadLogoResponse> { 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<ImageUploadResponse> {
// Validation
const validation = this.validateImageFile(file);
if (!validation.valid) {
return throwError(() => new Error(validation.error));
}
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); 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<UploadLogoResponse>(`${this.apiUrl}/upload-logo`, formData); // Ajouter des métadonnées optionnelles
} if (merchantId) {
formData.append('merchantId', merchantId.toString());
}
if (merchantName) {
formData.append('merchantName', merchantName);
}
/** // Headers
* Récupère l'URL présignée pour afficher un logo const headers = new HttpHeaders({
* URL valide pour 7 jours 'Accept': 'application/json'
*/
getMerchantLogoUrl(logoFileName: string): Observable<string> {
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<any> {
return this.http.delete(`${this.apiUrl}/delete`, {
params: {
bucketName: 'bo-assets',
objectName: logoFileName
}
}); });
return this.http.post<ImageUploadResponse>(
`${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 } { getMerchantLogoUrl(
// Vérifier le type MIME merchantId: string,
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml']; fileName: string,
if (!allowedTypes.includes(file.type)) { options: LogoUrlOptions = {}
return { ): Observable<LogoUrlResponse> {
valid: false, if (!merchantId || !fileName || fileName.trim() === '') {
error: 'Format non supporté. Utilisez JPG, PNG, GIF, WebP ou SVG.' return throwError(() => new Error('Paramètres invalides'));
};
} }
// Vérifier la taille (2MB max pour un logo) const params: any = {
const maxSize = 2 * 1024 * 1024; // 2MB fileName
if (file.size > maxSize) { };
return {
valid: false, if (options.signed) {
error: 'Le fichier est trop volumineux (max 2MB pour un logo)' params.signed = 'true';
};
} }
// Vérifier les dimensions si possible (optionnel) if (options.expirySeconds) {
return { valid: true }; params.expiry = options.expirySeconds.toString();
}
return this.http.get<LogoUrlResponse>(
`${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<ImageUploadResponse> {
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<void> {
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<ImageUploadResponse[]> {
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<string> { previewImage(file: File): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const reader = new FileReader(); 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<FileReader>) => {
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); 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<never> => {
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}`;
}
} }

View File

@ -180,23 +180,40 @@
<tr> <tr>
<td> <td>
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<div class="avatar-sm bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2"> <!-- ==================== LOGO AVEC ICÔNE ==================== -->
<ng-icon name="lucideStore" class="text-primary fs-12"></ng-icon> <div class="d-flex align-items-center me-3">
<!-- Icône fixe à gauche -->
<div class="avatar-sm bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2">
<ng-icon name="lucideStore" class="text-primary fs-12"></ng-icon>
</div>
<!-- Logo du marchand -->
<div>
@if (merchant.logo && merchant.logo.trim() !== '') {
<img
[src]="getMerchantLogoUrl(merchant.id, merchant.logo, merchant.name) | async"
[alt]="merchant.name + ' logo'"
class="rounded-circle"
style="width: 40px; height: 40px; object-fit: cover; border: 2px solid #f0f0f0;"
loading="lazy"
(error)="onLogoError($event, merchant.name)"
/>
} @else {
<img
[src]="getDefaultLogoUrl(merchant.name)"
[alt]="merchant.name + ' logo'"
class="rounded-circle"
style="width: 40px; height: 40px; object-fit: cover; border: 2px solid #f0f0f0;"
loading="lazy"
/>
}
</div>
</div> </div>
<!-- Logo du marchand -->
<div class="avatar-container me-3 position-relative"> <!-- Informations du marchand -->
<ng-container *ngIf="merchant.logo;"> <div class="min-w-0 flex-grow-1">
<!-- Image du logo --> <strong class="d-block text-truncate">{{ merchant.name }}</strong>
<img <small class="text-muted text-truncate d-block">{{ merchant.adresse }}</small>
[src]="getMerchantLogoUrl(merchant.logo) | async"
[alt]="merchant.name + ' logo'"
class="merchant-logo avatar-lg rounded-circle"
/>
</ng-container>
</div>
<div>
<strong class="d-block">{{ merchant.name }}</strong>
<small class="text-muted">{{ merchant.adresse }}</small>
</div> </div>
</div> </div>
</td> </td>

View File

@ -4,7 +4,7 @@ import { FormsModule } from '@angular/forms';
import { NgIcon } from '@ng-icons/core'; import { NgIcon } from '@ng-icons/core';
import { NgbPaginationModule } from '@ng-bootstrap/ng-bootstrap'; import { NgbPaginationModule } from '@ng-bootstrap/ng-bootstrap';
import { Observable, Subject, of } from 'rxjs'; import { Observable, Subject, of } from 'rxjs';
import { catchError, takeUntil, tap } from 'rxjs/operators'; import { catchError, map, takeUntil, tap } from 'rxjs/operators';
import { import {
Merchant, Merchant,
@ -43,7 +43,6 @@ export class MerchantConfigsList implements OnInit, OnDestroy {
private destroy$ = new Subject<void>(); private destroy$ = new Subject<void>();
private minioService = inject(MinioService); private minioService = inject(MinioService);
private sanitizer = inject(DomSanitizer);
// Cache des URLs de logos // Cache des URLs de logos
private logoUrlCache = new Map<string, string>(); private logoUrlCache = new Map<string, string>();
@ -113,11 +112,6 @@ export class MerchantConfigsList implements OnInit, OnDestroy {
this.loadCurrentUserPermissions(); this.loadCurrentUserPermissions();
} }
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
private loadCurrentUserPermissions() { private loadCurrentUserPermissions() {
this.authService.getUserProfile() this.authService.getUserProfile()
.pipe(takeUntil(this.destroy$)) .pipe(takeUntil(this.destroy$))
@ -219,83 +213,175 @@ export class MerchantConfigsList implements OnInit, OnDestroy {
} }
// ==================== AFFICHAGE DU LOGO ==================== // ==================== AFFICHAGE DU LOGO ====================
/** /**
* Récupère l'URL du logo avec fallback automatique * Récupère l'URL du logo avec fallback automatique
*/ */
getMerchantLogoUrl(logoFileName: string, merchantName?: string): Observable<string> { getMerchantLogoUrl(
merchantId: number | undefined,
logoFileName: string,
merchantName: string
): Observable<string> {
const newMerchantId = String(merchantId);
const cacheKey = `${merchantId}_${logoFileName}`;
// Vérifier si le logo est en cache d'erreur // Vérifier si le logo est en cache d'erreur
if (this.logoErrorCache.has(logoFileName)) { if (this.logoErrorCache.has(cacheKey)) {
const defaultLogo = this.getDefaultLogoUrl(merchantName || logoFileName); const defaultLogo = this.getDefaultLogoUrl(merchantName);
return of(defaultLogo); return of(defaultLogo);
} }
// Vérifier le cache normal // Vérifier le cache normal
if (this.logoUrlCache.has(logoFileName)) { if (this.logoUrlCache.has(cacheKey)) {
return of(this.logoUrlCache.get(logoFileName)!); return of(this.logoUrlCache.get(cacheKey)!);
} }
// Récupérer l'URL depuis MinIO // Récupérer l'URL depuis l'API avec la nouvelle structure
return this.minioService.getMerchantLogoUrl(logoFileName).pipe( return this.minioService.getMerchantLogoUrl(
tap(url => { newMerchantId,
// Mettre en cache logoFileName,
this.logoUrlCache.set(logoFileName, url); { 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 => { catchError(error => {
// En cas d'erreur, ajouter au cache d'erreur et retourner le logo par défaut console.warn(`⚠️ Logo not found for merchant ${merchantId}: ${logoFileName}`, error);
this.logoErrorCache.add(logoFileName);
const defaultLogo = this.getDefaultLogoUrl(merchantName || logoFileName); // 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); return of(defaultLogo);
}) })
); );
} }
/** /**
* Génère une URL de logo par défaut basée sur les initiales * Génère une URL de logo par défaut basée sur les initiales
*/ */
getDefaultLogoUrl(merchantName: string): string { getDefaultLogoUrl(merchantName: string): string {
// Créer des initiales significatives // Créer des initiales significatives
const initials = this.extractInitials(merchantName); const initials = this.extractInitials(merchantName);
// Palette de couleurs agréables // Palette de couleurs agréables
const colors = [ const colors = [
'FF6B6B', '4ECDC4', '45B7D1', '96CEB4', 'FFEAA7', '667eea', // Violet
'DDA0DD', '98D8C8', 'F7DC6F', 'BB8FCE', '85C1E9' '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 colorIndex = merchantName.length % colors.length;
const backgroundColor = colors[colorIndex]; 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 * Extrait les initiales de manière intelligente
*/ */
private extractInitials(name: string): string { private extractInitials(name: string): string {
if (!name || name.trim() === '') {
return '??';
}
// Nettoyer le nom // Nettoyer le nom
const cleanedName = name.trim().toUpperCase(); const cleanedName = name.trim().toUpperCase();
// Extraire les mots (ignorer les articles, prépositions courtes) // Extraire les mots
const words = cleanedName.split(/\s+/); const words = cleanedName.split(/\s+/);
// Si un seul mot, prendre les deux premières lettres // Si un seul mot, prendre les deux premières lettres
if (words.length === 1) { 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 const initials = words
.filter(word => word.length > 2) // Ignorer les mots courts .slice(0, 2) // Prendre les 2 premiers mots
.slice(0, 2) // Prendre maximum 2 mots .map(word => word[0] || '')
.map(word => word[0])
.join(''); .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 = `<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40">
<rect width="40" height="40" fill="#667eea" rx="20"/>
<text x="20" y="22" text-anchor="middle" fill="white" font-family="Arial" font-size="14" font-weight="bold">?</text>
</svg>`;
return 'data:image/svg+xml;base64,' + btoa(svg);
} }
private buildSearchParams(): SearchMerchantsParams { private buildSearchParams(): SearchMerchantsParams {
const params: SearchMerchantsParams = {}; const params: SearchMerchantsParams = {};
@ -459,4 +545,15 @@ export class MerchantConfigsList implements OnInit, OnDestroy {
shouldDisplayMerchantList(): boolean { shouldDisplayMerchantList(): boolean {
return this.isHubUser; return this.isHubUser;
} }
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
// Nettoyer les caches
this.logoUrlCache.clear();
this.logoErrorCache.clear();
}
} }

View File

@ -106,72 +106,119 @@
<ng-template ngbNavContent> <ng-template ngbNavContent>
<!-- Vue d'ensemble --> <!-- Vue d'ensemble -->
<div class="p-3"> <div class="p-3">
<!-- En-tête du profil --> <!-- En-tête du profil avec logo -->
<div class="profile-section"> <div class="profile-section">
<div class="profile-header"> <div class="profile-header">
<div class="row align-items-center"> <div class="row align-items-center">
<div class="col-md-4"> <!-- Logo et informations -->
<h2 class="mb-2">{{ merchant.name }}</h2> <div class="col-md-8">
<p class="mb-0 opacity-75">{{ merchant.description || 'Aucune description' }}</p> <div class="d-flex align-items-center">
</div> <!-- ==================== LOGO DU MARCHAND ==================== -->
<div class="merchant-logo-container me-4">
<div class="col-md-4"> @if (merchant.logo && merchant.logo.trim() !== '') {
<ng-container *ngIf="merchant.logo;"> <img
<img [src]="getMerchantLogoUrl(merchant.id, merchant.logo, merchant.name) | async"
[src]="getMerchantLogoUrl(merchant.logo) | async" [alt]="merchant.name + ' logo'"
[alt]="merchant.name" class="rounded-circle"
class="merchant-logo-large" style="width: 40px; height: 40px; object-fit: cover; border: 2px solid #f0f0f0;"
/> loading="lazy"
</ng-container> (error)="onLogoError($event, merchant.name)"
</div> />
} @else {
<div class="col-md-4 text-md-end"> <img
@if (canEditMerchant()) { [src]="getDefaultLogoUrl(merchant.name)"
<button [alt]="merchant.name + ' logo'"
class="btn btn-light" class="rounded-circle"
(click)="editMerchant(merchant)" style="width: 40px; height: 40px; object-fit: cover; border: 2px solid #f0f0f0;"
> loading="lazy"
<ng-icon name="lucideEdit" class="me-1"></ng-icon> (error)="onDefaultLogoError($event)"
Modifier le profil />
</button> }
} </div>
<!-- Informations du marchand -->
<div class="flex-grow-1">
<h2 class="mb-2 text-white">{{ merchant.name }}</h2>
@if (merchant.description) {
<p class="mb-0 text-white opacity-75">{{ merchant.description }}</p>
} @else {
<p class="mb-0 text-white opacity-50 fst-italic">Aucune description</p>
}
<!-- Informations de contact -->
<div class="mt-3 d-flex flex-wrap gap-3">
<div class="d-flex align-items-center text-white opacity-75">
<ng-icon name="lucideMapPin" size="16" class="me-2"></ng-icon>
<small>{{ merchant.adresse }}</small>
</div>
<div class="d-flex align-items-center text-white opacity-75">
<ng-icon name="lucidePhone" size="16" class="me-2"></ng-icon>
<small>{{ merchant.phone }}</small>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div>
<!-- Actions -->
<!-- Statistiques --> <div class="col-md-4 text-md-end">
<div class="p-3"> @if (canEditMerchant()) {
<div class="row g-3"> <button
@if (getMerchantStats(); as stats) { class="btn btn-light"
<div class="col-md-3"> (click)="editMerchant(merchant)"
<div class="stats-card"> >
<div class="stats-number">{{ stats.configs.total }}</div> <ng-icon name="lucideEdit" class="me-1"></ng-icon>
<div class="stats-label">Configurations</div> Modifier le profil
</div> </button>
</div>
<div class="col-md-3">
<div class="stats-card">
<div class="stats-number">{{ stats.contacts.total }}</div>
<div class="stats-label">Contacts</div>
</div>
</div>
<div class="col-md-3">
<div class="stats-card">
<div class="stats-number">{{ stats.users.total }}</div>
<div class="stats-label">Utilisateurs</div>
</div>
</div>
<div class="col-md-3">
<div class="stats-card">
<div class="stats-number">{{ stats.configs.sensitive }}</div>
<div class="stats-label">Configs sensibles</div>
</div>
</div>
} }
</div> </div>
</div> </div>
</div> </div>
<!-- Statistiques -->
<div class="p-3">
<div class="row g-3">
@if (getMerchantStats(); as stats) {
<div class="col-md-3">
<div class="stats-card">
<div class="stats-icon mb-2">
<ng-icon name="lucideSettings" class="text-primary"></ng-icon>
</div>
<div class="stats-number">{{ stats.configs.total }}</div>
<div class="stats-label">Configurations</div>
</div>
</div>
<div class="col-md-3">
<div class="stats-card">
<div class="stats-icon mb-2">
<ng-icon name="lucideUsers" class="text-info"></ng-icon>
</div>
<div class="stats-number">{{ stats.contacts.total }}</div>
<div class="stats-label">Contacts</div>
</div>
</div>
<div class="col-md-3">
<div class="stats-card">
<div class="stats-icon mb-2">
<ng-icon name="lucideUserCheck" class="text-success"></ng-icon>
</div>
<div class="stats-number">{{ stats.users.total }}</div>
<div class="stats-label">Utilisateurs</div>
</div>
</div>
<div class="col-md-3">
<div class="stats-card">
<div class="stats-icon mb-2">
<ng-icon name="lucideShield" class="text-warning"></ng-icon>
</div>
<div class="stats-number">{{ stats.configs.sensitive }}</div>
<div class="stats-label">Configs sensibles</div>
</div>
</div>
}
</div>
</div>
</div>
<!-- Informations principales --> <!-- Informations principales -->
<div class="row g-4"> <div class="row g-4">
<!-- Configurations récentes --> <!-- Configurations récentes -->

View File

@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { NgIcon } from '@ng-icons/core'; import { NgIcon } from '@ng-icons/core';
import { NgbAlertModule, NgbPaginationModule, NgbNavModule, NgbModal } from '@ng-bootstrap/ng-bootstrap'; 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 { import {
Merchant, Merchant,
@ -296,79 +296,171 @@ export class MerchantConfigView implements OnInit, OnDestroy {
/** /**
* Récupère l'URL du logo avec fallback automatique * Récupère l'URL du logo avec fallback automatique
*/ */
getMerchantLogoUrl(logoFileName: string, merchantName?: string): Observable<string> { getMerchantLogoUrl(
merchantId: number | undefined,
logoFileName: string,
merchantName: string
): Observable<string> {
const newMerchantId = String(merchantId);
const cacheKey = `${merchantId}_${logoFileName}`;
// Vérifier si le logo est en cache d'erreur // Vérifier si le logo est en cache d'erreur
if (this.logoErrorCache.has(logoFileName)) { if (this.logoErrorCache.has(cacheKey)) {
const defaultLogo = this.getDefaultLogoUrl(merchantName || logoFileName); const defaultLogo = this.getDefaultLogoUrl(merchantName);
return of(defaultLogo); return of(defaultLogo);
} }
// Vérifier le cache normal // Vérifier le cache normal
if (this.logoUrlCache.has(logoFileName)) { if (this.logoUrlCache.has(cacheKey)) {
return of(this.logoUrlCache.get(logoFileName)!); return of(this.logoUrlCache.get(cacheKey)!);
} }
// Récupérer l'URL depuis MinIO // Récupérer l'URL depuis l'API avec la nouvelle structure
return this.minioService.getMerchantLogoUrl(logoFileName).pipe( return this.minioService.getMerchantLogoUrl(
tap(url => { newMerchantId,
// Mettre en cache logoFileName,
this.logoUrlCache.set(logoFileName, url); { 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 => { catchError(error => {
// En cas d'erreur, ajouter au cache d'erreur et retourner le logo par défaut console.warn(`⚠️ Logo not found for merchant ${merchantId}: ${logoFileName}`, error);
this.logoErrorCache.add(logoFileName);
const defaultLogo = this.getDefaultLogoUrl(merchantName || logoFileName); // 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); return of(defaultLogo);
}) })
); );
} }
/** /**
* Génère une URL de logo par défaut basée sur les initiales * Génère une URL de logo par défaut basée sur les initiales
*/ */
getDefaultLogoUrl(merchantName: string): string { getDefaultLogoUrl(merchantName: string): string {
// Créer des initiales significatives // Créer des initiales significatives
const initials = this.extractInitials(merchantName); const initials = this.extractInitials(merchantName);
// Palette de couleurs agréables // Palette de couleurs agréables
const colors = [ const colors = [
'FF6B6B', '4ECDC4', '45B7D1', '96CEB4', 'FFEAA7', '667eea', // Violet
'DDA0DD', '98D8C8', 'F7DC6F', 'BB8FCE', '85C1E9' '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 colorIndex = merchantName.length % colors.length;
const backgroundColor = colors[colorIndex]; 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 * Extrait les initiales de manière intelligente
*/ */
private extractInitials(name: string): string { private extractInitials(name: string): string {
if (!name || name.trim() === '') {
return '??';
}
// Nettoyer le nom // Nettoyer le nom
const cleanedName = name.trim().toUpperCase(); const cleanedName = name.trim().toUpperCase();
// Extraire les mots (ignorer les articles, prépositions courtes) // Extraire les mots
const words = cleanedName.split(/\s+/); const words = cleanedName.split(/\s+/);
// Si un seul mot, prendre les deux premières lettres // Si un seul mot, prendre les deux premières lettres
if (words.length === 1) { 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 const initials = words
.filter(word => word.length > 2) // Ignorer les mots courts .slice(0, 2) // Prendre les 2 premiers mots
.slice(0, 2) // Prendre maximum 2 mots .map(word => word[0] || '')
.map(word => word[0])
.join(''); .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 = `<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40">
<rect width="40" height="40" fill="#667eea" rx="20"/>
<text x="20" y="22" text-anchor="middle" fill="white" font-family="Arial" font-size="14" font-weight="bold">?</text>
</svg>`;
return 'data:image/svg+xml;base64,' + btoa(svg);
}
/** /**
* Charge les permissions de l'utilisateur courant * Charge les permissions de l'utilisateur courant
*/ */

View File

@ -176,34 +176,37 @@
} }
</div> </div>
<!-- ==================== MODAL CRÉATION - SECTION LOGO ==================== -->
<!-- À ajouter dans votre modal de création de marchand -->
<div class="col-md-6"> <div class="col-md-6">
<div class="form-group logo-upload-section"> <div class="form-group logo-upload-section">
<label class="form-label"> <label class="form-label">
<i class="fas fa-image"></i> Logo du marchand (optionnel) <ng-icon name="lucideImage" class="me-1"></ng-icon>
Logo du marchand (optionnel)
</label> </label>
<div class="logo-upload-container"> <div class="logo-upload-container">
<!-- Zone de prévisualisation --> <!-- Zone de prévisualisation -->
<div class="logo-preview-area" *ngIf="logoPreviewUrl || !selectedLogoFile"> <div class="logo-preview-area">
<div class="logo-preview" *ngIf="logoPreviewUrl"> @if (logoPreviewUrl) {
<img [src]="logoPreviewUrl" alt="Prévisualisation du logo"> <!-- Image sélectionnée -->
<button <div class="logo-preview">
type="button" <img [src]="logoPreviewUrl" alt="Prévisualisation du logo">
class="btn-remove-preview" <button
(click)="removeSelectedLogo()" type="button"
title="Supprimer" class="btn-remove-preview"
> (click)="removeSelectedLogo()"
<i class="fas fa-times"></i> [disabled]="creatingMerchant || uploadingLogo"
</button> title="Supprimer"
</div> >
<ng-icon name="lucideX"></ng-icon>
<div class="logo-placeholder" *ngIf="!logoPreviewUrl"> </button>
<i class="fas fa-image fa-3x"></i> </div>
<p>Aucun logo sélectionné</p> } @else {
</div> <!-- Placeholder -->
<div class="logo-placeholder">
<ng-icon name="lucideImage" size="48" class="text-muted mb-2"></ng-icon>
<p class="text-muted mb-0">Aucun logo sélectionné</p>
</div>
}
</div> </div>
<!-- Bouton de sélection --> <!-- Bouton de sélection -->
@ -211,33 +214,53 @@
<input <input
type="file" type="file"
id="logoInput" id="logoInput"
accept="image/*" accept="image/jpeg,image/jpg,image/png,image/gif,image/webp,image/svg+xml"
(change)="onLogoSelected($event)" (change)="onLogoSelected($event)"
[disabled]="creatingMerchant || uploadingLogo"
style="display: none;" style="display: none;"
/> />
<label for="logoInput" class="btn btn-outline-primary btn-select-logo"> <label
<i class="fas fa-upload"></i> for="logoInput"
class="btn btn-outline-primary btn-select-logo"
[class.disabled]="creatingMerchant || uploadingLogo"
>
<ng-icon name="lucideUpload" class="me-1"></ng-icon>
{{ logoPreviewUrl ? 'Changer le logo' : 'Sélectionner un logo' }} {{ logoPreviewUrl ? 'Changer le logo' : 'Sélectionner un logo' }}
</label> </label>
<small class="text-muted d-block mt-2"> <small class="text-muted d-block mt-2">
Formats: JPG, PNG, GIF, WebP, SVG | Taille max: 2MB <ng-icon name="lucideInfo" size="12" class="me-1"></ng-icon>
Formats acceptés : JPG, PNG, GIF, WebP, SVG | Taille max : 5MB
</small> </small>
@if (selectedLogoFile) {
<div class="mt-2">
<small class="badge bg-info">
<ng-icon name="lucideFile" size="12" class="me-1"></ng-icon>
{{ selectedLogoFile.name }} ({{ formatFileSize(selectedLogoFile.size) }})
</small>
</div>
}
</div> </div>
</div> </div>
<!-- Erreur upload logo --> <!-- Erreur upload logo -->
<div class="alert alert-danger mt-2" *ngIf="logoUploadError"> @if (logoUploadError) {
<i class="fas fa-exclamation-circle"></i> {{ logoUploadError }} <div class="alert alert-danger mt-2">
</div> <ng-icon name="lucideAlertCircle" class="me-1"></ng-icon>
{{ logoUploadError }}
</div>
}
<!-- Indicateur d'upload --> <!-- Indicateur d'upload -->
<div class="upload-progress" *ngIf="uploadingLogo"> @if (uploadingLogo) {
<div class="spinner-border spinner-border-sm" role="status"> <div class="upload-progress">
<span class="sr-only">Upload en cours...</span> <div class="spinner-border spinner-border-sm" role="status">
<span class="visually-hidden">Upload en cours...</span>
</div>
<span class="ms-2">Upload du logo en cours...</span>
</div> </div>
<span class="ml-2">Upload du logo en cours...</span> }
</div>
</div> </div>
</div> </div>
@ -572,37 +595,41 @@
<div class="col-md-6"> <div class="col-md-6">
<div class="form-group logo-edit-section"> <div class="form-group logo-edit-section">
<label class="form-label"> <label class="form-label">
<i class="fas fa-image"></i> Logo du marchand <ng-icon name="lucideImage" class="me-1"></ng-icon>
Logo du marchand
</label> </label>
<div class="logo-edit-container"> <div class="logo-edit-container">
<!-- Logo actuel ou nouveau --> <!-- Logo actuel ou nouveau -->
<div class="logo-display-area"> <div class="logo-display-area">
<!-- Nouveau logo sélectionné --> @if (editLogoPreviewUrl) {
<div class="logo-preview" *ngIf="editLogoPreviewUrl"> <!-- Nouveau logo sélectionné -->
<img [src]="editLogoPreviewUrl" alt="Nouveau logo"> <div class="logo-preview">
<div class="logo-badge badge-new">Nouveau</div> <img [src]="editLogoPreviewUrl" alt="Nouveau logo">
<button <div class="logo-badge badge-new">Nouveau</div>
type="button" <button
class="btn-remove-preview" type="button"
(click)="cancelEditLogo()" class="btn-remove-preview"
title="Annuler" (click)="cancelEditLogo()"
> [disabled]="updatingMerchant || uploadingLogo"
<i class="fas fa-times"></i> title="Annuler"
</button> >
</div> <ng-icon name="lucideX"></ng-icon>
</button>
<!-- Logo actuel --> </div>
<div class="logo-preview" *ngIf="!editLogoPreviewUrl && currentLogoUrl"> } @else if (currentLogoUrl) {
<img [src]="currentLogoUrl" alt="Logo actuel"> <!-- Logo actuel -->
<div class="logo-badge badge-current">Actuel</div> <div class="logo-preview">
</div> <img [src]="currentLogoUrl" alt="Logo actuel">
<div class="logo-badge badge-current">Actuel</div>
<!-- Pas de logo --> </div>
<div class="logo-placeholder" *ngIf="!editLogoPreviewUrl && !currentLogoUrl"> } @else {
<i class="fas fa-image fa-3x"></i> <!-- Pas de logo -->
<p>Aucun logo</p> <div class="logo-placeholder">
</div> <ng-icon name="lucideImage" size="48" class="text-muted mb-2"></ng-icon>
<p class="text-muted mb-0">Aucun logo</p>
</div>
}
</div> </div>
<!-- Actions --> <!-- Actions -->
@ -610,41 +637,60 @@
<input <input
type="file" type="file"
id="editLogoInput" id="editLogoInput"
accept="image/*" accept="image/jpeg,image/jpg,image/png,image/gif,image/webp,image/svg+xml"
(change)="onEditLogoSelected($event)" (change)="onEditLogoSelected($event)"
[disabled]="updatingMerchant || uploadingLogo"
style="display: none;" style="display: none;"
/> />
<label for="editLogoInput" class="btn btn-outline-primary btn-change-logo"> <label
<i class="fas fa-sync-alt"></i> for="editLogoInput"
class="btn btn-outline-primary btn-change-logo"
[class.disabled]="updatingMerchant || uploadingLogo"
>
<ng-icon name="lucideRefreshCw" class="me-1"></ng-icon>
{{ currentLogoUrl ? 'Changer le logo' : 'Ajouter un logo' }} {{ currentLogoUrl ? 'Changer le logo' : 'Ajouter un logo' }}
</label> </label>
<button @if (currentLogoUrl && !editLogoPreviewUrl) {
type="button" <button
class="btn btn-outline-danger btn-remove-logo" type="button"
*ngIf="currentLogoUrl && !editLogoPreviewUrl" class="btn btn-outline-danger btn-remove-logo"
(click)="selectedMerchantForEdit!.logo = ''; currentLogoUrl = null" (click)="selectedMerchantForEdit!.logo = ''; currentLogoUrl = null"
> [disabled]="updatingMerchant"
<i class="fas fa-trash"></i> >
Supprimer le logo <ng-icon name="lucideTrash2" class="me-1"></ng-icon>
</button> Supprimer le logo
</button>
}
<small class="text-muted d-block mt-2"> <small class="text-muted d-block mt-2">
Formats: JPG, PNG, GIF, WebP, SVG | Taille max: 2MB <ng-icon name="lucideInfo" size="12" class="me-1"></ng-icon>
Formats : JPG, PNG, GIF, WebP, SVG | Max : 5MB
</small> </small>
@if (editLogoFile) {
<div class="mt-2">
<small class="badge bg-info">
<ng-icon name="lucideFile" size="12" class="me-1"></ng-icon>
{{ editLogoFile.name }} ({{ formatFileSize(editLogoFile.size) }})
</small>
</div>
}
</div> </div>
</div> </div>
<!-- Indicateur d'upload --> <!-- Indicateur d'upload -->
<div class="upload-progress" *ngIf="uploadingLogo"> @if (uploadingLogo) {
<div class="spinner-border spinner-border-sm" role="status"> <div class="upload-progress">
<span class="sr-only">Upload en cours...</span> <div class="spinner-border spinner-border-sm" role="status">
<span class="visually-hidden">Upload en cours...</span>
</div>
<span class="ms-2">Upload du nouveau logo en cours...</span>
</div> </div>
<span class="ml-2">Upload du nouveau logo en cours...</span> }
</div>
</div> </div>
</div> </div>
<div class="col-12"> <div class="col-12">
<label class="form-label">Description</label> <label class="form-label">Description</label>
<textarea <textarea

View File

@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule, FormBuilder, Validators, FormArray, FormGroup } from '@angular/forms'; import { FormsModule, ReactiveFormsModule, FormBuilder, Validators, FormArray, FormGroup } from '@angular/forms';
import { NgIcon } from '@ng-icons/core'; import { NgIcon } from '@ng-icons/core';
import { NgbNavModule, NgbModal, NgbModalModule } from '@ng-bootstrap/ng-bootstrap'; import { NgbNavModule, NgbModal, NgbModalModule } from '@ng-bootstrap/ng-bootstrap';
import { catchError, finalize, map, Observable, of, Subject, takeUntil, tap } from 'rxjs'; import { catchError, finalize, map, Observable, of, Subject, switchMap, takeUntil, tap } from 'rxjs';
import { DomSanitizer, SafeUrl } from '@angular/platform-browser'; import { DomSanitizer, SafeUrl } from '@angular/platform-browser';
import { MerchantConfigService } from './merchant-config.service'; import { MerchantConfigService } from './merchant-config.service';
@ -57,9 +57,12 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
private minioService = inject(MinioService); private minioService = inject(MinioService);
private sanitizer = inject(DomSanitizer); private sanitizer = inject(DomSanitizer);
// Cache des URLs de logos // Cache des URLs de logos
private logoUrlCache = new Map<string, string>(); private logoUrlCache = new Map<string, string>();
// Ajouter un cache pour les logos non trouvés
private logoErrorCache = new Set<string>();
// Configuration // Configuration
readonly UserRole = UserRole; readonly UserRole = UserRole;
@ -386,8 +389,8 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
} }
// Conversion pour la mise à jour // Conversion pour la mise à jour
private convertUpdateMerchantToBackend(dto: UpdateMerchantDto, existingMerchant?: Merchant): any { private convertUpdateMerchantToBackend(dto: UpdateMerchantDto): any {
return this.dataAdapter.convertUpdateMerchantToApi(dto, existingMerchant); return this.dataAdapter.convertUpdateMerchantToApi(dto);
} }
// ==================== GESTION DES PERMISSIONS ==================== // ==================== GESTION DES PERMISSIONS ====================
@ -552,28 +555,24 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
return this.canManageMerchants; return this.canManageMerchants;
} }
private loadMerchantProfile(merchantId: number): void {
if (this.loadingMerchants[merchantId]) return;
this.loadingMerchants[merchantId] = true;
this.merchantConfigService.getMerchantById(merchantId).subscribe({
next: (merchant) => {
// Conversion pour Angular
const frontendMerchant = this.convertMerchantToFrontend(merchant);
this.merchantProfiles[merchantId] = frontendMerchant;
this.loadingMerchants[merchantId] = false;
},
error: (error) => {
console.error(`Error loading merchant profile ${merchantId}:`, error);
this.loadingMerchants[merchantId] = false;
}
});
}
backToList(): void { backToList(): void {
console.log('🔙 Returning to list view'); console.log('🔙 Returning to list view');
this.showTab('list');
// Réinitialiser les IDs sélectionnés
this.selectedMerchantId = null;
this.selectedConfigId = null;
this.selectedUserId = null;
// Vider le cache du profil actuel
if (this.selectedMerchantId) {
delete this.merchantProfiles[this.selectedMerchantId];
}
// Changer d'onglet
this.activeTab = 'list';
// Forcer la détection des changements
this.cdRef.detectChanges();
} }
@ -678,30 +677,6 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
}); });
} }
createMerchant(): void {
this.createMerchantWithLogo();
}
updateMerchant(): void {
this.updateMerchantWithLogo();
}
/**
* Génère une URL de logo par défaut basée sur les initiales
*/
getDefaultLogoUrl(merchantName: string): string {
// Créer un avatar avec les initiales
const initials = merchantName
.split(' ')
.map(word => word[0])
.join('')
.substring(0, 2)
.toUpperCase();
// Utiliser un service comme UI Avatars
return `https://ui-avatars.com/api/?name=${encodeURIComponent(initials)}&background=random&size=200&bold=true`;
}
/** /**
* Formate la taille du fichier * Formate la taille du fichier
*/ */
@ -736,8 +711,7 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
this.editLogoPreviewUrl = null; this.editLogoPreviewUrl = null;
this.selectedLogoFile = null; this.selectedLogoFile = null;
this.editLogoFile = null; this.editLogoFile = null;
} }
// ==================== OPÉRATIONS CRUD ==================== // ==================== OPÉRATIONS CRUD ====================
// ==================== GESTION DU LOGO LORS DE LA CRÉATION ==================== // ==================== GESTION DU LOGO LORS DE LA CRÉATION ====================
@ -792,11 +766,12 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
fileInput.value = ''; fileInput.value = '';
} }
} }
/** /**
* Upload le logo et crée le marchand * Appel API pour créer le marchand
*/ */
createMerchantWithLogo(): void { createMerchant(): void {
if (!this.canCreateMerchants) { if (!this.canCreateMerchants) {
this.createMerchantError = 'Vous n\'avez pas la permission de créer des marchands'; this.createMerchantError = 'Vous n\'avez pas la permission de créer des marchands';
return; return;
@ -811,41 +786,6 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
this.creatingMerchant = true; this.creatingMerchant = true;
this.createMerchantError = ''; this.createMerchantError = '';
// Si un logo est sélectionné, l'uploader d'abord
if (this.selectedLogoFile) {
this.uploadingLogo = true;
this.minioService.uploadMerchantLogo(this.selectedLogoFile)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (uploadResponse) => {
console.log('✅ Logo uploaded:', uploadResponse);
this.uploadingLogo = false;
// Ajouter le nom du fichier au DTO
this.newMerchant.logo = uploadResponse.fileName;
// Créer le marchand avec le logo
this.createMerchantApiCall();
},
error: (error) => {
console.error('❌ Error uploading logo:', error);
this.uploadingLogo = false;
this.creatingMerchant = false;
this.createMerchantError = 'Erreur lors de l\'upload du logo: ' + (error.message || 'Erreur inconnue');
this.cdRef.detectChanges();
}
});
} else {
// Pas de logo, créer directement
this.createMerchantApiCall();
}
}
/**
* Appel API pour créer le marchand
*/
private createMerchantApiCall(): void {
const createDto = this.convertMerchantToBackend(this.newMerchant); const createDto = this.convertMerchantToBackend(this.newMerchant);
console.log('📤 Creating merchant:', createDto); console.log('📤 Creating merchant:', createDto);
@ -854,8 +794,29 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
.pipe(takeUntil(this.destroy$)) .pipe(takeUntil(this.destroy$))
.subscribe({ .subscribe({
next: (createdMerchant) => { next: (createdMerchant) => {
const frontendMerchant = this.convertMerchantToFrontend(createdMerchant); const frontendMerchant = this.convertMerchantToFrontend(createdMerchant);
// Si un logo est sélectionné, l'uploader d'abord
if (this.selectedLogoFile) {
this.uploadingLogo = true;
this.minioService.uploadMerchantLogo(Number(frontendMerchant.id), frontendMerchant.name, this.selectedLogoFile)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (uploadResponse) => {
console.log('✅ Logo uploaded:', uploadResponse);
this.uploadingLogo = false;
this.newMerchant.logo = uploadResponse.data.fileName;
},
error: (error) => {
console.error('❌ Error uploading logo:', error);
this.uploadingLogo = false;
}
});
}
console.log('✅ Merchant created successfully:', frontendMerchant); console.log('✅ Merchant created successfully:', frontendMerchant);
this.creatingMerchant = false; this.creatingMerchant = false;
this.modalService.dismissAll(); this.modalService.dismissAll();
@ -931,9 +892,9 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
} }
/** /**
* Met à jour le marchand avec le nouveau logo * Appel API pour mettre à jour le marchand (version avec switchMap)
*/ */
updateMerchantWithLogo(): void { updateMerchant(): void {
if (!this.selectedMerchantForEdit) { if (!this.selectedMerchantForEdit) {
this.updateMerchantError = 'Aucun marchand sélectionné pour modification'; this.updateMerchantError = 'Aucun marchand sélectionné pour modification';
return; return;
@ -948,119 +909,264 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
this.updatingMerchant = true; this.updatingMerchant = true;
this.updateMerchantError = ''; this.updateMerchantError = '';
const merchantId = this.selectedMerchantForEdit!.id!;
const merchantName = this.selectedMerchantForEdit.name!;
let uploadObservable$;
// Si un nouveau logo est sélectionné // Si un nouveau logo est sélectionné
if (this.editLogoFile && this.logoChanged) { if (this.editLogoFile && this.logoChanged) {
this.uploadingLogo = true; this.uploadingLogo = true;
const merchantId = this.selectedMerchantForEdit.id!;
this.minioService.uploadMerchantLogo(this.editLogoFile, merchantId) uploadObservable$ = this.minioService.uploadMerchantLogo(
.pipe(takeUntil(this.destroy$)) merchantId,
.subscribe({ merchantName,
next: (uploadResponse) => { this.editLogoFile
console.log('✅ New logo uploaded:', uploadResponse); ).pipe(
this.uploadingLogo = false; switchMap(uploadResponse => {
console.log('✅ New logo uploaded:', uploadResponse);
// Supprimer l'ancien logo si différent this.uploadingLogo = false;
const oldLogo = this.selectedMerchantForEdit!.logo;
if (oldLogo && oldLogo !== uploadResponse.fileName) { // Mettre à jour le nom du logo dans l'objet merchant
this.minioService.deleteMerchantLogo(oldLogo).subscribe({ this.selectedMerchantForEdit!.logo = uploadResponse.data.fileName;
next: () => console.log('🗑️ Old logo deleted'),
error: (err) => console.error('⚠️ Error deleting old logo:', err) // Supprimer l'ancien logo si différent
}); const oldLogo = this.selectedMerchantForEdit!.logo;
} if (oldLogo && oldLogo !== uploadResponse.data.fileName) {
this.minioService.deleteMerchantLogo(oldLogo).subscribe({
// Mettre à jour le logo dans le DTO next: () => console.log('🗑️ Old logo deleted'),
this.selectedMerchantForEdit!.logo = uploadResponse.fileName; error: (err) => console.warn('⚠️ Could not delete old logo:', err)
});
// Mettre à jour le marchand
this.updateMerchantApiCall();
},
error: (error) => {
console.error('❌ Error uploading new logo:', error);
this.uploadingLogo = false;
this.updatingMerchant = false;
this.updateMerchantError = 'Erreur lors de l\'upload du logo: ' + (error.message || 'Erreur inconnue');
this.cdRef.detectChanges();
} }
});
console.log('Logo : ' + this.selectedMerchantForEdit!.logo)
// Retourner l'observable pour la mise à jour du marchand
const updateDto = this.convertUpdateMerchantToBackend(
this.selectedMerchantForEdit!
);
return this.merchantConfigService.updateMerchant(merchantId, updateDto);
})
);
} else { } else {
// Pas de changement de logo, mettre à jour directement // Pas de nouveau logo, mettre à jour directement
this.updateMerchantApiCall(); const updateDto = this.convertUpdateMerchantToBackend(
this.selectedMerchantForEdit!
);
uploadObservable$ = this.merchantConfigService.updateMerchant(merchantId, updateDto);
} }
}
/** uploadObservable$.pipe(
* Appel API pour mettre à jour le marchand takeUntil(this.destroy$)
*/ ).subscribe({
private updateMerchantApiCall(): void { next: (updatedMerchant) => {
const merchantId = this.selectedMerchantForEdit!.id!; const frontendMerchant = this.convertMerchantToFrontend(updatedMerchant);
const updateDto = this.convertUpdateMerchantToBackend(
this.selectedMerchantForEdit!, this.updatingMerchant = false;
this.selectedMerchantForEdit! this.modalService.dismissAll();
); this.refreshMerchantsConfigsView();
this.refreshMerchantsList();
console.log('📤 Updating merchant with full data:', updateDto);
// Mettre à jour le cache
this.merchantConfigService.updateMerchant(merchantId, updateDto) if (merchantId) {
.pipe(takeUntil(this.destroy$)) this.merchantProfiles[merchantId] = frontendMerchant;
.subscribe({
next: (updatedMerchant) => {
const frontendMerchant = this.convertMerchantToFrontend(updatedMerchant);
this.updatingMerchant = false; // Invalider le cache de l'URL du logo
this.modalService.dismissAll(); if (frontendMerchant.logo) {
this.refreshMerchantsConfigsView(); this.logoUrlCache.delete(frontendMerchant.logo);
this.refreshMerchantsList();
// Mettre à jour le cache
if (this.selectedMerchantId) {
this.merchantProfiles[this.selectedMerchantId] = frontendMerchant;
// Invalider le cache de l'URL du logo
if (frontendMerchant.logo) {
this.logoUrlCache.delete(frontendMerchant.logo);
}
} }
if (this.isMerchantUser && this.userMerchantId === merchantId) {
this.userMerchant = frontendMerchant;
}
this.successMessage = 'Marchand modifié avec succès';
// Reset les états du logo
this.cancelEditLogo();
this.cdRef.detectChanges();
},
error: (error) => {
console.error('❌ Error updating merchant:', error);
this.updatingMerchant = false;
this.updateMerchantError = this.getUpdateErrorMessage(error);
this.cdRef.detectChanges();
} }
});
if (this.isMerchantUser && this.userMerchantId === merchantId) {
this.userMerchant = frontendMerchant;
}
this.successMessage = 'Marchand modifié avec succès';
// Reset les états du logo
this.cancelEditLogo();
this.cdRef.detectChanges();
},
error: (error) => {
console.error('❌ Error in update process:', error);
this.updatingMerchant = false;
this.uploadingLogo = false;
this.updateMerchantError = this.getUpdateErrorMessage(error);
this.cdRef.detectChanges();
}
});
} }
// ==================== AFFICHAGE DU LOGO ==================== // ==================== AFFICHAGE DU LOGO ====================
/** /**
* Récupère l'URL du logo pour affichage * Récupère l'URL du logo avec fallback automatique
*/ */
getMerchantLogoUrl(logoFileName: string): Observable<string> { getMerchantLogoUrl(
// Vérifier le cache merchantId: string,
if (this.logoUrlCache.has(logoFileName)) { merchantName: string,
return of(this.logoUrlCache.get(logoFileName)!); logoFileName: string,
): Observable<string> {
const cacheKey = `${merchantId}_${logoFileName}`;
// Vérifier si le logo est en cache d'erreur
if (this.logoErrorCache.has(cacheKey)) {
const defaultLogo = this.getDefaultLogoUrl(merchantName);
return of(defaultLogo);
} }
// Récupérer l'URL depuis MinIO // Vérifier le cache normal
return this.minioService.getMerchantLogoUrl(logoFileName).pipe( if (this.logoUrlCache.has(cacheKey)) {
tap(url => { return of(this.logoUrlCache.get(cacheKey)!);
// Mettre en cache }
this.logoUrlCache.set(logoFileName, url);
// Récupérer l'URL depuis l'API avec la nouvelle structure
return this.minioService.getMerchantLogoUrl(
merchantId,
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 => {
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 = [
'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];
// 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
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) || '??';
}
// Prendre la première lettre des deux premiers mots
const initials = words
.slice(0, 2) // Prendre les 2 premiers mots
.map(word => word[0] || '')
.join('');
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 = `<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40">
<rect width="40" height="40" fill="#667eea" rx="20"/>
<text x="20" y="22" text-anchor="middle" fill="white" font-family="Arial" font-size="14" font-weight="bold">?</text>
</svg>`;
return 'data:image/svg+xml;base64,' + btoa(svg);
}
/** /**
* Charge le logo pour l'édition * Charge le logo pour l'édition
@ -1071,13 +1177,13 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
return; return;
} }
this.getMerchantLogoUrl(merchant.logo).subscribe({ this.getMerchantLogoUrl(String(merchant.id), merchant.name, merchant.logo).subscribe({
next: (url) => { next: (url) => {
this.currentLogoUrl = url; this.currentLogoUrl = url;
this.cdRef.detectChanges(); this.cdRef.detectChanges();
}, },
error: (error) => { error: (error) => {
console.error('Error loading logo:', error); console.error('Error loading logo for edit:', error);
this.currentLogoUrl = null; this.currentLogoUrl = null;
} }
}); });

View File

@ -105,7 +105,7 @@ export class MerchantDataAdapter {
/** /**
* Convertit un DTO de mise à jour pour l'API * Convertit un DTO de mise à jour pour l'API
*/ */
convertUpdateMerchantToApi(dto: UpdateMerchantDto, existingMerchant?: Merchant): any { convertUpdateMerchantToApi(dto: UpdateMerchantDto): any {
this.validateUpdateMerchantDto(dto); this.validateUpdateMerchantDto(dto);
const updateData: any = {}; const updateData: any = {};

1
src/assets/images/01.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 154 36" xmlns:v="https://vecta.io/nano"><path d="M20.9 30.8h25.6V5.2H20.9v25.6zm20.8-15.3h-5.4v-5.4h5.4v5.4zm-15.8-5.4h5.4v10.4h10.4v5.4H25.9V10.1zm37.4-4.9c-2.5 0-5 .8-7.1 2.2s-3.8 3.4-4.7 5.8c-1 2.3-1.2 4.9-.7 7.4s1.7 4.8 3.5 6.6 4.1 3 6.6 3.5 5.1.2 7.4-.7a13.93 13.93 0 0 0 5.8-4.7c1.4-2.1 2.2-4.6 2.2-7.1 0-3.4-1.4-6.7-3.8-9.1-2.5-2.6-5.8-3.9-9.2-3.9zm0 20.7c-1.6 0-3.1-.5-4.4-1.3-1.3-.9-2.3-2.1-2.9-3.5s-.8-3-.4-4.6c.3-1.5 1.1-2.9 2.2-4s2.5-1.9 4-2.2 3.1-.1 4.6.4c1.4.6 2.7 1.6 3.5 2.9.9 1.3 1.3 2.8 1.3 4.4 0 2.1-.8 4.1-2.3 5.6s-3.5 2.3-5.6 2.3zM122 5.2c-2.5 0-5 .8-7.1 2.2s-3.8 3.4-4.7 5.8c-1 2.3-1.2 4.9-.7 7.4s1.7 4.8 3.5 6.6 4.1 3 6.6 3.5 5.1.2 7.4-.7a13.93 13.93 0 0 0 5.8-4.7c1.4-2.1 2.2-4.6 2.2-7.1 0-3.4-1.4-6.7-3.8-9.1-2.5-2.6-5.8-3.9-9.2-3.9zm0 20.7c-1.6 0-3.1-.5-4.4-1.3-1.3-.9-2.3-2.1-2.9-3.5s-.8-3-.4-4.6c.3-1.5 1.1-2.9 2.2-4s2.5-1.9 4-2.2 3.1-.1 4.6.4c1.4.6 2.7 1.6 3.5 2.9.9 1.3 1.3 2.8 1.3 4.4 0 2.1-.8 4.1-2.3 5.6s-3.5 2.3-5.6 2.3zM92.7 5.2c-2.5 0-5 .8-7.1 2.2s-3.8 3.4-4.7 5.8c-1 2.3-1.2 4.9-.7 7.4s1.7 4.8 3.5 6.6 4.1 3 6.6 3.5 5.1.2 7.4-.7a13.93 13.93 0 0 0 5.8-4.7c1.4-2.1 2.2-4.6 2.2-7.1 0-3.4-1.4-6.7-3.8-9.1-2.6-2.6-5.8-3.9-9.2-3.9zm0 20.7c-1.5 0-2.9-.4-4.1-1.2s-2.2-1.8-2.9-3.1-1-2.7-.8-4.2c.1-1.4.6-2.8 1.5-4s2-2.1 3.4-2.7c1.3-.6 2.8-.7 4.2-.5s2.8.8 3.9 1.8c1.1.9 1.9 2.2 2.4 3.5h-7.5v4.9h7.5c-.5 1.6-1.5 2.9-2.9 3.9-1.5 1.1-3.1 1.6-4.7 1.6z" fill="#b4b7c9"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB