Improve shop CLS, LCP, and accessibility for Lighthouse.
continuous-integration/drone/push Build is passing
continuous-integration/drone/push Build is passing
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
81f8ac0215
commit
65bd1695be
12 files changed
+113
-36
No files matched your search
@@ -17,3 +17,5 @@ appsettings.*.local.json
|
||||
# OS
|
||||
Thumbs.db
|
||||
.DS_Store
|
||||
|
||||
.unlighthouse/
|
||||
@@ -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
-1
@@ -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,20 +37,25 @@ 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"
|
||||
@@ -59,6 +65,7 @@ import { CartAddedModalComponent } from '../cart-added-modal.component';
|
||||
>
|
||||
×
|
||||
</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">
|
||||
<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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user