Improve shop CLS, LCP, and accessibility for Lighthouse.
continuous-integration/drone/push Build is passing

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Rafał MiczekandCursor committed 2026-10-17 12:24:57 +02:00
1 parent 81f8ac0215
commit 65bd1695be
12 files changed
+123 -46

No files matched your search

+2
View File
@@ -17,3 +17,5 @@ appsettings.*.local.json
# OS
Thumbs.db
.DS_Store
.unlighthouse/
+18
View File
@@ -0,0 +1,18 @@
# Local-only port publishes. Use with:
# docker compose -f docker-compose.yml -f docker-compose.local.yml up -d postgres redis openobserve
services:
postgres:
ports:
- "5433:5432"
redis:
ports:
- "6379:6379"
openobserve:
ports:
- "5080:5080"
networks:
iled:
external: true
homelab:
external: true
@@ -1,4 +1,4 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@@ -53,8 +53,11 @@
<img
[src]="item.imageUrl | productImage"
[alt]="item.productName"
width="96"
height="96"
class="h-full w-full object-cover"
loading="lazy"
decoding="async"
/>
} @else {
<span class="text-xs text-iled-grey-dark">iLED</span>
@@ -57,8 +57,11 @@
<p class="text-iled-grey-dark">{{ 'common.loading' | translate }}</p>
} @else {
<div class="grid grid-cols-2 gap-3 sm:gap-6 lg:grid-cols-4">
@for (product of bestsellers(); track product.id) {
<app-product-card [product]="product" />
@for (product of bestsellers(); track product.id; let i = $index) {
<app-product-card
[product]="product"
[priority]="i < 4"
/>
}
</div>
}
@@ -31,6 +31,10 @@
<img
[src]="img.url | productImage"
[alt]="img.altText || p.name"
width="80"
height="80"
loading="lazy"
decoding="async"
class="h-full w-full object-cover"
/>
@@ -40,13 +44,17 @@
</div>
<div class="flex flex-1 items-center justify-center bg-iled-bg">
<div
class="relative flex aspect-square max-h-[500px] w-full flex-1 items-center justify-center bg-iled-bg"
>
@if (activeImage()) {
<img
[src]="activeImage() | productImage"
[alt]="activeImageAlt()"
width="1000"
height="1000"
class="max-h-[500px] w-full object-contain"
fetchpriority="high"
decoding="async"
@@ -108,8 +108,11 @@
</div>
} @else {
<div class="grid grid-cols-2 gap-3 sm:gap-6 xl:grid-cols-3">
@for (product of products(); track product.id) {
<app-product-card [product]="product" />
@for (product of products(); track product.id; let i = $index) {
<app-product-card
[product]="product"
[priority]="i < 4"
/>
}
</div>
}
@@ -7,56 +7,56 @@
<svg lucideLightbulb [size]="24" class="text-iled-primary" aria-hidden="true"></svg>
<span>iLED</span>
</div>
<p class="text-sm text-iled-grey-dark">
<p class="text-sm text-neutral-400">
{{ 'footer.tagline' | translate }}
</p>
<p class="mt-3 text-xs text-iled-grey-dark">
<p class="mt-3 text-xs text-neutral-400">
{{ 'footer.secureCheckout' | translate }}
</p>
</div>
<div>
<h4 class="mb-3 text-sm font-medium md:mb-4 md:text-base">
<p class="mb-3 text-sm font-medium md:mb-4 md:text-base">
{{ 'footer.customerService' | translate }}
</h4>
<a routerLink="/shipping" class="mb-2 block text-sm text-iled-grey-dark hover:text-white">
</p>
<a routerLink="/shipping" class="mb-2 block text-sm text-neutral-400 hover:text-white">
{{ 'footer.shipping' | translate }}
</a>
<a routerLink="/returns" class="mb-2 block text-sm text-iled-grey-dark hover:text-white">
<a routerLink="/returns" class="mb-2 block text-sm text-neutral-400 hover:text-white">
{{ 'footer.returnsPolicy' | translate }}
</a>
<a routerLink="/contact" class="block text-sm text-iled-grey-dark hover:text-white">
<a routerLink="/contact" class="block text-sm text-neutral-400 hover:text-white">
{{ 'footer.contact' | translate }}
</a>
</div>
<div>
<h4 class="mb-3 text-sm font-medium md:mb-4 md:text-base">
<p class="mb-3 text-sm font-medium md:mb-4 md:text-base">
{{ 'footer.about' | translate }}
</h4>
<a routerLink="/about" class="mb-2 block text-sm text-iled-grey-dark hover:text-white">
</p>
<a routerLink="/about" class="mb-2 block text-sm text-neutral-400 hover:text-white">
{{ 'footer.aboutUs' | translate }}
</a>
<a routerLink="/privacy" class="mb-2 block text-sm text-iled-grey-dark hover:text-white">
<a routerLink="/privacy" class="mb-2 block text-sm text-neutral-400 hover:text-white">
{{ 'footer.privacy' | translate }}
</a>
<a routerLink="/terms" class="mb-2 block text-sm text-iled-grey-dark hover:text-white">
<a routerLink="/terms" class="mb-2 block text-sm text-neutral-400 hover:text-white">
{{ 'footer.terms' | translate }}
</a>
<a routerLink="/cookies" class="block text-sm text-iled-grey-dark hover:text-white">
<a routerLink="/cookies" class="block text-sm text-neutral-400 hover:text-white">
{{ 'footer.cookies' | translate }}
</a>
</div>
<div>
<h4 class="mb-3 text-sm font-medium md:mb-4 md:text-base">
<p class="mb-3 text-sm font-medium md:mb-4 md:text-base">
{{ 'footer.advice' | translate }}
</h4>
<a routerLink="/faq" class="block text-sm text-iled-grey-dark hover:text-white">
</p>
<a routerLink="/faq" class="block text-sm text-neutral-400 hover:text-white">
{{ 'footer.faq' | translate }}
</a>
</div>
</div>
<div class="border-t border-neutral-800">
<div
class="iled-container flex flex-col items-start justify-between gap-3 py-4 text-sm text-iled-grey-dark sm:flex-row sm:items-center sm:gap-4"
class="iled-container flex flex-col items-start justify-between gap-3 py-4 text-sm text-neutral-400 sm:flex-row sm:items-center sm:gap-4"
>
<span>{{ 'footer.copyright' | translate }}</span>
<div
@@ -1,5 +1,5 @@
<header
class="sticky top-0 z-40 border-b border-iled-grey-light bg-white/95 backdrop-blur-sm transition-shadow"
class="border-b border-iled-grey-light bg-white/95 backdrop-blur-sm transition-shadow"
[class.shadow-sm]="scrolled()"
>
<div class="iled-container flex h-14 items-center justify-between gap-3 md:h-20 md:gap-10">
@@ -1,6 +1,7 @@
import {
ChangeDetectionStrategy,
Component,
computed,
DestroyRef,
effect,
HostListener,
@@ -36,29 +37,35 @@ import {
import { ProductListItem, PromoBanner } from '../../core/models';
import { CartAddedModalComponent } from '../cart-added-modal.component';
const PROMO_DISMISSED_KEY = 'iled_promo_dismissed';
const PROMO_EXPECT_KEY = 'iled_promo_expect';
@Component({
selector: 'app-promo-banner',
standalone: true,
imports: [TranslatePipe],
template: `
@if (banner() && !dismissed()) {
@if (showSlot()) {
<div
class="relative flex items-center justify-center bg-iled-primary px-10 py-2 text-center text-sm"
class="relative flex h-9 items-center justify-center bg-iled-primary px-10 text-center text-sm"
role="region"
[attr.aria-label]="'a11y.promoBanner' | translate"
[attr.aria-hidden]="!banner() ? 'true' : null"
>
<span>{{ banner()!.text }}</span>
@if (banner()!.highlightedCode) {
<strong class="ml-1 font-bold">{{ banner()!.highlightedCode }}</strong>
@if (banner(); as b) {
<span>{{ b.text }}</span>
@if (b.highlightedCode) {
<strong class="ml-1 font-bold">{{ b.highlightedCode }}</strong>
}
<button
type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 text-sm font-bold"
[attr.aria-label]="'common.close' | translate"
(click)="dismiss()"
>
×
</button>
}
<button
type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 text-sm font-bold"
[attr.aria-label]="'common.close' | translate"
(click)="dismiss()"
>
×
</button>
</div>
}
`,
@@ -68,21 +75,46 @@ export class PromoBannerComponent {
readonly #catalog = inject(CatalogService);
readonly #i18n = inject(I18nService);
readonly banner = signal<PromoBanner | null>(null);
readonly loaded = signal(false);
readonly dismissed = signal(
sessionStorage.getItem('iled_promo_dismissed') === '1',
sessionStorage.getItem(PROMO_DISMISSED_KEY) === '1',
);
/**
* Reserve banner height until the API responds (or forever while visible)
* so sticky header / page content do not jump.
*/
readonly showSlot = computed(() => {
if (this.dismissed()) {
return false;
}
if (!this.loaded()) {
return sessionStorage.getItem(PROMO_EXPECT_KEY) !== '0';
}
return this.banner() !== null;
});
constructor() {
effect(() => {
this.#i18n.locale();
this.loaded.set(false);
this.#catalog.getBanner().subscribe({
next: (b) => this.banner.set(b),
next: (b) => {
this.banner.set(b);
this.loaded.set(true);
sessionStorage.setItem(PROMO_EXPECT_KEY, b ? '1' : '0');
},
error: () => {
this.banner.set(null);
this.loaded.set(true);
sessionStorage.setItem(PROMO_EXPECT_KEY, '0');
},
});
});
}
dismiss(): void {
sessionStorage.setItem('iled_promo_dismissed', '1');
sessionStorage.setItem(PROMO_DISMISSED_KEY, '1');
this.dismissed.set(true);
}
}
@@ -230,6 +262,8 @@ export class StarRatingComponent {
})
export class ProductCardComponent implements OnInit {
readonly product = input.required<ProductListItem>();
/** Eager-load + high fetch priority for above-the-fold LCP cards. */
readonly priority = input(false);
readonly #cart = inject(CartService);
readonly #catalog = inject(CatalogService);
readonly #favorites = inject(FavoritesService);
@@ -328,8 +362,10 @@ export class ProductCardComponent implements OnInit {
{{ 'a11y.skipToContent' | translate }}
</a>
<div class="flex min-h-screen flex-col">
<app-promo-banner />
<app-header />
<div class="sticky top-0 z-40">
<app-promo-banner />
<app-header />
</div>
<main
id="main-content"
class="flex-1"
@@ -5,7 +5,10 @@
<img
[src]="product().primaryImageUrl | productImage"
[alt]="product().name"
loading="lazy"
width="800"
height="800"
[attr.loading]="priority() ? 'eager' : 'lazy'"
[attr.fetchpriority]="priority() ? 'high' : null"
decoding="async"
class="h-full w-full object-cover transition duration-300 group-hover:scale-105"
/>
@@ -44,9 +47,9 @@
</div>
<div class="flex flex-1 flex-col gap-0.5 p-3 sm:gap-1 sm:p-4">
<h3 class="line-clamp-2 text-sm font-medium sm:truncate sm:text-base">
<p class="line-clamp-2 text-sm font-medium sm:truncate sm:text-base">
{{ product().name }}
</h3>
</p>
<p class="hidden truncate text-sm text-iled-grey-dark sm:block">
{{ product().shortDescription }}
</p>
+2 -1
View File
@@ -10,7 +10,8 @@
--color-iled-grey-light: #d9d9d9;
--color-iled-bg: #fafaf8;
--color-iled-surface: #ffffff;
--color-iled-success: #16a34a;
/* Darker green for AA contrast on white at small sizes */
--color-iled-success: #15803d;
--color-iled-danger: #dc2626;
--color-iled-focus: #ffbc39;
--font-sans: 'Outfit', ui-sans-serif, system-ui, sans-serif;