fix(storefront): add missing placeholder image asset and onerror fallback
getMainImage() referenced /assets/images/placeholder.svg as the no-image fallback, but src/assets/images/ never existed - any item with zero photos rendered a browser broken-image icon instead of a placeholder. Added the asset. Also added an (error) handler (onImageError) on every dynamic <img> that renders a user/admin-supplied URL (product card, cart line item, cart payment QR code, product gallery main + thumbnails) so a 404'd/broken image URL swaps to the shared placeholder instead of shipping broken.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
<article class="product-card" [class.product-card-compact]="appearance === 'compact'" (mouseenter)="preview.emit(item.itemID)">
|
||||
<a [routerLink]="['/product', item.itemID] | langRoute" class="product-link" (click)="onSelected($event)">
|
||||
<div class="product-image">
|
||||
<img [src]="getMainImage(item)" [alt]="title || item.name" loading="lazy" decoding="async" width="300" height="300" />
|
||||
<img [src]="getMainImage(item)" [alt]="title || item.name" loading="lazy" decoding="async" width="300" height="300" (error)="onImageError($event)" />
|
||||
@if (showDiscountBadge && item.discount > 0) {
|
||||
<div class="discount-badge" [attr.aria-label]="'common.discountLabel' | translate:{ value: item.discount }">-{{ item.discount }}%</div>
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { RouterLink } from '@angular/router';
|
||||
import { Product } from '../../core/products/models/product-domain.model';
|
||||
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
|
||||
import { TranslatePipe } from '../../i18n/translate.pipe';
|
||||
import { cleanDescription, getBadgeClass, getDiscountedPrice, getMainImage } from '../../utils/item.utils';
|
||||
import { cleanDescription, getBadgeClass, getDiscountedPrice, getMainImage, onImageError } from '../../utils/item.utils';
|
||||
|
||||
const STOCK_LABEL_KEYS: Record<string, string> = {
|
||||
high: 'catalog.stockHigh',
|
||||
@@ -56,6 +56,7 @@ export class ProductCardComponent {
|
||||
readonly getDiscountedPrice = getDiscountedPrice;
|
||||
readonly getBadgeClass = getBadgeClass;
|
||||
readonly cleanDescription = cleanDescription;
|
||||
readonly onImageError = onImageError;
|
||||
|
||||
onAddToCart(event: Event): void {
|
||||
this.addToCart.emit({ itemID: this.item.itemID, event });
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
}
|
||||
|
||||
@default {
|
||||
<img [src]="media[selectedIndex].url" [alt]="media[selectedIndex].alt" loading="eager" decoding="async" />
|
||||
<img [src]="media[selectedIndex].url" [alt]="media[selectedIndex].alt" loading="eager" decoding="async" (error)="onImageError($event)" />
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@@ -45,7 +45,7 @@
|
||||
} @else if (item.type !== 'image') {
|
||||
<span class="product-gallery-doc">{{ item.type }}</span>
|
||||
}
|
||||
<img [src]="item.thumbnailUrl" [alt]="item.alt + ' ' + ($index + 1)" loading="lazy" decoding="async" />
|
||||
<img [src]="item.thumbnailUrl" [alt]="item.alt + ' ' + ($index + 1)" loading="lazy" decoding="async" (error)="onImageError($event)" />
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { Product } from '../../../../../core/products/models/product-domain.model';
|
||||
import { getMainImage } from '../../../../../utils/item.utils';
|
||||
import { getMainImage, onImageError } from '../../../../../utils/item.utils';
|
||||
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
||||
|
||||
type ProductMediaType = 'image' | 'video' | 'pdf' | 'manual' | 'warranty';
|
||||
@@ -22,6 +22,8 @@ interface GalleryMediaItem {
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ProductGalleryComponent {
|
||||
readonly onImageError = onImageError;
|
||||
|
||||
@Input({ required: true }) product!: Product;
|
||||
@Input() selectedIndex = 0;
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
(touchstart)="onSwipeStart(item.itemID, $event)">
|
||||
<div class="cart-item">
|
||||
<a [routerLink]="['/product', item.itemID] | langRoute" class="item-image">
|
||||
<img [src]="getMainImage(item)" [alt]="itemName(item)" loading="lazy" />
|
||||
<img [src]="getMainImage(item)" [alt]="itemName(item)" loading="lazy" (error)="onImageError($event)" />
|
||||
</a>
|
||||
|
||||
<div class="item-info">
|
||||
@@ -216,7 +216,7 @@
|
||||
@if (qrCodeUrl()) {
|
||||
<div class="qr-section">
|
||||
<div class="qr-wrapper">
|
||||
<img [src]="qrCodeUrl()" [attr.alt]="'cart.paymentQrAlt' | translate" class="qr-code" />
|
||||
<img [src]="qrCodeUrl()" [attr.alt]="'cart.paymentQrAlt' | translate" class="qr-code" (error)="onImageError($event)" />
|
||||
<div class="scan-line"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { EMPTY, interval, of, Subscription } from 'rxjs';
|
||||
import { catchError, exhaustMap, take, timeout } from 'rxjs/operators';
|
||||
import { DeliverySelectorComponent } from '../../components/delivery-selector/delivery-selector.component';
|
||||
import { TelegramLoginComponent } from '../../components/telegram-login/telegram-login.component';
|
||||
import { getDiscountedPrice, getMainImage, trackByItemId, getBadgeClass, getTranslatedField } from '../../utils/item.utils';
|
||||
import { getDiscountedPrice, getMainImage, trackByItemId, getBadgeClass, getTranslatedField, onImageError } from '../../utils/item.utils';
|
||||
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
|
||||
import { TranslatePipe } from '../../i18n/translate.pipe';
|
||||
import { TranslateService } from '../../i18n/translate.service';
|
||||
@@ -255,6 +255,7 @@ export class CartComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
readonly getMainImage = getMainImage;
|
||||
readonly onImageError = onImageError;
|
||||
readonly trackByItemId = trackByItemId;
|
||||
readonly getDiscountedPrice = getDiscountedPrice;
|
||||
readonly getBadgeClass = getBadgeClass;
|
||||
|
||||
@@ -13,6 +13,15 @@ export function getMainImage(item: Item): string {
|
||||
return item.photos?.[0]?.url || '/assets/images/placeholder.svg';
|
||||
}
|
||||
|
||||
const PLACEHOLDER_IMAGE = '/assets/images/placeholder.svg';
|
||||
|
||||
/** Swaps a broken/404'd <img> src to the shared placeholder, once, to avoid an infinite error loop. */
|
||||
export function onImageError(event: Event): void {
|
||||
const img = event.target as HTMLImageElement;
|
||||
if (img.src.endsWith(PLACEHOLDER_IMAGE)) return;
|
||||
img.src = PLACEHOLDER_IMAGE;
|
||||
}
|
||||
|
||||
export function trackByItemId(_index: number, item: Item): number | string {
|
||||
return item.id || item.itemID;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user