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;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 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 { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient, HttpHeaders, HttpEventType, HttpResponse } from '@angular/common/http';
|
||||
import { Observable, throwError, of, Subject } from 'rxjs';
|
||||
import { map, catchError, tap, filter } from 'rxjs/operators';
|
||||
import { environment } from '@environments/environment';
|
||||
|
||||
export interface UploadLogoResponse {
|
||||
export interface ImageUploadResponse {
|
||||
message : string;
|
||||
success: boolean;
|
||||
fileName: string;
|
||||
url: string;
|
||||
size: number;
|
||||
merchant: {
|
||||
id: string,
|
||||
name: string
|
||||
};
|
||||
data: {
|
||||
fileName: string;
|
||||
url: string;
|
||||
publicUrl: string;
|
||||
downloadUrl: string;
|
||||
size: number;
|
||||
contentType: string;
|
||||
uploadedAt: Date;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ImageValidationResult {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface UploadProgress {
|
||||
loaded: number;
|
||||
total: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
export interface LogoUrlResponse {
|
||||
success: boolean;
|
||||
data: {
|
||||
fileName: string;
|
||||
url: string;
|
||||
merchantId: string;
|
||||
merchantName: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LogoUrlOptions {
|
||||
signed?: boolean;
|
||||
expirySeconds?: number;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class MinioService {
|
||||
private apiUrl = `${environment.configApiUrl}/minio`; // URL de votre backend
|
||||
private http = inject(HttpClient);
|
||||
private baseUrl = `${environment.configApiUrl}/images`;
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
// Sujet pour les événements de progression
|
||||
private uploadProgressSubject = new Subject<UploadProgress>();
|
||||
public uploadProgress$ = this.uploadProgressSubject.asObservable();
|
||||
|
||||
// Types MIME autorisés
|
||||
private readonly ALLOWED_MIME_TYPES = [
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'image/svg+xml'
|
||||
];
|
||||
|
||||
// Taille maximale : 5MB
|
||||
private readonly MAX_FILE_SIZE = 5 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Upload un logo de marchand vers MinIO
|
||||
* Valide un fichier image
|
||||
*/
|
||||
uploadMerchantLogo(file: File, merchantId?: number): Observable<UploadLogoResponse> {
|
||||
validateImageFile(file: File): ImageValidationResult {
|
||||
// Vérifier le type MIME
|
||||
if (!this.ALLOWED_MIME_TYPES.includes(file.type)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Type de fichier non autorisé. Formats acceptés : JPG, PNG, GIF, WebP, SVG`
|
||||
};
|
||||
}
|
||||
|
||||
// Vérifier la taille
|
||||
if (file.size > this.MAX_FILE_SIZE) {
|
||||
const maxSizeMB = this.MAX_FILE_SIZE / (1024 * 1024);
|
||||
return {
|
||||
valid: false,
|
||||
error: `Fichier trop volumineux. Taille maximum : ${maxSizeMB}MB`
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* MÉTHODE PRINCIPALE - Upload via le serveur
|
||||
* préparation, upload et vérification
|
||||
*/
|
||||
uploadMerchantLogo( merchantId: number, merchantName: string, file: File): Observable<ImageUploadResponse> {
|
||||
// Validation
|
||||
const validation = this.validateImageFile(file);
|
||||
if (!validation.valid) {
|
||||
return throwError(() => new Error(validation.error));
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('bucketName', 'bo-assets');
|
||||
|
||||
// Générer un nom unique pour le logo
|
||||
const timestamp = Date.now();
|
||||
const extension = file.name.split('.').pop();
|
||||
const fileName = merchantId
|
||||
? `merchant_${merchantId}_${timestamp}.${extension}`
|
||||
: `logo_${timestamp}.${extension}`;
|
||||
// Ajouter des métadonnées optionnelles
|
||||
if (merchantId) {
|
||||
formData.append('merchantId', merchantId.toString());
|
||||
}
|
||||
if (merchantName) {
|
||||
formData.append('merchantName', merchantName);
|
||||
}
|
||||
|
||||
formData.append('objectName', fileName);
|
||||
|
||||
return this.http.post<UploadLogoResponse>(`${this.apiUrl}/upload-logo`, formData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère l'URL présignée pour afficher un logo
|
||||
* URL valide pour 7 jours
|
||||
*/
|
||||
getMerchantLogoUrl(logoFileName: string): Observable<string> {
|
||||
const expiry = 7 * 24 * 60 * 60; // 7 jours en secondes
|
||||
return this.http.get<{ url: string }>(
|
||||
`${this.apiUrl}/presigned-url`,
|
||||
{
|
||||
params: {
|
||||
bucketName: 'bo-assets',
|
||||
objectName: logoFileName,
|
||||
expiry: expiry.toString()
|
||||
}
|
||||
}
|
||||
).pipe(map(response => response.url));
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime un logo de marchand
|
||||
*/
|
||||
deleteMerchantLogo(logoFileName: string): Observable<any> {
|
||||
return this.http.delete(`${this.apiUrl}/delete`, {
|
||||
params: {
|
||||
bucketName: 'bo-assets',
|
||||
objectName: logoFileName
|
||||
}
|
||||
// Headers
|
||||
const headers = new HttpHeaders({
|
||||
'Accept': 'application/json'
|
||||
});
|
||||
|
||||
return this.http.post<ImageUploadResponse>(
|
||||
`${this.baseUrl}/merchants/${merchantId}/logos/upload`,
|
||||
formData,
|
||||
{
|
||||
headers,
|
||||
reportProgress: true,
|
||||
observe: 'events'
|
||||
}
|
||||
).pipe(
|
||||
|
||||
filter(event => event.type === HttpEventType.Response),
|
||||
|
||||
// Extraire le body
|
||||
map(event => event.body as ImageUploadResponse),
|
||||
|
||||
catchError(error => {
|
||||
console.error('❌ Error uploading image:', error);
|
||||
return throwError(() => new Error(
|
||||
error.error?.message ||
|
||||
error.error?.error ||
|
||||
error.message ||
|
||||
'Erreur lors de l\'upload du logo'
|
||||
));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide qu'un fichier est une image valide
|
||||
* Récupère l'URL (presigned) d'un logo marchand
|
||||
*/
|
||||
validateImageFile(file: File): { valid: boolean; error?: string } {
|
||||
// Vérifier le type MIME
|
||||
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'];
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Format non supporté. Utilisez JPG, PNG, GIF, WebP ou SVG.'
|
||||
};
|
||||
getMerchantLogoUrl(
|
||||
merchantId: string,
|
||||
fileName: string,
|
||||
options: LogoUrlOptions = {}
|
||||
): Observable<LogoUrlResponse> {
|
||||
if (!merchantId || !fileName || fileName.trim() === '') {
|
||||
return throwError(() => new Error('Paramètres invalides'));
|
||||
}
|
||||
|
||||
// Vérifier la taille (2MB max pour un logo)
|
||||
const maxSize = 2 * 1024 * 1024; // 2MB
|
||||
if (file.size > maxSize) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Le fichier est trop volumineux (max 2MB pour un logo)'
|
||||
};
|
||||
const params: any = {
|
||||
fileName
|
||||
};
|
||||
|
||||
if (options.signed) {
|
||||
params.signed = 'true';
|
||||
}
|
||||
|
||||
// Vérifier les dimensions si possible (optionnel)
|
||||
return { valid: true };
|
||||
if (options.expirySeconds) {
|
||||
params.expiry = options.expirySeconds.toString();
|
||||
}
|
||||
|
||||
return this.http.get<LogoUrlResponse>(
|
||||
`${this.baseUrl}/merchants/${merchantId}/logos/url`,
|
||||
{ params }
|
||||
).pipe(
|
||||
map(response => {
|
||||
if (response?.success && response.data?.url) {
|
||||
return response;
|
||||
}
|
||||
throw new Error('Logo non trouvé');
|
||||
}),
|
||||
catchError(error => {
|
||||
console.error('❌ Error getting logo URL:', error);
|
||||
return throwError(() => new Error(
|
||||
error.error?.message ||
|
||||
error.message ||
|
||||
'Logo non trouvé'
|
||||
));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les informations complètes d'une image
|
||||
*/
|
||||
getImageInfo(fileName: string): Observable<ImageUploadResponse> {
|
||||
if (!fileName || fileName.trim() === '') {
|
||||
return throwError(() => new Error('Nom de fichier invalide'));
|
||||
}
|
||||
|
||||
return this.http.get<{
|
||||
success: boolean;
|
||||
data?: ImageUploadResponse
|
||||
}>(`${this.baseUrl}/info/${encodeURIComponent(fileName)}`).pipe(
|
||||
map(response => {
|
||||
if (response.success && response.data) {
|
||||
return response.data;
|
||||
}
|
||||
throw new Error('Image not found or error in response');
|
||||
}),
|
||||
catchError(this.handleError('get image info'))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime un logo
|
||||
*/
|
||||
deleteMerchantLogo(fileName: string): Observable<void> {
|
||||
if (!fileName || fileName.trim() === '') {
|
||||
return throwError(() => new Error('Nom de fichier invalide'));
|
||||
}
|
||||
|
||||
return this.http.delete<{
|
||||
success: boolean;
|
||||
message: string
|
||||
}>(
|
||||
`${this.baseUrl}/${encodeURIComponent(fileName)}`
|
||||
).pipe(
|
||||
map(response => {
|
||||
if (response.success) {
|
||||
return void 0;
|
||||
}
|
||||
throw new Error(response.message || 'Erreur lors de la suppression');
|
||||
}),
|
||||
catchError(error => {
|
||||
console.error('❌ Error deleting logo:', error);
|
||||
return throwError(() => new Error(
|
||||
error.error?.message || 'Erreur lors de la suppression du logo'
|
||||
));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste les images de l'utilisateur courant
|
||||
*/
|
||||
getMyImages(): Observable<ImageUploadResponse[]> {
|
||||
return this.http.get<{
|
||||
success: boolean;
|
||||
count: number;
|
||||
images: ImageUploadResponse[]
|
||||
}>(`${this.baseUrl}/my-images`).pipe(
|
||||
map(response => {
|
||||
if (response.success) {
|
||||
return response.images || [];
|
||||
}
|
||||
return [];
|
||||
}),
|
||||
catchError(error => {
|
||||
console.error('❌ Error getting user images:', error);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -101,9 +274,124 @@ export class MinioService {
|
||||
previewImage(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e: any) => resolve(e.target.result);
|
||||
reader.onerror = () => reject(new Error('Erreur lors de la lecture du fichier'));
|
||||
|
||||
reader.onload = (e: ProgressEvent<FileReader>) => {
|
||||
if (e.target?.result) {
|
||||
resolve(e.target.result as string);
|
||||
} else {
|
||||
reject(new Error('Impossible de lire le fichier'));
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
reject(new Error('Erreur lors de la lecture du fichier'));
|
||||
};
|
||||
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Formate la taille d'un fichier
|
||||
*/
|
||||
formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si un nom de fichier est valide
|
||||
*/
|
||||
isValidFileName(fileName: string): boolean {
|
||||
return typeof fileName === 'string' && fileName.trim().length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait le nom de fichier depuis le chemin complet
|
||||
*/
|
||||
extractFileNameFromPath(filePath: string): string {
|
||||
return filePath.split('/').pop() || filePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée une URL d'objet blob pour prévisualisation
|
||||
*/
|
||||
createObjectUrl(file: File): string {
|
||||
return URL.createObjectURL(file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Révoque une URL d'objet blob
|
||||
*/
|
||||
revokeObjectUrl(url: string): void {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Réinitialise la progression
|
||||
*/
|
||||
resetUploadProgress(): void {
|
||||
this.uploadProgressSubject.next({ loaded: 0, total: 0, percentage: 0 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Gestionnaire d'erreurs générique
|
||||
*/
|
||||
private handleError(operation: string) {
|
||||
return (error: any): Observable<never> => {
|
||||
console.error(`❌ Error in ${operation}:`, error);
|
||||
|
||||
let errorMessage = 'Une erreur est survenue';
|
||||
|
||||
if (error.error instanceof ErrorEvent) {
|
||||
// Erreur côté client
|
||||
errorMessage = error.error.message;
|
||||
} else {
|
||||
// Erreur côté serveur
|
||||
errorMessage = error.error?.message ||
|
||||
error.error?.error ||
|
||||
error.message ||
|
||||
`Error ${error.status}: ${error.statusText}`;
|
||||
}
|
||||
|
||||
return throwError(() => new Error(errorMessage));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si le type MIME est une image
|
||||
*/
|
||||
isImageMimeType(mimeType: string): boolean {
|
||||
return this.ALLOWED_MIME_TYPES.includes(mimeType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère un nom de fichier sécurisé côté client (optionnel)
|
||||
*/
|
||||
generateSafeFileName(originalName: string, userId?: string): string {
|
||||
const timestamp = Date.now();
|
||||
const randomString = Math.random().toString(36).substring(2, 10);
|
||||
const extension = originalName.includes('.')
|
||||
? originalName.substring(originalName.lastIndexOf('.'))
|
||||
: '';
|
||||
|
||||
const baseName = originalName.includes('.')
|
||||
? originalName.substring(0, originalName.lastIndexOf('.'))
|
||||
: originalName;
|
||||
|
||||
const safeBaseName = baseName
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-zA-Z0-9-]/g, '_')
|
||||
.replace(/_+/g, '_')
|
||||
.toLowerCase()
|
||||
.substring(0, 100);
|
||||
|
||||
const prefix = userId ? `${userId}/` : '';
|
||||
|
||||
return `${prefix}${timestamp}_${randomString}_${safeBaseName}${extension}`;
|
||||
}
|
||||
}
|
||||
@ -180,23 +180,40 @@
|
||||
<tr>
|
||||
<td>
|
||||
<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">
|
||||
<ng-icon name="lucideStore" class="text-primary fs-12"></ng-icon>
|
||||
<!-- ==================== LOGO AVEC ICÔNE ==================== -->
|
||||
<div class="d-flex align-items-center me-3">
|
||||
<!-- Icône fixe à gauche -->
|
||||
<div class="avatar-sm bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2">
|
||||
<ng-icon name="lucideStore" class="text-primary fs-12"></ng-icon>
|
||||
</div>
|
||||
|
||||
<!-- Logo du marchand -->
|
||||
<div>
|
||||
@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>
|
||||
<!-- Logo du marchand -->
|
||||
<div class="avatar-container me-3 position-relative">
|
||||
<ng-container *ngIf="merchant.logo;">
|
||||
<!-- Image du logo -->
|
||||
<img
|
||||
[src]="getMerchantLogoUrl(merchant.logo) | async"
|
||||
[alt]="merchant.name + ' logo'"
|
||||
class="merchant-logo avatar-lg rounded-circle"
|
||||
/>
|
||||
</ng-container>
|
||||
</div>
|
||||
<div>
|
||||
<strong class="d-block">{{ merchant.name }}</strong>
|
||||
<small class="text-muted">{{ merchant.adresse }}</small>
|
||||
|
||||
<!-- Informations du marchand -->
|
||||
<div class="min-w-0 flex-grow-1">
|
||||
<strong class="d-block text-truncate">{{ merchant.name }}</strong>
|
||||
<small class="text-muted text-truncate d-block">{{ merchant.adresse }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@ -4,7 +4,7 @@ import { FormsModule } from '@angular/forms';
|
||||
import { NgIcon } from '@ng-icons/core';
|
||||
import { NgbPaginationModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { Observable, Subject, of } from 'rxjs';
|
||||
import { catchError, takeUntil, tap } from 'rxjs/operators';
|
||||
import { catchError, map, takeUntil, tap } from 'rxjs/operators';
|
||||
|
||||
import {
|
||||
Merchant,
|
||||
@ -43,7 +43,6 @@ export class MerchantConfigsList implements OnInit, OnDestroy {
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
private minioService = inject(MinioService);
|
||||
private sanitizer = inject(DomSanitizer);
|
||||
|
||||
// Cache des URLs de logos
|
||||
private logoUrlCache = new Map<string, string>();
|
||||
@ -113,11 +112,6 @@ export class MerchantConfigsList implements OnInit, OnDestroy {
|
||||
this.loadCurrentUserPermissions();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
private loadCurrentUserPermissions() {
|
||||
this.authService.getUserProfile()
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
@ -220,31 +214,58 @@ export class MerchantConfigsList implements OnInit, OnDestroy {
|
||||
|
||||
// ==================== AFFICHAGE DU LOGO ====================
|
||||
|
||||
|
||||
/**
|
||||
* Récupère l'URL du logo avec fallback automatique
|
||||
*/
|
||||
getMerchantLogoUrl(logoFileName: string, merchantName?: string): Observable<string> {
|
||||
getMerchantLogoUrl(
|
||||
merchantId: number | undefined,
|
||||
logoFileName: string,
|
||||
merchantName: string
|
||||
): Observable<string> {
|
||||
|
||||
const newMerchantId = String(merchantId);
|
||||
|
||||
const cacheKey = `${merchantId}_${logoFileName}`;
|
||||
|
||||
// Vérifier si le logo est en cache d'erreur
|
||||
if (this.logoErrorCache.has(logoFileName)) {
|
||||
const defaultLogo = this.getDefaultLogoUrl(merchantName || logoFileName);
|
||||
if (this.logoErrorCache.has(cacheKey)) {
|
||||
const defaultLogo = this.getDefaultLogoUrl(merchantName);
|
||||
return of(defaultLogo);
|
||||
}
|
||||
|
||||
// Vérifier le cache normal
|
||||
if (this.logoUrlCache.has(logoFileName)) {
|
||||
return of(this.logoUrlCache.get(logoFileName)!);
|
||||
if (this.logoUrlCache.has(cacheKey)) {
|
||||
return of(this.logoUrlCache.get(cacheKey)!);
|
||||
}
|
||||
|
||||
// Récupérer l'URL depuis MinIO
|
||||
return this.minioService.getMerchantLogoUrl(logoFileName).pipe(
|
||||
tap(url => {
|
||||
// Mettre en cache
|
||||
this.logoUrlCache.set(logoFileName, url);
|
||||
// Récupérer l'URL depuis l'API avec la nouvelle structure
|
||||
return this.minioService.getMerchantLogoUrl(
|
||||
newMerchantId,
|
||||
logoFileName,
|
||||
{ signed: true, expirySeconds: 3600 }
|
||||
).pipe(
|
||||
map(response => {
|
||||
// Extraire l'URL de la réponse
|
||||
const url = response.data.url ;
|
||||
|
||||
// Mettre en cache avec la clé composite
|
||||
this.logoUrlCache.set(cacheKey, url);
|
||||
|
||||
return url;
|
||||
}),
|
||||
catchError(error => {
|
||||
// En cas d'erreur, ajouter au cache d'erreur et retourner le logo par défaut
|
||||
this.logoErrorCache.add(logoFileName);
|
||||
const defaultLogo = this.getDefaultLogoUrl(merchantName || logoFileName);
|
||||
console.warn(`⚠️ Logo not found for merchant ${merchantId}: ${logoFileName}`, error);
|
||||
|
||||
// En cas d'erreur, ajouter au cache d'erreur
|
||||
this.logoErrorCache.add(cacheKey);
|
||||
|
||||
// Générer un logo par défaut
|
||||
const defaultLogo = this.getDefaultLogoUrl(merchantName);
|
||||
|
||||
// Mettre le logo par défaut dans le cache normal aussi
|
||||
this.logoUrlCache.set(cacheKey, defaultLogo);
|
||||
|
||||
return of(defaultLogo);
|
||||
})
|
||||
);
|
||||
@ -254,48 +275,113 @@ export class MerchantConfigsList implements OnInit, OnDestroy {
|
||||
* Génère une URL de logo par défaut basée sur les initiales
|
||||
*/
|
||||
getDefaultLogoUrl(merchantName: string): string {
|
||||
|
||||
// Créer des initiales significatives
|
||||
const initials = this.extractInitials(merchantName);
|
||||
|
||||
// Palette de couleurs agréables
|
||||
const colors = [
|
||||
'FF6B6B', '4ECDC4', '45B7D1', '96CEB4', 'FFEAA7',
|
||||
'DDA0DD', '98D8C8', 'F7DC6F', 'BB8FCE', '85C1E9'
|
||||
'667eea', // Violet
|
||||
'764ba2', // Violet foncé
|
||||
'f56565', // Rouge
|
||||
'4299e1', // Bleu
|
||||
'48bb78', // Vert
|
||||
'ed8936', // Orange
|
||||
'FF6B6B', // Rouge clair
|
||||
'4ECDC4', // Turquoise
|
||||
'45B7D1', // Bleu clair
|
||||
'96CEB4' // Vert menthe
|
||||
];
|
||||
|
||||
const colorIndex = merchantName.length % colors.length;
|
||||
const backgroundColor = colors[colorIndex];
|
||||
const textColor = 'FFFFFF'; // Blanc pour contraste
|
||||
|
||||
return `https://ui-avatars.com/api/?name=${encodeURIComponent(initials)}&background=${backgroundColor}&color=${textColor}&size=200&bold=true&font-size=0.5`;
|
||||
// Taille fixe à 80px (l'API génère un carré de cette taille)
|
||||
// L'image sera redimensionnée à 40px via CSS
|
||||
return `https://ui-avatars.com/api/?name=${encodeURIComponent(initials)}&background=${backgroundColor}&color=FFFFFF&size=80`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait les initiales de manière intelligente
|
||||
*/
|
||||
private extractInitials(name: string): string {
|
||||
if (!name || name.trim() === '') {
|
||||
return '??';
|
||||
}
|
||||
|
||||
// Nettoyer le nom
|
||||
const cleanedName = name.trim().toUpperCase();
|
||||
|
||||
// Extraire les mots (ignorer les articles, prépositions courtes)
|
||||
// Extraire les mots
|
||||
const words = cleanedName.split(/\s+/);
|
||||
|
||||
// Si un seul mot, prendre les deux premières lettres
|
||||
if (words.length === 1) {
|
||||
return words[0].substring(0, 2);
|
||||
return words[0].substring(0, 2) || '??';
|
||||
}
|
||||
|
||||
// Prendre la première lettre des deux premiers mots significatifs
|
||||
// Prendre la première lettre des deux premiers mots
|
||||
const initials = words
|
||||
.filter(word => word.length > 2) // Ignorer les mots courts
|
||||
.slice(0, 2) // Prendre maximum 2 mots
|
||||
.map(word => word[0])
|
||||
.slice(0, 2) // Prendre les 2 premiers mots
|
||||
.map(word => word[0] || '')
|
||||
.join('');
|
||||
|
||||
return initials || name.substring(0, 2).toUpperCase();
|
||||
return initials || name.substring(0, 2).toUpperCase() || '??';
|
||||
}
|
||||
|
||||
/**
|
||||
* Gère les erreurs de chargement des logos MinIO
|
||||
*/
|
||||
onLogoError(event: Event, merchantName: string): void {
|
||||
const img = event.target as HTMLImageElement;
|
||||
|
||||
if (!img) return;
|
||||
|
||||
console.warn('Logo MinIO failed to load, using default for:', merchantName);
|
||||
|
||||
img.onerror = null;
|
||||
img.src = this.getDefaultLogoUrl(merchantName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gère les erreurs de chargement des logos par défaut
|
||||
*/
|
||||
onDefaultLogoError(event: Event | string): void {
|
||||
if (!(event instanceof Event)) {
|
||||
console.error('Default logo error (non-event):', event);
|
||||
return;
|
||||
}
|
||||
|
||||
const img = event.target as HTMLImageElement | null;
|
||||
if (!img) return;
|
||||
|
||||
console.error('Default logo also failed to load, using fallback SVG');
|
||||
|
||||
// SVG local
|
||||
img.onerror = null; // éviter boucle infinie
|
||||
img.src = 'assets/images/default-merchant-logo.svg';
|
||||
|
||||
// Dernier recours
|
||||
img.onerror = (e) => {
|
||||
if (!(e instanceof Event)) return;
|
||||
const fallbackImg = e.target as HTMLImageElement | null;
|
||||
if (!fallbackImg) return;
|
||||
|
||||
fallbackImg.onerror = null;
|
||||
fallbackImg.src = this.generateFallbackDataUrl();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère un fallback SVG en data URL
|
||||
*/
|
||||
private generateFallbackDataUrl(): string {
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40">
|
||||
<rect width="40" height="40" fill="#667eea" rx="20"/>
|
||||
<text x="20" y="22" text-anchor="middle" fill="white" font-family="Arial" font-size="14" font-weight="bold">?</text>
|
||||
</svg>`;
|
||||
|
||||
return 'data:image/svg+xml;base64,' + btoa(svg);
|
||||
}
|
||||
|
||||
private buildSearchParams(): SearchMerchantsParams {
|
||||
const params: SearchMerchantsParams = {};
|
||||
@ -459,4 +545,15 @@ export class MerchantConfigsList implements OnInit, OnDestroy {
|
||||
shouldDisplayMerchantList(): boolean {
|
||||
return this.isHubUser;
|
||||
}
|
||||
|
||||
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
|
||||
// Nettoyer les caches
|
||||
this.logoUrlCache.clear();
|
||||
this.logoErrorCache.clear();
|
||||
}
|
||||
}
|
||||
@ -106,72 +106,119 @@
|
||||
<ng-template ngbNavContent>
|
||||
<!-- Vue d'ensemble -->
|
||||
<div class="p-3">
|
||||
<!-- En-tête du profil -->
|
||||
<div class="profile-section">
|
||||
<div class="profile-header">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-md-4">
|
||||
<h2 class="mb-2">{{ merchant.name }}</h2>
|
||||
<p class="mb-0 opacity-75">{{ merchant.description || 'Aucune description' }}</p>
|
||||
</div>
|
||||
<!-- En-tête du profil avec logo -->
|
||||
<div class="profile-section">
|
||||
<div class="profile-header">
|
||||
<div class="row align-items-center">
|
||||
<!-- Logo et informations -->
|
||||
<div class="col-md-8">
|
||||
<div class="d-flex align-items-center">
|
||||
<!-- ==================== LOGO DU MARCHAND ==================== -->
|
||||
<div class="merchant-logo-container me-4">
|
||||
@if (merchant.logo && merchant.logo.trim() !== '') {
|
||||
<img
|
||||
[src]="getMerchantLogoUrl(merchant.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">
|
||||
<ng-container *ngIf="merchant.logo;">
|
||||
<img
|
||||
[src]="getMerchantLogoUrl(merchant.logo) | async"
|
||||
[alt]="merchant.name"
|
||||
class="merchant-logo-large"
|
||||
/>
|
||||
</ng-container>
|
||||
</div>
|
||||
<!-- Informations du marchand -->
|
||||
<div class="flex-grow-1">
|
||||
<h2 class="mb-2 text-white">{{ merchant.name }}</h2>
|
||||
@if (merchant.description) {
|
||||
<p class="mb-0 text-white opacity-75">{{ merchant.description }}</p>
|
||||
} @else {
|
||||
<p class="mb-0 text-white opacity-50 fst-italic">Aucune description</p>
|
||||
}
|
||||
|
||||
<div class="col-md-4 text-md-end">
|
||||
@if (canEditMerchant()) {
|
||||
<button
|
||||
class="btn btn-light"
|
||||
(click)="editMerchant(merchant)"
|
||||
>
|
||||
<ng-icon name="lucideEdit" class="me-1"></ng-icon>
|
||||
Modifier le profil
|
||||
</button>
|
||||
}
|
||||
<!-- Informations de contact -->
|
||||
<div class="mt-3 d-flex flex-wrap gap-3">
|
||||
<div class="d-flex align-items-center text-white opacity-75">
|
||||
<ng-icon name="lucideMapPin" size="16" class="me-2"></ng-icon>
|
||||
<small>{{ merchant.adresse }}</small>
|
||||
</div>
|
||||
<div class="d-flex align-items-center text-white opacity-75">
|
||||
<ng-icon name="lucidePhone" size="16" class="me-2"></ng-icon>
|
||||
<small>{{ merchant.phone }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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-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-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>
|
||||
<!-- Actions -->
|
||||
<div class="col-md-4 text-md-end">
|
||||
@if (canEditMerchant()) {
|
||||
<button
|
||||
class="btn btn-light"
|
||||
(click)="editMerchant(merchant)"
|
||||
>
|
||||
<ng-icon name="lucideEdit" class="me-1"></ng-icon>
|
||||
Modifier le profil
|
||||
</button>
|
||||
}
|
||||
</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 -->
|
||||
<div class="row g-4">
|
||||
<!-- Configurations récentes -->
|
||||
|
||||
@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { NgIcon } from '@ng-icons/core';
|
||||
import { NgbAlertModule, NgbPaginationModule, NgbNavModule, NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { catchError, Observable, of, Subject, takeUntil, tap } from 'rxjs';
|
||||
import { catchError, map, Observable, of, Subject, takeUntil, tap } from 'rxjs';
|
||||
|
||||
import {
|
||||
Merchant,
|
||||
@ -296,28 +296,54 @@ export class MerchantConfigView implements OnInit, OnDestroy {
|
||||
/**
|
||||
* Récupère l'URL du logo avec fallback automatique
|
||||
*/
|
||||
getMerchantLogoUrl(logoFileName: string, merchantName?: string): Observable<string> {
|
||||
getMerchantLogoUrl(
|
||||
merchantId: number | undefined,
|
||||
logoFileName: string,
|
||||
merchantName: string
|
||||
): Observable<string> {
|
||||
|
||||
const newMerchantId = String(merchantId);
|
||||
|
||||
const cacheKey = `${merchantId}_${logoFileName}`;
|
||||
|
||||
// Vérifier si le logo est en cache d'erreur
|
||||
if (this.logoErrorCache.has(logoFileName)) {
|
||||
const defaultLogo = this.getDefaultLogoUrl(merchantName || logoFileName);
|
||||
if (this.logoErrorCache.has(cacheKey)) {
|
||||
const defaultLogo = this.getDefaultLogoUrl(merchantName);
|
||||
return of(defaultLogo);
|
||||
}
|
||||
|
||||
// Vérifier le cache normal
|
||||
if (this.logoUrlCache.has(logoFileName)) {
|
||||
return of(this.logoUrlCache.get(logoFileName)!);
|
||||
if (this.logoUrlCache.has(cacheKey)) {
|
||||
return of(this.logoUrlCache.get(cacheKey)!);
|
||||
}
|
||||
|
||||
// Récupérer l'URL depuis MinIO
|
||||
return this.minioService.getMerchantLogoUrl(logoFileName).pipe(
|
||||
tap(url => {
|
||||
// Mettre en cache
|
||||
this.logoUrlCache.set(logoFileName, url);
|
||||
// Récupérer l'URL depuis l'API avec la nouvelle structure
|
||||
return this.minioService.getMerchantLogoUrl(
|
||||
newMerchantId,
|
||||
logoFileName,
|
||||
{ signed: true, expirySeconds: 3600 }
|
||||
).pipe(
|
||||
map(response => {
|
||||
// Extraire l'URL de la réponse
|
||||
const url = response.data.url ;
|
||||
|
||||
// Mettre en cache avec la clé composite
|
||||
this.logoUrlCache.set(cacheKey, url);
|
||||
|
||||
return url;
|
||||
}),
|
||||
catchError(error => {
|
||||
// En cas d'erreur, ajouter au cache d'erreur et retourner le logo par défaut
|
||||
this.logoErrorCache.add(logoFileName);
|
||||
const defaultLogo = this.getDefaultLogoUrl(merchantName || logoFileName);
|
||||
console.warn(`⚠️ Logo not found for merchant ${merchantId}: ${logoFileName}`, error);
|
||||
|
||||
// En cas d'erreur, ajouter au cache d'erreur
|
||||
this.logoErrorCache.add(cacheKey);
|
||||
|
||||
// Générer un logo par défaut
|
||||
const defaultLogo = this.getDefaultLogoUrl(merchantName);
|
||||
|
||||
// Mettre le logo par défaut dans le cache normal aussi
|
||||
this.logoUrlCache.set(cacheKey, defaultLogo);
|
||||
|
||||
return of(defaultLogo);
|
||||
})
|
||||
);
|
||||
@ -327,46 +353,112 @@ export class MerchantConfigView implements OnInit, OnDestroy {
|
||||
* Génère une URL de logo par défaut basée sur les initiales
|
||||
*/
|
||||
getDefaultLogoUrl(merchantName: string): string {
|
||||
|
||||
// Créer des initiales significatives
|
||||
const initials = this.extractInitials(merchantName);
|
||||
|
||||
// Palette de couleurs agréables
|
||||
const colors = [
|
||||
'FF6B6B', '4ECDC4', '45B7D1', '96CEB4', 'FFEAA7',
|
||||
'DDA0DD', '98D8C8', 'F7DC6F', 'BB8FCE', '85C1E9'
|
||||
'667eea', // Violet
|
||||
'764ba2', // Violet foncé
|
||||
'f56565', // Rouge
|
||||
'4299e1', // Bleu
|
||||
'48bb78', // Vert
|
||||
'ed8936', // Orange
|
||||
'FF6B6B', // Rouge clair
|
||||
'4ECDC4', // Turquoise
|
||||
'45B7D1', // Bleu clair
|
||||
'96CEB4' // Vert menthe
|
||||
];
|
||||
|
||||
const colorIndex = merchantName.length % colors.length;
|
||||
const backgroundColor = colors[colorIndex];
|
||||
const textColor = 'FFFFFF'; // Blanc pour contraste
|
||||
|
||||
return `https://ui-avatars.com/api/?name=${encodeURIComponent(initials)}&background=${backgroundColor}&color=${textColor}&size=200&bold=true&font-size=0.5`;
|
||||
// Taille fixe à 80px (l'API génère un carré de cette taille)
|
||||
// L'image sera redimensionnée à 40px via CSS
|
||||
return `https://ui-avatars.com/api/?name=${encodeURIComponent(initials)}&background=${backgroundColor}&color=FFFFFF&size=80`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait les initiales de manière intelligente
|
||||
*/
|
||||
private extractInitials(name: string): string {
|
||||
if (!name || name.trim() === '') {
|
||||
return '??';
|
||||
}
|
||||
|
||||
// Nettoyer le nom
|
||||
const cleanedName = name.trim().toUpperCase();
|
||||
|
||||
// Extraire les mots (ignorer les articles, prépositions courtes)
|
||||
// Extraire les mots
|
||||
const words = cleanedName.split(/\s+/);
|
||||
|
||||
// Si un seul mot, prendre les deux premières lettres
|
||||
if (words.length === 1) {
|
||||
return words[0].substring(0, 2);
|
||||
return words[0].substring(0, 2) || '??';
|
||||
}
|
||||
|
||||
// Prendre la première lettre des deux premiers mots significatifs
|
||||
// Prendre la première lettre des deux premiers mots
|
||||
const initials = words
|
||||
.filter(word => word.length > 2) // Ignorer les mots courts
|
||||
.slice(0, 2) // Prendre maximum 2 mots
|
||||
.map(word => word[0])
|
||||
.slice(0, 2) // Prendre les 2 premiers mots
|
||||
.map(word => word[0] || '')
|
||||
.join('');
|
||||
|
||||
return initials || name.substring(0, 2).toUpperCase();
|
||||
return initials || name.substring(0, 2).toUpperCase() || '??';
|
||||
}
|
||||
|
||||
/**
|
||||
* Gère les erreurs de chargement des logos MinIO
|
||||
*/
|
||||
onLogoError(event: Event, merchantName: string): void {
|
||||
const img = event.target as HTMLImageElement;
|
||||
|
||||
if (!img) return;
|
||||
|
||||
console.warn('Logo MinIO failed to load, using default for:', merchantName);
|
||||
|
||||
img.onerror = null;
|
||||
img.src = this.getDefaultLogoUrl(merchantName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gère les erreurs de chargement des logos par défaut
|
||||
*/
|
||||
onDefaultLogoError(event: Event | string): void {
|
||||
if (!(event instanceof Event)) {
|
||||
console.error('Default logo error (non-event):', event);
|
||||
return;
|
||||
}
|
||||
|
||||
const img = event.target as HTMLImageElement | null;
|
||||
if (!img) return;
|
||||
|
||||
console.error('Default logo also failed to load, using fallback SVG');
|
||||
|
||||
// SVG local
|
||||
img.onerror = null; // éviter boucle infinie
|
||||
img.src = 'assets/images/default-merchant-logo.svg';
|
||||
|
||||
// Dernier recours
|
||||
img.onerror = (e) => {
|
||||
if (!(e instanceof Event)) return;
|
||||
const fallbackImg = e.target as HTMLImageElement | null;
|
||||
if (!fallbackImg) return;
|
||||
|
||||
fallbackImg.onerror = null;
|
||||
fallbackImg.src = this.generateFallbackDataUrl();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère un fallback SVG en data URL
|
||||
*/
|
||||
private generateFallbackDataUrl(): string {
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40">
|
||||
<rect width="40" height="40" fill="#667eea" rx="20"/>
|
||||
<text x="20" y="22" text-anchor="middle" fill="white" font-family="Arial" font-size="14" font-weight="bold">?</text>
|
||||
</svg>`;
|
||||
|
||||
return 'data:image/svg+xml;base64,' + btoa(svg);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -176,34 +176,37 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- ==================== MODAL CRÉATION - SECTION LOGO ==================== -->
|
||||
<!-- À ajouter dans votre modal de création de marchand -->
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="form-group logo-upload-section">
|
||||
<label class="form-label">
|
||||
<i class="fas fa-image"></i> Logo du marchand (optionnel)
|
||||
<ng-icon name="lucideImage" class="me-1"></ng-icon>
|
||||
Logo du marchand (optionnel)
|
||||
</label>
|
||||
|
||||
<div class="logo-upload-container">
|
||||
<!-- Zone de prévisualisation -->
|
||||
<div class="logo-preview-area" *ngIf="logoPreviewUrl || !selectedLogoFile">
|
||||
<div class="logo-preview" *ngIf="logoPreviewUrl">
|
||||
<img [src]="logoPreviewUrl" alt="Prévisualisation du logo">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-remove-preview"
|
||||
(click)="removeSelectedLogo()"
|
||||
title="Supprimer"
|
||||
>
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="logo-placeholder" *ngIf="!logoPreviewUrl">
|
||||
<i class="fas fa-image fa-3x"></i>
|
||||
<p>Aucun logo sélectionné</p>
|
||||
</div>
|
||||
<div class="logo-preview-area">
|
||||
@if (logoPreviewUrl) {
|
||||
<!-- Image sélectionnée -->
|
||||
<div class="logo-preview">
|
||||
<img [src]="logoPreviewUrl" alt="Prévisualisation du logo">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-remove-preview"
|
||||
(click)="removeSelectedLogo()"
|
||||
[disabled]="creatingMerchant || uploadingLogo"
|
||||
title="Supprimer"
|
||||
>
|
||||
<ng-icon name="lucideX"></ng-icon>
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<!-- Placeholder -->
|
||||
<div class="logo-placeholder">
|
||||
<ng-icon name="lucideImage" size="48" class="text-muted mb-2"></ng-icon>
|
||||
<p class="text-muted mb-0">Aucun logo sélectionné</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Bouton de sélection -->
|
||||
@ -211,33 +214,53 @@
|
||||
<input
|
||||
type="file"
|
||||
id="logoInput"
|
||||
accept="image/*"
|
||||
accept="image/jpeg,image/jpg,image/png,image/gif,image/webp,image/svg+xml"
|
||||
(change)="onLogoSelected($event)"
|
||||
[disabled]="creatingMerchant || uploadingLogo"
|
||||
style="display: none;"
|
||||
/>
|
||||
<label for="logoInput" class="btn btn-outline-primary btn-select-logo">
|
||||
<i class="fas fa-upload"></i>
|
||||
<label
|
||||
for="logoInput"
|
||||
class="btn btn-outline-primary btn-select-logo"
|
||||
[class.disabled]="creatingMerchant || uploadingLogo"
|
||||
>
|
||||
<ng-icon name="lucideUpload" class="me-1"></ng-icon>
|
||||
{{ logoPreviewUrl ? 'Changer le logo' : 'Sélectionner un logo' }}
|
||||
</label>
|
||||
|
||||
<small class="text-muted d-block mt-2">
|
||||
Formats: JPG, PNG, GIF, WebP, SVG | Taille max: 2MB
|
||||
<ng-icon name="lucideInfo" size="12" class="me-1"></ng-icon>
|
||||
Formats acceptés : JPG, PNG, GIF, WebP, SVG | Taille max : 5MB
|
||||
</small>
|
||||
|
||||
@if (selectedLogoFile) {
|
||||
<div class="mt-2">
|
||||
<small class="badge bg-info">
|
||||
<ng-icon name="lucideFile" size="12" class="me-1"></ng-icon>
|
||||
{{ selectedLogoFile.name }} ({{ formatFileSize(selectedLogoFile.size) }})
|
||||
</small>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Erreur upload logo -->
|
||||
<div class="alert alert-danger mt-2" *ngIf="logoUploadError">
|
||||
<i class="fas fa-exclamation-circle"></i> {{ logoUploadError }}
|
||||
</div>
|
||||
@if (logoUploadError) {
|
||||
<div class="alert alert-danger mt-2">
|
||||
<ng-icon name="lucideAlertCircle" class="me-1"></ng-icon>
|
||||
{{ logoUploadError }}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Indicateur d'upload -->
|
||||
<div class="upload-progress" *ngIf="uploadingLogo">
|
||||
<div class="spinner-border spinner-border-sm" role="status">
|
||||
<span class="sr-only">Upload en cours...</span>
|
||||
@if (uploadingLogo) {
|
||||
<div class="upload-progress">
|
||||
<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>
|
||||
<span class="ml-2">Upload du logo en cours...</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -572,37 +595,41 @@
|
||||
<div class="col-md-6">
|
||||
<div class="form-group logo-edit-section">
|
||||
<label class="form-label">
|
||||
<i class="fas fa-image"></i> Logo du marchand
|
||||
<ng-icon name="lucideImage" class="me-1"></ng-icon>
|
||||
Logo du marchand
|
||||
</label>
|
||||
|
||||
<div class="logo-edit-container">
|
||||
<!-- Logo actuel ou nouveau -->
|
||||
<div class="logo-display-area">
|
||||
<!-- Nouveau logo sélectionné -->
|
||||
<div class="logo-preview" *ngIf="editLogoPreviewUrl">
|
||||
<img [src]="editLogoPreviewUrl" alt="Nouveau logo">
|
||||
<div class="logo-badge badge-new">Nouveau</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-remove-preview"
|
||||
(click)="cancelEditLogo()"
|
||||
title="Annuler"
|
||||
>
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Logo actuel -->
|
||||
<div class="logo-preview" *ngIf="!editLogoPreviewUrl && currentLogoUrl">
|
||||
<img [src]="currentLogoUrl" alt="Logo actuel">
|
||||
<div class="logo-badge badge-current">Actuel</div>
|
||||
</div>
|
||||
|
||||
<!-- Pas de logo -->
|
||||
<div class="logo-placeholder" *ngIf="!editLogoPreviewUrl && !currentLogoUrl">
|
||||
<i class="fas fa-image fa-3x"></i>
|
||||
<p>Aucun logo</p>
|
||||
</div>
|
||||
@if (editLogoPreviewUrl) {
|
||||
<!-- Nouveau logo sélectionné -->
|
||||
<div class="logo-preview">
|
||||
<img [src]="editLogoPreviewUrl" alt="Nouveau logo">
|
||||
<div class="logo-badge badge-new">Nouveau</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-remove-preview"
|
||||
(click)="cancelEditLogo()"
|
||||
[disabled]="updatingMerchant || uploadingLogo"
|
||||
title="Annuler"
|
||||
>
|
||||
<ng-icon name="lucideX"></ng-icon>
|
||||
</button>
|
||||
</div>
|
||||
} @else if (currentLogoUrl) {
|
||||
<!-- Logo actuel -->
|
||||
<div class="logo-preview">
|
||||
<img [src]="currentLogoUrl" alt="Logo actuel">
|
||||
<div class="logo-badge badge-current">Actuel</div>
|
||||
</div>
|
||||
} @else {
|
||||
<!-- Pas de logo -->
|
||||
<div class="logo-placeholder">
|
||||
<ng-icon name="lucideImage" size="48" class="text-muted mb-2"></ng-icon>
|
||||
<p class="text-muted mb-0">Aucun logo</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
@ -610,38 +637,57 @@
|
||||
<input
|
||||
type="file"
|
||||
id="editLogoInput"
|
||||
accept="image/*"
|
||||
accept="image/jpeg,image/jpg,image/png,image/gif,image/webp,image/svg+xml"
|
||||
(change)="onEditLogoSelected($event)"
|
||||
[disabled]="updatingMerchant || uploadingLogo"
|
||||
style="display: none;"
|
||||
/>
|
||||
<label for="editLogoInput" class="btn btn-outline-primary btn-change-logo">
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
<label
|
||||
for="editLogoInput"
|
||||
class="btn btn-outline-primary btn-change-logo"
|
||||
[class.disabled]="updatingMerchant || uploadingLogo"
|
||||
>
|
||||
<ng-icon name="lucideRefreshCw" class="me-1"></ng-icon>
|
||||
{{ currentLogoUrl ? 'Changer le logo' : 'Ajouter un logo' }}
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-danger btn-remove-logo"
|
||||
*ngIf="currentLogoUrl && !editLogoPreviewUrl"
|
||||
(click)="selectedMerchantForEdit!.logo = ''; currentLogoUrl = null"
|
||||
>
|
||||
<i class="fas fa-trash"></i>
|
||||
Supprimer le logo
|
||||
</button>
|
||||
@if (currentLogoUrl && !editLogoPreviewUrl) {
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-danger btn-remove-logo"
|
||||
(click)="selectedMerchantForEdit!.logo = ''; currentLogoUrl = null"
|
||||
[disabled]="updatingMerchant"
|
||||
>
|
||||
<ng-icon name="lucideTrash2" class="me-1"></ng-icon>
|
||||
Supprimer le logo
|
||||
</button>
|
||||
}
|
||||
|
||||
<small class="text-muted d-block mt-2">
|
||||
Formats: JPG, PNG, GIF, WebP, SVG | Taille max: 2MB
|
||||
<ng-icon name="lucideInfo" size="12" class="me-1"></ng-icon>
|
||||
Formats : JPG, PNG, GIF, WebP, SVG | Max : 5MB
|
||||
</small>
|
||||
|
||||
@if (editLogoFile) {
|
||||
<div class="mt-2">
|
||||
<small class="badge bg-info">
|
||||
<ng-icon name="lucideFile" size="12" class="me-1"></ng-icon>
|
||||
{{ editLogoFile.name }} ({{ formatFileSize(editLogoFile.size) }})
|
||||
</small>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Indicateur d'upload -->
|
||||
<div class="upload-progress" *ngIf="uploadingLogo">
|
||||
<div class="spinner-border spinner-border-sm" role="status">
|
||||
<span class="sr-only">Upload en cours...</span>
|
||||
@if (uploadingLogo) {
|
||||
<div class="upload-progress">
|
||||
<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>
|
||||
<span class="ml-2">Upload du nouveau logo en cours...</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common';
|
||||
import { FormsModule, ReactiveFormsModule, FormBuilder, Validators, FormArray, FormGroup } from '@angular/forms';
|
||||
import { NgIcon } from '@ng-icons/core';
|
||||
import { NgbNavModule, NgbModal, NgbModalModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { catchError, finalize, map, Observable, of, Subject, takeUntil, tap } from 'rxjs';
|
||||
import { catchError, finalize, map, Observable, of, Subject, switchMap, takeUntil, tap } from 'rxjs';
|
||||
import { DomSanitizer, SafeUrl } from '@angular/platform-browser';
|
||||
|
||||
import { MerchantConfigService } from './merchant-config.service';
|
||||
@ -60,6 +60,9 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
|
||||
|
||||
// Cache des URLs de logos
|
||||
private logoUrlCache = new Map<string, string>();
|
||||
// Ajouter un cache pour les logos non trouvés
|
||||
private logoErrorCache = new Set<string>();
|
||||
|
||||
|
||||
// Configuration
|
||||
readonly UserRole = UserRole;
|
||||
@ -386,8 +389,8 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
// Conversion pour la mise à jour
|
||||
private convertUpdateMerchantToBackend(dto: UpdateMerchantDto, existingMerchant?: Merchant): any {
|
||||
return this.dataAdapter.convertUpdateMerchantToApi(dto, existingMerchant);
|
||||
private convertUpdateMerchantToBackend(dto: UpdateMerchantDto): any {
|
||||
return this.dataAdapter.convertUpdateMerchantToApi(dto);
|
||||
}
|
||||
|
||||
// ==================== GESTION DES PERMISSIONS ====================
|
||||
@ -552,28 +555,24 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
|
||||
return this.canManageMerchants;
|
||||
}
|
||||
|
||||
private loadMerchantProfile(merchantId: number): void {
|
||||
if (this.loadingMerchants[merchantId]) return;
|
||||
|
||||
this.loadingMerchants[merchantId] = true;
|
||||
|
||||
this.merchantConfigService.getMerchantById(merchantId).subscribe({
|
||||
next: (merchant) => {
|
||||
// Conversion pour Angular
|
||||
const frontendMerchant = this.convertMerchantToFrontend(merchant);
|
||||
this.merchantProfiles[merchantId] = frontendMerchant;
|
||||
this.loadingMerchants[merchantId] = false;
|
||||
},
|
||||
error: (error) => {
|
||||
console.error(`Error loading merchant profile ${merchantId}:`, error);
|
||||
this.loadingMerchants[merchantId] = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
backToList(): void {
|
||||
console.log('🔙 Returning to list view');
|
||||
this.showTab('list');
|
||||
|
||||
// Réinitialiser les IDs sélectionnés
|
||||
this.selectedMerchantId = null;
|
||||
this.selectedConfigId = null;
|
||||
this.selectedUserId = null;
|
||||
|
||||
// Vider le cache du profil actuel
|
||||
if (this.selectedMerchantId) {
|
||||
delete this.merchantProfiles[this.selectedMerchantId];
|
||||
}
|
||||
|
||||
// Changer d'onglet
|
||||
this.activeTab = 'list';
|
||||
|
||||
// Forcer la détection des changements
|
||||
this.cdRef.detectChanges();
|
||||
}
|
||||
|
||||
|
||||
@ -678,30 +677,6 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
createMerchant(): void {
|
||||
this.createMerchantWithLogo();
|
||||
}
|
||||
|
||||
updateMerchant(): void {
|
||||
this.updateMerchantWithLogo();
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère une URL de logo par défaut basée sur les initiales
|
||||
*/
|
||||
getDefaultLogoUrl(merchantName: string): string {
|
||||
// Créer un avatar avec les initiales
|
||||
const initials = merchantName
|
||||
.split(' ')
|
||||
.map(word => word[0])
|
||||
.join('')
|
||||
.substring(0, 2)
|
||||
.toUpperCase();
|
||||
|
||||
// Utiliser un service comme UI Avatars
|
||||
return `https://ui-avatars.com/api/?name=${encodeURIComponent(initials)}&background=random&size=200&bold=true`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formate la taille du fichier
|
||||
*/
|
||||
@ -737,7 +712,6 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
|
||||
this.selectedLogoFile = null;
|
||||
this.editLogoFile = null;
|
||||
}
|
||||
|
||||
// ==================== OPÉRATIONS CRUD ====================
|
||||
|
||||
// ==================== GESTION DU LOGO LORS DE LA CRÉATION ====================
|
||||
@ -794,9 +768,10 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload le logo et crée le marchand
|
||||
* Appel API pour créer le marchand
|
||||
*/
|
||||
createMerchantWithLogo(): void {
|
||||
createMerchant(): void {
|
||||
|
||||
if (!this.canCreateMerchants) {
|
||||
this.createMerchantError = 'Vous n\'avez pas la permission de créer des marchands';
|
||||
return;
|
||||
@ -811,41 +786,6 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
|
||||
this.creatingMerchant = true;
|
||||
this.createMerchantError = '';
|
||||
|
||||
// Si un logo est sélectionné, l'uploader d'abord
|
||||
if (this.selectedLogoFile) {
|
||||
|
||||
this.uploadingLogo = true;
|
||||
this.minioService.uploadMerchantLogo(this.selectedLogoFile)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (uploadResponse) => {
|
||||
console.log('✅ Logo uploaded:', uploadResponse);
|
||||
this.uploadingLogo = false;
|
||||
|
||||
// Ajouter le nom du fichier au DTO
|
||||
this.newMerchant.logo = uploadResponse.fileName;
|
||||
|
||||
// Créer le marchand avec le logo
|
||||
this.createMerchantApiCall();
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('❌ Error uploading logo:', error);
|
||||
this.uploadingLogo = false;
|
||||
this.creatingMerchant = false;
|
||||
this.createMerchantError = 'Erreur lors de l\'upload du logo: ' + (error.message || 'Erreur inconnue');
|
||||
this.cdRef.detectChanges();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Pas de logo, créer directement
|
||||
this.createMerchantApiCall();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Appel API pour créer le marchand
|
||||
*/
|
||||
private createMerchantApiCall(): void {
|
||||
const createDto = this.convertMerchantToBackend(this.newMerchant);
|
||||
|
||||
console.log('📤 Creating merchant:', createDto);
|
||||
@ -854,8 +794,29 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (createdMerchant) => {
|
||||
|
||||
const frontendMerchant = this.convertMerchantToFrontend(createdMerchant);
|
||||
|
||||
// Si un logo est sélectionné, l'uploader d'abord
|
||||
if (this.selectedLogoFile) {
|
||||
this.uploadingLogo = true;
|
||||
|
||||
this.minioService.uploadMerchantLogo(Number(frontendMerchant.id), frontendMerchant.name, this.selectedLogoFile)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (uploadResponse) => {
|
||||
console.log('✅ Logo uploaded:', uploadResponse);
|
||||
this.uploadingLogo = false;
|
||||
|
||||
this.newMerchant.logo = uploadResponse.data.fileName;
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('❌ Error uploading logo:', error);
|
||||
this.uploadingLogo = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
console.log('✅ Merchant created successfully:', frontendMerchant);
|
||||
this.creatingMerchant = false;
|
||||
this.modalService.dismissAll();
|
||||
@ -931,9 +892,9 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour le marchand avec le nouveau logo
|
||||
* Appel API pour mettre à jour le marchand (version avec switchMap)
|
||||
*/
|
||||
updateMerchantWithLogo(): void {
|
||||
updateMerchant(): void {
|
||||
if (!this.selectedMerchantForEdit) {
|
||||
this.updateMerchantError = 'Aucun marchand sélectionné pour modification';
|
||||
return;
|
||||
@ -948,120 +909,265 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
|
||||
this.updatingMerchant = true;
|
||||
this.updateMerchantError = '';
|
||||
|
||||
const merchantId = this.selectedMerchantForEdit!.id!;
|
||||
const merchantName = this.selectedMerchantForEdit.name!;
|
||||
|
||||
let uploadObservable$;
|
||||
|
||||
// Si un nouveau logo est sélectionné
|
||||
if (this.editLogoFile && this.logoChanged) {
|
||||
this.uploadingLogo = true;
|
||||
const merchantId = this.selectedMerchantForEdit.id!;
|
||||
|
||||
this.minioService.uploadMerchantLogo(this.editLogoFile, merchantId)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (uploadResponse) => {
|
||||
console.log('✅ New logo uploaded:', uploadResponse);
|
||||
this.uploadingLogo = false;
|
||||
uploadObservable$ = this.minioService.uploadMerchantLogo(
|
||||
merchantId,
|
||||
merchantName,
|
||||
this.editLogoFile
|
||||
).pipe(
|
||||
switchMap(uploadResponse => {
|
||||
console.log('✅ New logo uploaded:', uploadResponse);
|
||||
this.uploadingLogo = false;
|
||||
|
||||
// Supprimer l'ancien logo si différent
|
||||
const oldLogo = this.selectedMerchantForEdit!.logo;
|
||||
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 nom du logo dans l'objet merchant
|
||||
this.selectedMerchantForEdit!.logo = uploadResponse.data.fileName;
|
||||
|
||||
// Mettre à jour le logo dans le DTO
|
||||
this.selectedMerchantForEdit!.logo = uploadResponse.fileName;
|
||||
|
||||
// Mettre à jour le marchand
|
||||
this.updateMerchantApiCall();
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('❌ Error uploading new logo:', error);
|
||||
this.uploadingLogo = false;
|
||||
this.updatingMerchant = false;
|
||||
this.updateMerchantError = 'Erreur lors de l\'upload du logo: ' + (error.message || 'Erreur inconnue');
|
||||
this.cdRef.detectChanges();
|
||||
// Supprimer l'ancien logo si différent
|
||||
const oldLogo = this.selectedMerchantForEdit!.logo;
|
||||
if (oldLogo && oldLogo !== uploadResponse.data.fileName) {
|
||||
this.minioService.deleteMerchantLogo(oldLogo).subscribe({
|
||||
next: () => console.log('🗑️ Old logo deleted'),
|
||||
error: (err) => console.warn('⚠️ Could not delete old logo:', err)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
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 {
|
||||
// Pas de changement de logo, mettre à jour directement
|
||||
this.updateMerchantApiCall();
|
||||
// Pas de nouveau logo, mettre à jour directement
|
||||
const updateDto = this.convertUpdateMerchantToBackend(
|
||||
this.selectedMerchantForEdit!
|
||||
);
|
||||
|
||||
uploadObservable$ = this.merchantConfigService.updateMerchant(merchantId, updateDto);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Appel API pour mettre à jour le marchand
|
||||
*/
|
||||
private updateMerchantApiCall(): void {
|
||||
const merchantId = this.selectedMerchantForEdit!.id!;
|
||||
const updateDto = this.convertUpdateMerchantToBackend(
|
||||
this.selectedMerchantForEdit!,
|
||||
this.selectedMerchantForEdit!
|
||||
);
|
||||
uploadObservable$.pipe(
|
||||
takeUntil(this.destroy$)
|
||||
).subscribe({
|
||||
next: (updatedMerchant) => {
|
||||
const frontendMerchant = this.convertMerchantToFrontend(updatedMerchant);
|
||||
|
||||
console.log('📤 Updating merchant with full data:', updateDto);
|
||||
this.updatingMerchant = false;
|
||||
this.modalService.dismissAll();
|
||||
this.refreshMerchantsConfigsView();
|
||||
this.refreshMerchantsList();
|
||||
|
||||
this.merchantConfigService.updateMerchant(merchantId, updateDto)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (updatedMerchant) => {
|
||||
const frontendMerchant = this.convertMerchantToFrontend(updatedMerchant);
|
||||
// Mettre à jour le cache
|
||||
if (merchantId) {
|
||||
this.merchantProfiles[merchantId] = frontendMerchant;
|
||||
|
||||
this.updatingMerchant = false;
|
||||
this.modalService.dismissAll();
|
||||
this.refreshMerchantsConfigsView();
|
||||
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);
|
||||
}
|
||||
// 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 ====================
|
||||
|
||||
/**
|
||||
* Récupère l'URL du logo pour affichage
|
||||
* Récupère l'URL du logo avec fallback automatique
|
||||
*/
|
||||
getMerchantLogoUrl(logoFileName: string): Observable<string> {
|
||||
// Vérifier le cache
|
||||
if (this.logoUrlCache.has(logoFileName)) {
|
||||
return of(this.logoUrlCache.get(logoFileName)!);
|
||||
getMerchantLogoUrl(
|
||||
merchantId: string,
|
||||
merchantName: string,
|
||||
logoFileName: string,
|
||||
): Observable<string> {
|
||||
|
||||
const cacheKey = `${merchantId}_${logoFileName}`;
|
||||
|
||||
// Vérifier si le logo est en cache d'erreur
|
||||
if (this.logoErrorCache.has(cacheKey)) {
|
||||
const defaultLogo = this.getDefaultLogoUrl(merchantName);
|
||||
return of(defaultLogo);
|
||||
}
|
||||
|
||||
// Récupérer l'URL depuis MinIO
|
||||
return this.minioService.getMerchantLogoUrl(logoFileName).pipe(
|
||||
tap(url => {
|
||||
// Mettre en cache
|
||||
this.logoUrlCache.set(logoFileName, url);
|
||||
// Vérifier le cache normal
|
||||
if (this.logoUrlCache.has(cacheKey)) {
|
||||
return of(this.logoUrlCache.get(cacheKey)!);
|
||||
}
|
||||
|
||||
// Récupérer l'URL depuis l'API avec la nouvelle structure
|
||||
return this.minioService.getMerchantLogoUrl(
|
||||
merchantId,
|
||||
logoFileName,
|
||||
{ signed: true, expirySeconds: 3600 }
|
||||
).pipe(
|
||||
map(response => {
|
||||
// Extraire l'URL de la réponse
|
||||
const url = response.data.url ;
|
||||
|
||||
// Mettre en cache avec la clé composite
|
||||
this.logoUrlCache.set(cacheKey, url);
|
||||
|
||||
return url;
|
||||
}),
|
||||
catchError(error => {
|
||||
console.warn(`⚠️ Logo not found for merchant ${merchantId}: ${logoFileName}`, error);
|
||||
|
||||
// En cas d'erreur, ajouter au cache d'erreur
|
||||
this.logoErrorCache.add(cacheKey);
|
||||
|
||||
// Générer un logo par défaut
|
||||
const defaultLogo = this.getDefaultLogoUrl(merchantName);
|
||||
|
||||
// Mettre le logo par défaut dans le cache normal aussi
|
||||
this.logoUrlCache.set(cacheKey, defaultLogo);
|
||||
|
||||
return of(defaultLogo);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère une URL de logo par défaut basée sur les initiales
|
||||
*/
|
||||
getDefaultLogoUrl(merchantName: string): string {
|
||||
// Créer des initiales significatives
|
||||
const initials = this.extractInitials(merchantName);
|
||||
|
||||
// Palette de couleurs agréables
|
||||
const colors = [
|
||||
'667eea', // Violet
|
||||
'764ba2', // Violet foncé
|
||||
'f56565', // Rouge
|
||||
'4299e1', // Bleu
|
||||
'48bb78', // Vert
|
||||
'ed8936', // Orange
|
||||
'FF6B6B', // Rouge clair
|
||||
'4ECDC4', // Turquoise
|
||||
'45B7D1', // Bleu clair
|
||||
'96CEB4' // Vert menthe
|
||||
];
|
||||
|
||||
const colorIndex = merchantName.length % colors.length;
|
||||
const backgroundColor = colors[colorIndex];
|
||||
|
||||
// Taille fixe à 80px (l'API génère un carré de cette taille)
|
||||
// L'image sera redimensionnée à 40px via CSS
|
||||
return `https://ui-avatars.com/api/?name=${encodeURIComponent(initials)}&background=${backgroundColor}&color=FFFFFF&size=80`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait les initiales de manière intelligente
|
||||
*/
|
||||
private extractInitials(name: string): string {
|
||||
if (!name || name.trim() === '') {
|
||||
return '??';
|
||||
}
|
||||
|
||||
// Nettoyer le nom
|
||||
const cleanedName = name.trim().toUpperCase();
|
||||
|
||||
// Extraire les mots
|
||||
const words = cleanedName.split(/\s+/);
|
||||
|
||||
// Si un seul mot, prendre les deux premières lettres
|
||||
if (words.length === 1) {
|
||||
return words[0].substring(0, 2) || '??';
|
||||
}
|
||||
|
||||
// Prendre la première lettre des deux premiers mots
|
||||
const initials = words
|
||||
.slice(0, 2) // Prendre les 2 premiers mots
|
||||
.map(word => word[0] || '')
|
||||
.join('');
|
||||
|
||||
return initials || name.substring(0, 2).toUpperCase() || '??';
|
||||
}
|
||||
|
||||
/**
|
||||
* Gère les erreurs de chargement des logos MinIO
|
||||
*/
|
||||
onLogoError(event: Event, merchantName: string): void {
|
||||
const img = event.target as HTMLImageElement;
|
||||
|
||||
if (!img) return;
|
||||
|
||||
console.warn('Logo MinIO failed to load, using default for:', merchantName);
|
||||
|
||||
img.onerror = null;
|
||||
img.src = this.getDefaultLogoUrl(merchantName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gère les erreurs de chargement des logos par défaut
|
||||
*/
|
||||
onDefaultLogoError(event: Event | string): void {
|
||||
if (!(event instanceof Event)) {
|
||||
console.error('Default logo error (non-event):', event);
|
||||
return;
|
||||
}
|
||||
|
||||
const img = event.target as HTMLImageElement | null;
|
||||
if (!img) return;
|
||||
|
||||
console.error('Default logo also failed to load, using fallback SVG');
|
||||
|
||||
// SVG local
|
||||
img.onerror = null; // éviter boucle infinie
|
||||
img.src = 'assets/images/default-merchant-logo.svg';
|
||||
|
||||
// Dernier recours
|
||||
img.onerror = (e) => {
|
||||
if (!(e instanceof Event)) return;
|
||||
const fallbackImg = e.target as HTMLImageElement | null;
|
||||
if (!fallbackImg) return;
|
||||
|
||||
fallbackImg.onerror = null;
|
||||
fallbackImg.src = this.generateFallbackDataUrl();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère un fallback SVG en data URL
|
||||
*/
|
||||
private generateFallbackDataUrl(): string {
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40">
|
||||
<rect width="40" height="40" fill="#667eea" rx="20"/>
|
||||
<text x="20" y="22" text-anchor="middle" fill="white" font-family="Arial" font-size="14" font-weight="bold">?</text>
|
||||
</svg>`;
|
||||
|
||||
return 'data:image/svg+xml;base64,' + btoa(svg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge le logo pour l'édition
|
||||
*/
|
||||
@ -1071,13 +1177,13 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
this.getMerchantLogoUrl(merchant.logo).subscribe({
|
||||
this.getMerchantLogoUrl(String(merchant.id), merchant.name, merchant.logo).subscribe({
|
||||
next: (url) => {
|
||||
this.currentLogoUrl = url;
|
||||
this.cdRef.detectChanges();
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('Error loading logo:', error);
|
||||
console.error('Error loading logo for edit:', error);
|
||||
this.currentLogoUrl = null;
|
||||
}
|
||||
});
|
||||
|
||||
@ -105,7 +105,7 @@ export class MerchantDataAdapter {
|
||||
/**
|
||||
* Convertit un DTO de mise à jour pour l'API
|
||||
*/
|
||||
convertUpdateMerchantToApi(dto: UpdateMerchantDto, existingMerchant?: Merchant): any {
|
||||
convertUpdateMerchantToApi(dto: UpdateMerchantDto): any {
|
||||
this.validateUpdateMerchantDto(dto);
|
||||
|
||||
const updateData: any = {};
|
||||
|
||||
1
src/assets/images/01.svg
Normal file
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