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)">
|
<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)">
|
<a [routerLink]="['/product', item.itemID] | langRoute" class="product-link" (click)="onSelected($event)">
|
||||||
<div class="product-image">
|
<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) {
|
@if (showDiscountBadge && item.discount > 0) {
|
||||||
<div class="discount-badge" [attr.aria-label]="'common.discountLabel' | translate:{ value: item.discount }">-{{ item.discount }}%</div>
|
<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 { Product } from '../../core/products/models/product-domain.model';
|
||||||
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
|
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
|
||||||
import { TranslatePipe } from '../../i18n/translate.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> = {
|
const STOCK_LABEL_KEYS: Record<string, string> = {
|
||||||
high: 'catalog.stockHigh',
|
high: 'catalog.stockHigh',
|
||||||
@@ -56,6 +56,7 @@ export class ProductCardComponent {
|
|||||||
readonly getDiscountedPrice = getDiscountedPrice;
|
readonly getDiscountedPrice = getDiscountedPrice;
|
||||||
readonly getBadgeClass = getBadgeClass;
|
readonly getBadgeClass = getBadgeClass;
|
||||||
readonly cleanDescription = cleanDescription;
|
readonly cleanDescription = cleanDescription;
|
||||||
|
readonly onImageError = onImageError;
|
||||||
|
|
||||||
onAddToCart(event: Event): void {
|
onAddToCart(event: Event): void {
|
||||||
this.addToCart.emit({ itemID: this.item.itemID, event });
|
this.addToCart.emit({ itemID: this.item.itemID, event });
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@default {
|
@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>
|
</div>
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
} @else if (item.type !== 'image') {
|
} @else if (item.type !== 'image') {
|
||||||
<span class="product-gallery-doc">{{ item.type }}</span>
|
<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>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
|
||||||
import { Product } from '../../../../../core/products/models/product-domain.model';
|
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';
|
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
||||||
|
|
||||||
type ProductMediaType = 'image' | 'video' | 'pdf' | 'manual' | 'warranty';
|
type ProductMediaType = 'image' | 'video' | 'pdf' | 'manual' | 'warranty';
|
||||||
@@ -22,6 +22,8 @@ interface GalleryMediaItem {
|
|||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
})
|
})
|
||||||
export class ProductGalleryComponent {
|
export class ProductGalleryComponent {
|
||||||
|
readonly onImageError = onImageError;
|
||||||
|
|
||||||
@Input({ required: true }) product!: Product;
|
@Input({ required: true }) product!: Product;
|
||||||
@Input() selectedIndex = 0;
|
@Input() selectedIndex = 0;
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
(touchstart)="onSwipeStart(item.itemID, $event)">
|
(touchstart)="onSwipeStart(item.itemID, $event)">
|
||||||
<div class="cart-item">
|
<div class="cart-item">
|
||||||
<a [routerLink]="['/product', item.itemID] | langRoute" class="item-image">
|
<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>
|
</a>
|
||||||
|
|
||||||
<div class="item-info">
|
<div class="item-info">
|
||||||
@@ -216,7 +216,7 @@
|
|||||||
@if (qrCodeUrl()) {
|
@if (qrCodeUrl()) {
|
||||||
<div class="qr-section">
|
<div class="qr-section">
|
||||||
<div class="qr-wrapper">
|
<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 class="scan-line"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { EMPTY, interval, of, Subscription } from 'rxjs';
|
|||||||
import { catchError, exhaustMap, take, timeout } from 'rxjs/operators';
|
import { catchError, exhaustMap, take, timeout } from 'rxjs/operators';
|
||||||
import { DeliverySelectorComponent } from '../../components/delivery-selector/delivery-selector.component';
|
import { DeliverySelectorComponent } from '../../components/delivery-selector/delivery-selector.component';
|
||||||
import { TelegramLoginComponent } from '../../components/telegram-login/telegram-login.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 { LangRoutePipe } from '../../pipes/lang-route.pipe';
|
||||||
import { TranslatePipe } from '../../i18n/translate.pipe';
|
import { TranslatePipe } from '../../i18n/translate.pipe';
|
||||||
import { TranslateService } from '../../i18n/translate.service';
|
import { TranslateService } from '../../i18n/translate.service';
|
||||||
@@ -255,6 +255,7 @@ export class CartComponent implements OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
readonly getMainImage = getMainImage;
|
readonly getMainImage = getMainImage;
|
||||||
|
readonly onImageError = onImageError;
|
||||||
readonly trackByItemId = trackByItemId;
|
readonly trackByItemId = trackByItemId;
|
||||||
readonly getDiscountedPrice = getDiscountedPrice;
|
readonly getDiscountedPrice = getDiscountedPrice;
|
||||||
readonly getBadgeClass = getBadgeClass;
|
readonly getBadgeClass = getBadgeClass;
|
||||||
|
|||||||
@@ -13,6 +13,15 @@ export function getMainImage(item: Item): string {
|
|||||||
return item.photos?.[0]?.url || '/assets/images/placeholder.svg';
|
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 {
|
export function trackByItemId(_index: number, item: Item): number | string {
|
||||||
return item.id || item.itemID;
|
return item.id || item.itemID;
|
||||||
}
|
}
|
||||||
|
|||||||
8
src/assets/images/placeholder.svg
Normal file
8
src/assets/images/placeholder.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 300" role="img" aria-label="No image available">
|
||||||
|
<rect width="300" height="300" fill="#f1f1f1"/>
|
||||||
|
<g fill="none" stroke="#c7c7c7" stroke-width="8" stroke-linejoin="round" stroke-linecap="round">
|
||||||
|
<rect x="40" y="60" width="220" height="180" rx="10"/>
|
||||||
|
<circle cx="100" cy="115" r="18"/>
|
||||||
|
<path d="M40 210 L120 140 L170 185 L210 150 L260 200 L260 240 L40 240 Z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 446 B |
Reference in New Issue
Block a user