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:
sdarbinyan
2026-07-26 00:12:43 +04:00
parent 6c6fa00ccf
commit 3e54e88db7
8 changed files with 29 additions and 8 deletions

View File

@@ -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>
} }

View File

@@ -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 });

View File

@@ -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>

View File

@@ -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;

View File

@@ -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>

View File

@@ -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;

View File

@@ -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;
} }

View 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