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;
}
}
// ==================== 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 { 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;
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
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();
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);
}
/**
* Récupère l'URL présignée pour afficher un logo
* URL valide pour 7 jours
*/
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
}
// Headers
const headers = new HttpHeaders({
'Accept': 'application/json'
});
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 } {
// 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<LogoUrlResponse> {
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<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> {
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<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);
});
}
/**
* 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>
<td>
<div class="d-flex align-items-center">
<!-- ==================== LOGO AVEC ICÔNE ==================== -->
<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 class="avatar-container me-3 position-relative">
<ng-container *ngIf="merchant.logo;">
<!-- Image du logo -->
<img
[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>
@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>
<!-- Informations du marchand -->
<div class="min-w-0 flex-grow-1">
<strong class="d-block text-truncate">{{ merchant.name }}</strong>
<small class="text-muted text-truncate d-block">{{ merchant.adresse }}</small>
</div>
</div>
</td>

View File

@ -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<void>();
private minioService = inject(MinioService);
private sanitizer = inject(DomSanitizer);
// Cache des URLs de logos
private logoUrlCache = new Map<string, string>();
@ -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$))
@ -220,31 +214,58 @@ 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<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
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);
})
);
@ -254,48 +275,113 @@ export class MerchantConfigsList implements OnInit, OnDestroy {
* 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 = `<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 {
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();
}
}

View File

@ -106,25 +106,61 @@
<ng-template ngbNavContent>
<!-- Vue d'ensemble -->
<div class="p-3">
<!-- En-tête du profil -->
<!-- En-tête du profil avec logo -->
<div class="profile-section">
<div class="profile-header">
<div class="row align-items-center">
<div class="col-md-4">
<h2 class="mb-2">{{ merchant.name }}</h2>
<p class="mb-0 opacity-75">{{ merchant.description || 'Aucune description' }}</p>
</div>
<div class="col-md-4">
<ng-container *ngIf="merchant.logo;">
<!-- Logo et informations -->
<div class="col-md-8">
<div class="d-flex align-items-center">
<!-- ==================== LOGO DU MARCHAND ==================== -->
<div class="merchant-logo-container me-4">
@if (merchant.logo && merchant.logo.trim() !== '') {
<img
[src]="getMerchantLogoUrl(merchant.logo) | async"
[alt]="merchant.name"
class="merchant-logo-large"
[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)"
/>
</ng-container>
} @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"
(error)="onDefaultLogoError($event)"
/>
}
</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>
<!-- Actions -->
<div class="col-md-4 text-md-end">
@if (canEditMerchant()) {
<button
@ -145,24 +181,36 @@
@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>
@ -171,7 +219,6 @@
</div>
</div>
</div>
<!-- Informations principales -->
<div class="row g-4">
<!-- Configurations récentes -->

View File

@ -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,28 +296,54 @@ export class MerchantConfigView implements OnInit, OnDestroy {
/**
* 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
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);
})
);
@ -327,46 +353,112 @@ export class MerchantConfigView implements OnInit, OnDestroy {
* 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 = `<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);
}
/**

View File

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

View File

@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule, FormBuilder, Validators, FormArray, FormGroup } from '@angular/forms';
import { NgIcon } from '@ng-icons/core';
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 { MerchantConfigService } from './merchant-config.service';
@ -60,6 +60,9 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
// Cache des URLs de logos
private logoUrlCache = new Map<string, string>();
// Ajouter un cache pour les logos non trouvés
private logoErrorCache = new Set<string>();
// Configuration
readonly UserRole = UserRole;
@ -386,8 +389,8 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
}
// Conversion pour la mise à jour
private convertUpdateMerchantToBackend(dto: UpdateMerchantDto, existingMerchant?: Merchant): any {
return this.dataAdapter.convertUpdateMerchantToApi(dto, existingMerchant);
private convertUpdateMerchantToBackend(dto: UpdateMerchantDto): any {
return this.dataAdapter.convertUpdateMerchantToApi(dto);
}
// ==================== GESTION DES PERMISSIONS ====================
@ -552,28 +555,24 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
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 {
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
*/
@ -737,7 +712,6 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
this.selectedLogoFile = null;
this.editLogoFile = null;
}
// ==================== OPÉRATIONS CRUD ====================
// ==================== GESTION DU LOGO LORS DE LA CRÉATION ====================
@ -794,9 +768,10 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
}
/**
* Upload le logo et crée le marchand
* Appel API pour créer le marchand
*/
createMerchantWithLogo(): void {
createMerchant(): void {
if (!this.canCreateMerchants) {
this.createMerchantError = 'Vous n\'avez pas la permission de créer des marchands';
return;
@ -811,41 +786,6 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
this.creatingMerchant = true;
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);
console.log('📤 Creating merchant:', createDto);
@ -854,8 +794,29 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (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);
this.creatingMerchant = false;
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) {
this.updateMerchantError = 'Aucun marchand sélectionné pour modification';
return;
@ -948,62 +909,58 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
this.updatingMerchant = true;
this.updateMerchantError = '';
const merchantId = this.selectedMerchantForEdit!.id!;
const merchantName = this.selectedMerchantForEdit.name!;
let uploadObservable$;
// Si un nouveau logo est sélectionné
if (this.editLogoFile && this.logoChanged) {
this.uploadingLogo = true;
const merchantId = this.selectedMerchantForEdit.id!;
this.minioService.uploadMerchantLogo(this.editLogoFile, merchantId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (uploadResponse) => {
uploadObservable$ = this.minioService.uploadMerchantLogo(
merchantId,
merchantName,
this.editLogoFile
).pipe(
switchMap(uploadResponse => {
console.log('✅ New logo uploaded:', uploadResponse);
this.uploadingLogo = false;
// Mettre à jour le nom du logo dans l'objet merchant
this.selectedMerchantForEdit!.logo = uploadResponse.data.fileName;
// Supprimer l'ancien logo si différent
const oldLogo = this.selectedMerchantForEdit!.logo;
if (oldLogo && oldLogo !== uploadResponse.fileName) {
if (oldLogo && oldLogo !== uploadResponse.data.fileName) {
this.minioService.deleteMerchantLogo(oldLogo).subscribe({
next: () => console.log('🗑️ Old logo deleted'),
error: (err) => console.error('⚠️ Error deleting old logo:', err)
error: (err) => console.warn('⚠️ Could not delete old logo:', err)
});
}
// Mettre à jour le logo dans le DTO
this.selectedMerchantForEdit!.logo = uploadResponse.fileName;
console.log('Logo : ' + this.selectedMerchantForEdit!.logo)
// 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();
}
});
} else {
// Pas de changement de logo, mettre à jour directement
this.updateMerchantApiCall();
}
}
/**
* Appel API pour mettre à jour le marchand
*/
private updateMerchantApiCall(): void {
const merchantId = this.selectedMerchantForEdit!.id!;
// Retourner l'observable pour la mise à jour du marchand
const updateDto = this.convertUpdateMerchantToBackend(
this.selectedMerchantForEdit!,
this.selectedMerchantForEdit!
);
console.log('📤 Updating merchant with full data:', updateDto);
return this.merchantConfigService.updateMerchant(merchantId, updateDto);
})
);
} else {
// Pas de nouveau logo, mettre à jour directement
const updateDto = this.convertUpdateMerchantToBackend(
this.selectedMerchantForEdit!
);
this.merchantConfigService.updateMerchant(merchantId, updateDto)
.pipe(takeUntil(this.destroy$))
.subscribe({
uploadObservable$ = this.merchantConfigService.updateMerchant(merchantId, updateDto);
}
uploadObservable$.pipe(
takeUntil(this.destroy$)
).subscribe({
next: (updatedMerchant) => {
const frontendMerchant = this.convertMerchantToFrontend(updatedMerchant);
@ -1013,8 +970,8 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
this.refreshMerchantsList();
// Mettre à jour le cache
if (this.selectedMerchantId) {
this.merchantProfiles[this.selectedMerchantId] = frontendMerchant;
if (merchantId) {
this.merchantProfiles[merchantId] = frontendMerchant;
// Invalider le cache de l'URL du logo
if (frontendMerchant.logo) {
@ -1034,8 +991,9 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
this.cdRef.detectChanges();
},
error: (error) => {
console.error('❌ Error updating merchant:', error);
console.error('❌ Error in update process:', error);
this.updatingMerchant = false;
this.uploadingLogo = false;
this.updateMerchantError = this.getUpdateErrorMessage(error);
this.cdRef.detectChanges();
}
@ -1045,23 +1003,171 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
// ==================== 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> {
// Vérifier le cache
if (this.logoUrlCache.has(logoFileName)) {
return of(this.logoUrlCache.get(logoFileName)!);
getMerchantLogoUrl(
merchantId: string,
merchantName: string,
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
return this.minioService.getMerchantLogoUrl(logoFileName).pipe(
tap(url => {
// Mettre en cache
this.logoUrlCache.set(logoFileName, url);
// Vérifier le cache normal
if (this.logoUrlCache.has(cacheKey)) {
return of(this.logoUrlCache.get(cacheKey)!);
}
// 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
*/
@ -1071,13 +1177,13 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
return;
}
this.getMerchantLogoUrl(merchant.logo).subscribe({
this.getMerchantLogoUrl(String(merchant.id), merchant.name, merchant.logo).subscribe({
next: (url) => {
this.currentLogoUrl = url;
this.cdRef.detectChanges();
},
error: (error) => {
console.error('Error loading logo:', error);
console.error('Error loading logo for edit:', error);
this.currentLogoUrl = null;
}
});

View File

@ -105,7 +105,7 @@ export class MerchantDataAdapter {
/**
* Convertit un DTO de mise à jour pour l'API
*/
convertUpdateMerchantToApi(dto: UpdateMerchantDto, existingMerchant?: Merchant): any {
convertUpdateMerchantToApi(dto: UpdateMerchantDto): any {
this.validateUpdateMerchantDto(dto);
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