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
+113 -36

No files matched your search

+2
View File
@@ -17,3 +17,5 @@ appsettings.*.local.json
# OS # OS
Thumbs.db Thumbs.db
.DS_Store .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 #nullable disable
@@ -53,8 +53,11 @@
<img <img
[src]="item.imageUrl | productImage" [src]="item.imageUrl | productImage"
[alt]="item.productName" [alt]="item.productName"
width="96"
height="96"
class="h-full w-full object-cover" class="h-full w-full object-cover"
loading="lazy" loading="lazy"
decoding="async"
/> />
} @else { } @else {
<span class="text-xs text-iled-grey-dark">iLED</span> <span class="text-xs text-iled-grey-dark">iLED</span>
@@ -57,8 +57,11 @@
<p class="text-iled-grey-dark">{{ 'common.loading' | translate }}</p> <p class="text-iled-grey-dark">{{ 'common.loading' | translate }}</p>
} @else { } @else {
<div class="grid grid-cols-2 gap-3 sm:gap-6 lg:grid-cols-4"> <div class="grid grid-cols-2 gap-3 sm:gap-6 lg:grid-cols-4">
@for (product of bestsellers(); track product.id) { @for (product of bestsellers(); track product.id; let i = $index) {
<app-product-card [product]="product" /> <app-product-card
[product]="product"
[priority]="i < 4"
/>
} }
</div> </div>
} }
@@ -31,6 +31,10 @@
<img <img
[src]="img.url | productImage" [src]="img.url | productImage"
[alt]="img.altText || p.name" [alt]="img.altText || p.name"
width="80"
height="80"
loading="lazy"
decoding="async"
class="h-full w-full object-cover" class="h-full w-full object-cover"
/> />
@@ -40,13 +44,17 @@
</div> </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()) { @if (activeImage()) {
<img <img
[src]="activeImage() | productImage" [src]="activeImage() | productImage"
[alt]="activeImageAlt()" [alt]="activeImageAlt()"
width="1000"
height="1000"
class="max-h-[500px] w-full object-contain" class="max-h-[500px] w-full object-contain"
fetchpriority="high" fetchpriority="high"
decoding="async" decoding="async"
@@ -108,8 +108,11 @@
</div> </div>
} @else { } @else {
<div class="grid grid-cols-2 gap-3 sm:gap-6 xl:grid-cols-3"> <div class="grid grid-cols-2 gap-3 sm:gap-6 xl:grid-cols-3">
@for (product of products(); track product.id) { @for (product of products(); track product.id; let i = $index) {
<app-product-card [product]="product" /> <app-product-card
[product]="product"
[priority]="i < 4"
/>
} }
</div> </div>
} }
@@ -7,56 +7,56 @@
<svg lucideLightbulb [size]="24" class="text-iled-primary" aria-hidden="true"></svg> <svg lucideLightbulb [size]="24" class="text-iled-primary" aria-hidden="true"></svg>
<span>iLED</span> <span>iLED</span>
</div> </div>
<p class="text-sm text-iled-grey-dark"> <p class="text-sm text-neutral-400">
{{ 'footer.tagline' | translate }} {{ 'footer.tagline' | translate }}
</p> </p>
<p class="mt-3 text-xs text-iled-grey-dark"> <p class="mt-3 text-xs text-neutral-400">
{{ 'footer.secureCheckout' | translate }} {{ 'footer.secureCheckout' | translate }}
</p> </p>
</div> </div>
<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 }} {{ 'footer.customerService' | translate }}
</h4> </p>
<a routerLink="/shipping" class="mb-2 block text-sm text-iled-grey-dark hover:text-white"> <a routerLink="/shipping" class="mb-2 block text-sm text-neutral-400 hover:text-white">
{{ 'footer.shipping' | translate }} {{ 'footer.shipping' | translate }}
</a> </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 }} {{ 'footer.returnsPolicy' | translate }}
</a> </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 }} {{ 'footer.contact' | translate }}
</a> </a>
</div> </div>
<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 }} {{ 'footer.about' | translate }}
</h4> </p>
<a routerLink="/about" class="mb-2 block text-sm text-iled-grey-dark hover:text-white"> <a routerLink="/about" class="mb-2 block text-sm text-neutral-400 hover:text-white">
{{ 'footer.aboutUs' | translate }} {{ 'footer.aboutUs' | translate }}
</a> </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 }} {{ 'footer.privacy' | translate }}
</a> </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 }} {{ 'footer.terms' | translate }}
</a> </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 }} {{ 'footer.cookies' | translate }}
</a> </a>
</div> </div>
<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 }} {{ 'footer.advice' | translate }}
</h4> </p>
<a routerLink="/faq" class="block text-sm text-iled-grey-dark hover:text-white"> <a routerLink="/faq" class="block text-sm text-neutral-400 hover:text-white">
{{ 'footer.faq' | translate }} {{ 'footer.faq' | translate }}
</a> </a>
</div> </div>
</div> </div>
<div class="border-t border-neutral-800"> <div class="border-t border-neutral-800">
<div <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> <span>{{ 'footer.copyright' | translate }}</span>
<div <div
@@ -1,5 +1,5 @@
<header <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()" [class.shadow-sm]="scrolled()"
> >
<div class="iled-container flex h-14 items-center justify-between gap-3 md:h-20 md:gap-10"> <div class="iled-container flex h-14 items-center justify-between gap-3 md:h-20 md:gap-10">
@@ -1,6 +1,7 @@
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
computed,
DestroyRef, DestroyRef,
effect, effect,
HostListener, HostListener,
@@ -36,20 +37,25 @@ import {
import { ProductListItem, PromoBanner } from '../../core/models'; import { ProductListItem, PromoBanner } from '../../core/models';
import { CartAddedModalComponent } from '../cart-added-modal.component'; import { CartAddedModalComponent } from '../cart-added-modal.component';
const PROMO_DISMISSED_KEY = 'iled_promo_dismissed';
const PROMO_EXPECT_KEY = 'iled_promo_expect';
@Component({ @Component({
selector: 'app-promo-banner', selector: 'app-promo-banner',
standalone: true, standalone: true,
imports: [TranslatePipe], imports: [TranslatePipe],
template: ` template: `
@if (banner() && !dismissed()) { @if (showSlot()) {
<div <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" role="region"
[attr.aria-label]="'a11y.promoBanner' | translate" [attr.aria-label]="'a11y.promoBanner' | translate"
[attr.aria-hidden]="!banner() ? 'true' : null"
> >
<span>{{ banner()!.text }}</span> @if (banner(); as b) {
@if (banner()!.highlightedCode) { <span>{{ b.text }}</span>
<strong class="ml-1 font-bold">{{ banner()!.highlightedCode }}</strong> @if (b.highlightedCode) {
<strong class="ml-1 font-bold">{{ b.highlightedCode }}</strong>
} }
<button <button
type="button" type="button"
@@ -59,6 +65,7 @@ import { CartAddedModalComponent } from '../cart-added-modal.component';
> >
× ×
</button> </button>
}
</div> </div>
} }
`, `,
@@ -68,21 +75,46 @@ export class PromoBannerComponent {
readonly #catalog = inject(CatalogService); readonly #catalog = inject(CatalogService);
readonly #i18n = inject(I18nService); readonly #i18n = inject(I18nService);
readonly banner = signal<PromoBanner | null>(null); readonly banner = signal<PromoBanner | null>(null);
readonly loaded = signal(false);
readonly dismissed = signal( 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() { constructor() {
effect(() => { effect(() => {
this.#i18n.locale(); this.#i18n.locale();
this.loaded.set(false);
this.#catalog.getBanner().subscribe({ 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 { dismiss(): void {
sessionStorage.setItem('iled_promo_dismissed', '1'); sessionStorage.setItem(PROMO_DISMISSED_KEY, '1');
this.dismissed.set(true); this.dismissed.set(true);
} }
} }
@@ -230,6 +262,8 @@ export class StarRatingComponent {
}) })
export class ProductCardComponent implements OnInit { export class ProductCardComponent implements OnInit {
readonly product = input.required<ProductListItem>(); 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 #cart = inject(CartService);
readonly #catalog = inject(CatalogService); readonly #catalog = inject(CatalogService);
readonly #favorites = inject(FavoritesService); readonly #favorites = inject(FavoritesService);
@@ -328,8 +362,10 @@ export class ProductCardComponent implements OnInit {
{{ 'a11y.skipToContent' | translate }} {{ 'a11y.skipToContent' | translate }}
</a> </a>
<div class="flex min-h-screen flex-col"> <div class="flex min-h-screen flex-col">
<div class="sticky top-0 z-40">
<app-promo-banner /> <app-promo-banner />
<app-header /> <app-header />
</div>
<main <main
id="main-content" id="main-content"
class="flex-1" class="flex-1"
@@ -5,7 +5,10 @@
<img <img
[src]="product().primaryImageUrl | productImage" [src]="product().primaryImageUrl | productImage"
[alt]="product().name" [alt]="product().name"
loading="lazy" width="800"
height="800"
[attr.loading]="priority() ? 'eager' : 'lazy'"
[attr.fetchpriority]="priority() ? 'high' : null"
decoding="async" decoding="async"
class="h-full w-full object-cover transition duration-300 group-hover:scale-105" class="h-full w-full object-cover transition duration-300 group-hover:scale-105"
/> />
@@ -44,9 +47,9 @@
</div> </div>
<div class="flex flex-1 flex-col gap-0.5 p-3 sm:gap-1 sm:p-4"> <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 }} {{ product().name }}
</h3> </p>
<p class="hidden truncate text-sm text-iled-grey-dark sm:block"> <p class="hidden truncate text-sm text-iled-grey-dark sm:block">
{{ product().shortDescription }} {{ product().shortDescription }}
</p> </p>
+2 -1
View File
@@ -10,7 +10,8 @@
--color-iled-grey-light: #d9d9d9; --color-iled-grey-light: #d9d9d9;
--color-iled-bg: #fafaf8; --color-iled-bg: #fafaf8;
--color-iled-surface: #ffffff; --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-danger: #dc2626;
--color-iled-focus: #ffbc39; --color-iled-focus: #ffbc39;
--font-sans: 'Outfit', ui-sans-serif, system-ui, sans-serif; --font-sans: 'Outfit', ui-sans-serif, system-ui, sans-serif;