feat: Manage Images using Minio Service
This commit is contained in:
parent
47f09b3c4e
commit
f8aa8eb595
463
src/app/app.scss
463
src/app/app.scss
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
// Ajouter des métadonnées optionnelles
|
||||||
const timestamp = Date.now();
|
if (merchantId) {
|
||||||
const extension = file.name.split('.').pop();
|
formData.append('merchantId', merchantId.toString());
|
||||||
const fileName = merchantId
|
}
|
||||||
? `merchant_${merchantId}_${timestamp}.${extension}`
|
if (merchantName) {
|
||||||
: `logo_${timestamp}.${extension}`;
|
formData.append('merchantName', merchantName);
|
||||||
|
}
|
||||||
|
|
||||||
formData.append('objectName', fileName);
|
// Headers
|
||||||
|
const headers = new HttpHeaders({
|
||||||
return this.http.post<UploadLogoResponse>(`${this.apiUrl}/upload-logo`, formData);
|
'Accept': 'application/json'
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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$))
|
||||||
@ -220,31 +214,58 @@ 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);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@ -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
|
* 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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -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">
|
||||||
|
@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"
|
||||||
|
(error)="onDefaultLogoError($event)"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="col-md-4">
|
<!-- Informations du marchand -->
|
||||||
<ng-container *ngIf="merchant.logo;">
|
<div class="flex-grow-1">
|
||||||
<img
|
<h2 class="mb-2 text-white">{{ merchant.name }}</h2>
|
||||||
[src]="getMerchantLogoUrl(merchant.logo) | async"
|
@if (merchant.description) {
|
||||||
[alt]="merchant.name"
|
<p class="mb-0 text-white opacity-75">{{ merchant.description }}</p>
|
||||||
class="merchant-logo-large"
|
} @else {
|
||||||
/>
|
<p class="mb-0 text-white opacity-50 fst-italic">Aucune description</p>
|
||||||
</ng-container>
|
}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-4 text-md-end">
|
<!-- Informations de contact -->
|
||||||
@if (canEditMerchant()) {
|
<div class="mt-3 d-flex flex-wrap gap-3">
|
||||||
<button
|
<div class="d-flex align-items-center text-white opacity-75">
|
||||||
class="btn btn-light"
|
<ng-icon name="lucideMapPin" size="16" class="me-2"></ng-icon>
|
||||||
(click)="editMerchant(merchant)"
|
<small>{{ merchant.adresse }}</small>
|
||||||
>
|
</div>
|
||||||
<ng-icon name="lucideEdit" class="me-1"></ng-icon>
|
<div class="d-flex align-items-center text-white opacity-75">
|
||||||
Modifier le profil
|
<ng-icon name="lucidePhone" size="16" class="me-2"></ng-icon>
|
||||||
</button>
|
<small>{{ merchant.phone }}</small>
|
||||||
}
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Statistiques -->
|
<!-- Actions -->
|
||||||
<div class="p-3">
|
<div class="col-md-4 text-md-end">
|
||||||
<div class="row g-3">
|
@if (canEditMerchant()) {
|
||||||
@if (getMerchantStats(); as stats) {
|
<button
|
||||||
<div class="col-md-3">
|
class="btn btn-light"
|
||||||
<div class="stats-card">
|
(click)="editMerchant(merchant)"
|
||||||
<div class="stats-number">{{ stats.configs.total }}</div>
|
>
|
||||||
<div class="stats-label">Configurations</div>
|
<ng-icon name="lucideEdit" class="me-1"></ng-icon>
|
||||||
</div>
|
Modifier le profil
|
||||||
</div>
|
</button>
|
||||||
<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 -->
|
||||||
|
|||||||
@ -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,28 +296,54 @@ 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);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@ -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
|
* 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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,38 +637,57 @@
|
|||||||
<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>
|
||||||
|
|
||||||
|
|||||||
@ -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';
|
||||||
@ -60,6 +60,9 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
// 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
|
||||||
*/
|
*/
|
||||||
@ -737,7 +712,6 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
|
|||||||
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 ====================
|
||||||
@ -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) {
|
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,120 +909,265 @@ 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);
|
||||||
|
this.uploadingLogo = false;
|
||||||
|
|
||||||
// Supprimer l'ancien logo si différent
|
// Mettre à jour le nom du logo dans l'objet merchant
|
||||||
const oldLogo = this.selectedMerchantForEdit!.logo;
|
this.selectedMerchantForEdit!.logo = uploadResponse.data.fileName;
|
||||||
if (oldLogo && oldLogo !== uploadResponse.fileName) {
|
|
||||||
this.minioService.deleteMerchantLogo(oldLogo).subscribe({
|
|
||||||
next: () => console.log('🗑️ Old logo deleted'),
|
|
||||||
error: (err) => console.error('⚠️ Error deleting old logo:', err)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mettre à jour le logo dans le DTO
|
// Supprimer l'ancien logo si différent
|
||||||
this.selectedMerchantForEdit!.logo = uploadResponse.fileName;
|
const oldLogo = this.selectedMerchantForEdit!.logo;
|
||||||
|
if (oldLogo && oldLogo !== uploadResponse.data.fileName) {
|
||||||
// Mettre à jour le marchand
|
this.minioService.deleteMerchantLogo(oldLogo).subscribe({
|
||||||
this.updateMerchantApiCall();
|
next: () => console.log('🗑️ Old logo deleted'),
|
||||||
},
|
error: (err) => console.warn('⚠️ Could not delete old logo:', err)
|
||||||
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.selectedMerchantForEdit!
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log('📤 Updating merchant with full data:', updateDto);
|
this.updatingMerchant = false;
|
||||||
|
this.modalService.dismissAll();
|
||||||
|
this.refreshMerchantsConfigsView();
|
||||||
|
this.refreshMerchantsList();
|
||||||
|
|
||||||
this.merchantConfigService.updateMerchant(merchantId, updateDto)
|
// Mettre à jour le cache
|
||||||
.pipe(takeUntil(this.destroy$))
|
if (merchantId) {
|
||||||
.subscribe({
|
this.merchantProfiles[merchantId] = frontendMerchant;
|
||||||
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;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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
1
src/assets/images/01.svg
Normal 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 |
Loading…
Reference in New Issue
Block a user