Add shop skeleton loading and subtle reveal animations.
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 13:29:41 +02:00
1 parent 65bd1695be
commit 1890b50e0d
11 files changed
+274 -35

No files matched your search

@@ -243,16 +243,28 @@
<p class="py-10 text-iled-grey-dark">{{ 'common.loading' | translate }}</p> <p class="py-10 text-iled-grey-dark">{{ 'common.loading' | translate }}</p>
} }
@if (recommended().length > 0) { @if (recommendedLoading() || recommended().length > 0) {
<section class="mt-10 md:mt-16"> <section class="mt-10 md:mt-16">
<h2 class="font-display mb-4 text-xl font-semibold md:mb-6 md:text-2xl"> <h2 class="font-display mb-4 text-xl font-semibold md:mb-6 md:text-2xl">
{{ 'cart.recommended' | translate }} {{ 'cart.recommended' | translate }}
</h2> </h2>
<div class="grid grid-cols-2 gap-3 sm:gap-6 lg:grid-cols-4"> @if (recommendedLoading()) {
@for (product of recommended(); track product.id) { <div
<app-product-card [product]="product" /> class="grid grid-cols-2 gap-3 sm:gap-6 lg:grid-cols-4"
} role="status"
</div> [attr.aria-label]="'common.loading' | translate"
>
@for (i of recommendedSkeletonSlots; track i) {
<app-product-card-skeleton [delayMs]="i * 40" />
}
</div>
} @else {
<div class="iled-stagger grid grid-cols-2 gap-3 sm:gap-6 lg:grid-cols-4">
@for (product of recommended(); track product.id) {
<app-product-card [product]="product" />
}
</div>
}
</section> </section>
} }
</div> </div>
@@ -24,6 +24,7 @@ import {
ShellComponent, ShellComponent,
ProductCardComponent, ProductCardComponent,
} from '../../shared/layout/layout.components'; } from '../../shared/layout/layout.components';
import { ProductCardSkeletonComponent } from '../../shared/product-card-skeleton.component';
import { FreeShippingBarComponent } from '../../shared/free-shipping-bar.component'; import { FreeShippingBarComponent } from '../../shared/free-shipping-bar.component';
import { CartService, CatalogService } from '../../core/shop.services'; import { CartService, CatalogService } from '../../core/shop.services';
import { ProductListItem } from '../../core/models'; import { ProductListItem } from '../../core/models';
@@ -34,6 +35,7 @@ import { ProductListItem } from '../../core/models';
imports: [ imports: [
ShellComponent, ShellComponent,
ProductCardComponent, ProductCardComponent,
ProductCardSkeletonComponent,
FreeShippingBarComponent, FreeShippingBarComponent,
FormsModule, FormsModule,
I18nCurrencyPipe, I18nCurrencyPipe,
@@ -56,6 +58,8 @@ export class CartPageComponent {
readonly i18n = this.#i18n; readonly i18n = this.#i18n;
readonly cart = this.#cart.cart; readonly cart = this.#cart.cart;
readonly recommended = signal<ProductListItem[]>([]); readonly recommended = signal<ProductListItem[]>([]);
readonly recommendedLoading = signal(true);
readonly recommendedSkeletonSlots = [0, 1, 2, 3];
discountCode = ''; discountCode = '';
readonly discountMessage = signal(''); readonly discountMessage = signal('');
readonly error = signal(''); readonly error = signal('');
@@ -67,11 +71,15 @@ export class CartPageComponent {
effect(() => { effect(() => {
this.#i18n.locale(); this.#i18n.locale();
this.#cart.load(); this.#cart.load();
this.recommendedLoading.set(true);
this.#catalog.getProducts({ pageSize: 8 }).subscribe({ this.#catalog.getProducts({ pageSize: 8 }).subscribe({
next: (res) => next: (res) => {
this.recommended.set( this.recommended.set(
res.items.filter((p) => p.isInStock).slice(0, 4), res.items.filter((p) => p.isInStock).slice(0, 4),
), );
this.recommendedLoading.set(false);
},
error: () => this.recommendedLoading.set(false),
}); });
}); });
} }
@@ -18,7 +18,8 @@
</a> </a>
</div> </div>
<div <div
class="relative flex min-h-[220px] items-center justify-center md:min-h-[320px]" class="iled-fade-in relative flex min-h-[220px] items-center justify-center md:min-h-[320px]"
style="animation-delay: 120ms"
aria-hidden="true" aria-hidden="true"
> >
<div <div
@@ -35,7 +36,7 @@
</section> </section>
<section class="border-b border-iled-grey-light bg-white py-10"> <section class="border-b border-iled-grey-light bg-white py-10">
<div class="iled-container"> <div class="iled-container iled-fade-in" style="animation-delay: 80ms">
<app-trust-strip /> <app-trust-strip />
</div> </div>
</section> </section>
@@ -53,10 +54,18 @@
{{ 'home.viewAllBestsellers' | translate }} {{ 'home.viewAllBestsellers' | translate }}
</a> </a>
</div> </div>
@if (bestsellers().length === 0) { @if (bestsellersLoading()) {
<p class="text-iled-grey-dark">{{ 'common.loading' | translate }}</p> <div
class="grid grid-cols-2 gap-3 sm:gap-6 lg:grid-cols-4"
role="status"
[attr.aria-label]="'common.loading' | translate"
>
@for (i of skeletonSlots; track i) {
<app-product-card-skeleton [delayMs]="i * 45" />
}
</div>
} @else { } @else {
<div class="grid grid-cols-2 gap-3 sm:gap-6 lg:grid-cols-4"> <div class="iled-stagger grid grid-cols-2 gap-3 sm:gap-6 lg:grid-cols-4">
@for (product of bestsellers(); track product.id; let i = $index) { @for (product of bestsellers(); track product.id; let i = $index) {
<app-product-card <app-product-card
[product]="product" [product]="product"
@@ -80,10 +89,18 @@
{{ 'home.viewAllNew' | translate }} {{ 'home.viewAllNew' | translate }}
</a> </a>
</div> </div>
@if (newArrivals().length === 0) { @if (newArrivalsLoading()) {
<p class="text-iled-grey-dark">{{ 'common.loading' | translate }}</p> <div
class="grid grid-cols-2 gap-3 sm:gap-6 lg:grid-cols-4"
role="status"
[attr.aria-label]="'common.loading' | translate"
>
@for (i of skeletonSlots; track i) {
<app-product-card-skeleton [delayMs]="i * 45" />
}
</div>
} @else { } @else {
<div class="grid grid-cols-2 gap-3 sm:gap-6 lg:grid-cols-4"> <div class="iled-stagger grid grid-cols-2 gap-3 sm:gap-6 lg:grid-cols-4">
@for (product of newArrivals(); track product.id) { @for (product of newArrivals(); track product.id) {
<app-product-card [product]="product" /> <app-product-card [product]="product" />
} }
@@ -10,6 +10,7 @@ import { LucideLightbulb } from '@lucide/angular';
import { I18nService } from '../../core/i18n/i18n.service'; import { I18nService } from '../../core/i18n/i18n.service';
import { TranslatePipe } from '../../core/i18n/translate.pipe'; import { TranslatePipe } from '../../core/i18n/translate.pipe';
import { ShellComponent, ProductCardComponent } from '../../shared/layout/layout.components'; import { ShellComponent, ProductCardComponent } from '../../shared/layout/layout.components';
import { ProductCardSkeletonComponent } from '../../shared/product-card-skeleton.component';
import { TrustStripComponent } from '../../shared/trust-strip.component'; import { TrustStripComponent } from '../../shared/trust-strip.component';
import { CatalogService } from '../../core/shop.services'; import { CatalogService } from '../../core/shop.services';
import { ProductListItem } from '../../core/models'; import { ProductListItem } from '../../core/models';
@@ -20,6 +21,7 @@ import { ProductListItem } from '../../core/models';
imports: [ imports: [
ShellComponent, ShellComponent,
ProductCardComponent, ProductCardComponent,
ProductCardSkeletonComponent,
TrustStripComponent, TrustStripComponent,
RouterLink, RouterLink,
LucideLightbulb, LucideLightbulb,
@@ -33,6 +35,9 @@ export class HomeComponent {
readonly #i18n = inject(I18nService); readonly #i18n = inject(I18nService);
readonly bestsellers = signal<ProductListItem[]>([]); readonly bestsellers = signal<ProductListItem[]>([]);
readonly newArrivals = signal<ProductListItem[]>([]); readonly newArrivals = signal<ProductListItem[]>([]);
readonly bestsellersLoading = signal(true);
readonly newArrivalsLoading = signal(true);
readonly skeletonSlots = [0, 1, 2, 3];
constructor() { constructor() {
effect(() => { effect(() => {
@@ -42,11 +47,21 @@ export class HomeComponent {
} }
#load(): void { #load(): void {
this.bestsellersLoading.set(true);
this.newArrivalsLoading.set(true);
this.#catalog.getProducts({ isBestseller: true, pageSize: 8 }).subscribe({ this.#catalog.getProducts({ isBestseller: true, pageSize: 8 }).subscribe({
next: (res) => this.bestsellers.set(res.items.slice(0, 4)), next: (res) => {
this.bestsellers.set(res.items.slice(0, 4));
this.bestsellersLoading.set(false);
},
error: () => this.bestsellersLoading.set(false),
}); });
this.#catalog.getProducts({ isNewArrival: true, pageSize: 8 }).subscribe({ this.#catalog.getProducts({ isNewArrival: true, pageSize: 8 }).subscribe({
next: (res) => this.newArrivals.set(res.items.slice(0, 4)), next: (res) => {
this.newArrivals.set(res.items.slice(0, 4));
this.newArrivalsLoading.set(false);
},
error: () => this.newArrivalsLoading.set(false),
}); });
} }
} }
@@ -1,8 +1,39 @@
<app-shell> <app-shell>
@if (product(); as p) { @if (loading()) {
<div
class="mx-auto max-w-6xl px-6 py-10"
role="status"
[attr.aria-label]="'common.loading' | translate"
>
<div class="flex flex-col gap-10 lg:flex-row">
<div class="flex flex-1 gap-4">
<div class="hidden flex-col gap-2 sm:flex">
@for (i of [0, 1, 2]; track i) {
<div class="iled-skeleton h-20 w-20"></div>
}
</div>
<div class="iled-skeleton aspect-square max-h-[500px] w-full flex-1"></div>
</div>
<div class="flex w-full flex-col gap-3 lg:w-[420px]">
<div class="iled-skeleton h-4 w-24"></div>
<div class="iled-skeleton h-9 w-[85%]"></div>
<div class="iled-skeleton h-8 w-32"></div>
<div class="iled-skeleton h-4 w-48"></div>
<div class="mt-2 flex gap-3">
@for (i of [0, 1, 2]; track i) {
<div class="iled-skeleton h-8 w-8 rounded-full"></div>
}
</div>
<div class="iled-skeleton mt-4 h-12 w-full"></div>
<div class="iled-skeleton h-4 w-40"></div>
<div class="iled-skeleton h-4 w-56"></div>
</div>
</div>
</div>
} @else if (product(); as p) {
<div class="mx-auto max-w-6xl px-6 py-10"> <div class="iled-fade-in mx-auto max-w-6xl px-6 py-10">
<div class="flex flex-col gap-10 lg:flex-row"> <div class="flex flex-col gap-10 lg:flex-row">
@@ -257,15 +288,23 @@
<h2 class="mb-6 text-2xl font-bold">{{ 'products.related' | translate }}</h2> <h2 class="mb-6 text-2xl font-bold">{{ 'products.related' | translate }}</h2>
<div class="grid grid-cols-2 gap-3 sm:gap-6 lg:grid-cols-4"> @if (relatedLoading()) {
<div
@for (item of related(); track item.id) { class="grid grid-cols-2 gap-3 sm:gap-6 lg:grid-cols-4"
role="status"
<app-product-card [product]="item" /> [attr.aria-label]="'common.loading' | translate"
>
} @for (i of relatedSkeletonSlots; track i) {
<app-product-card-skeleton [delayMs]="i * 40" />
</div> }
</div>
} @else {
<div class="iled-stagger grid grid-cols-2 gap-3 sm:gap-6 lg:grid-cols-4">
@for (item of related(); track item.id) {
<app-product-card [product]="item" />
}
</div>
}
</section> </section>
@@ -27,6 +27,7 @@ import {
StarRatingComponent, StarRatingComponent,
ProductCardComponent, ProductCardComponent,
} from '../../shared/layout/layout.components'; } from '../../shared/layout/layout.components';
import { ProductCardSkeletonComponent } from '../../shared/product-card-skeleton.component';
import { import {
CatalogService, CatalogService,
CartService, CartService,
@@ -48,6 +49,7 @@ import { ProductImagePipe } from '../../core/product-image.pipe';
ShellComponent, ShellComponent,
StarRatingComponent, StarRatingComponent,
ProductCardComponent, ProductCardComponent,
ProductCardSkeletonComponent,
FormsModule, FormsModule,
DecimalPipe, DecimalPipe,
I18nCurrencyPipe, I18nCurrencyPipe,
@@ -75,11 +77,14 @@ export class ProductDetailComponent implements OnInit {
readonly product = signal<ProductDetail | null>(null); readonly product = signal<ProductDetail | null>(null);
readonly related = signal<ProductListItem[]>([]); readonly related = signal<ProductListItem[]>([]);
readonly relatedLoading = signal(true);
readonly loading = signal(true);
readonly reviews = signal<ProductReviews | null>(null); readonly reviews = signal<ProductReviews | null>(null);
readonly selectedVariant = signal<ProductVariant | null>(null); readonly selectedVariant = signal<ProductVariant | null>(null);
readonly activeImage = signal<string | null>(null); readonly activeImage = signal<string | null>(null);
readonly quantity = signal(1); readonly quantity = signal(1);
readonly cartModalOpen = signal(false); readonly cartModalOpen = signal(false);
readonly relatedSkeletonSlots = [0, 1, 2, 3];
#slug = ''; #slug = '';
reviewName = ''; reviewName = '';
@@ -111,12 +116,16 @@ export class ProductDetailComponent implements OnInit {
} }
#loadProduct(slug: string): void { #loadProduct(slug: string): void {
this.loading.set(true);
this.relatedLoading.set(true);
this.product.set(null);
this.#catalog.getProduct(slug).subscribe({ this.#catalog.getProduct(slug).subscribe({
next: (p) => { next: (p) => {
this.product.set(p); this.product.set(p);
const def = p.variants.find((v) => v.isDefault) ?? p.variants[0]; const def = p.variants.find((v) => v.isDefault) ?? p.variants[0];
this.selectedVariant.set(def ?? null); this.selectedVariant.set(def ?? null);
this.activeImage.set(p.images[0]?.url ?? null); this.activeImage.set(p.images[0]?.url ?? null);
this.loading.set(false);
this.#seo.apply({ this.#seo.apply({
title: `${p.name} — iLED`, title: `${p.name} — iLED`,
description: p.shortDescription || p.description, description: p.shortDescription || p.description,
@@ -125,9 +134,14 @@ export class ProductDetailComponent implements OnInit {
type: 'product', type: 'product',
}); });
}, },
error: () => this.loading.set(false),
}); });
this.#catalog.getProducts({ pageSize: 4 }).subscribe({ this.#catalog.getProducts({ pageSize: 4 }).subscribe({
next: (res) => this.related.set(res.items), next: (res) => {
this.related.set(res.items);
this.relatedLoading.set(false);
},
error: () => this.relatedLoading.set(false),
}); });
} }
@@ -97,8 +97,18 @@
{{ 'products.title' | translate: { count: totalCount() } }} {{ 'products.title' | translate: { count: totalCount() } }}
</h1> </h1>
@if (products().length === 0) { @if (loading()) {
<div class="py-16 text-center"> <div
class="grid grid-cols-2 gap-3 sm:gap-6 xl:grid-cols-3"
role="status"
[attr.aria-label]="'common.loading' | translate"
>
@for (i of skeletonSlots; track i) {
<app-product-card-skeleton [delayMs]="i * 40" />
}
</div>
} @else if (products().length === 0) {
<div class="iled-fade-in py-16 text-center">
<p class="mb-4 text-iled-grey-dark"> <p class="mb-4 text-iled-grey-dark">
{{ 'products.noResults' | translate }} {{ 'products.noResults' | translate }}
</p> </p>
@@ -107,7 +117,7 @@
</button> </button>
</div> </div>
} @else { } @else {
<div class="grid grid-cols-2 gap-3 sm:gap-6 xl:grid-cols-3"> <div class="iled-stagger grid grid-cols-2 gap-3 sm:gap-6 xl:grid-cols-3">
@for (product of products(); track product.id; let i = $index) { @for (product of products(); track product.id; let i = $index) {
<app-product-card <app-product-card
[product]="product" [product]="product"
@@ -15,13 +15,20 @@ import {
ShellComponent, ShellComponent,
ProductCardComponent, ProductCardComponent,
} from '../../shared/layout/layout.components'; } from '../../shared/layout/layout.components';
import { ProductCardSkeletonComponent } from '../../shared/product-card-skeleton.component';
import { CatalogService } from '../../core/shop.services'; import { CatalogService } from '../../core/shop.services';
import { ProductFilterOptions, ProductListItem } from '../../core/models'; import { ProductFilterOptions, ProductListItem } from '../../core/models';
@Component({ @Component({
selector: 'app-product-list', selector: 'app-product-list',
standalone: true, standalone: true,
imports: [ShellComponent, ProductCardComponent, FormsModule, TranslatePipe], imports: [
ShellComponent,
ProductCardComponent,
ProductCardSkeletonComponent,
FormsModule,
TranslatePipe,
],
templateUrl: './product-list.component.html', templateUrl: './product-list.component.html',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
@@ -35,6 +42,8 @@ export class ProductListComponent implements OnInit {
readonly filters = signal<ProductFilterOptions | null>(null); readonly filters = signal<ProductFilterOptions | null>(null);
readonly totalCount = signal(0); readonly totalCount = signal(0);
readonly filtersOpen = signal(false); readonly filtersOpen = signal(false);
readonly loading = signal(true);
readonly skeletonSlots = [0, 1, 2, 3, 4, 5];
minPrice = 0; minPrice = 0;
maxPrice = 1000; maxPrice = 1000;
@@ -106,6 +115,7 @@ export class ProductListComponent implements OnInit {
this.maxPrice = swap; this.maxPrice = swap;
} }
this.loading.set(true);
this.#catalog this.#catalog
.getProducts({ .getProducts({
page: this.page, page: this.page,
@@ -121,7 +131,9 @@ export class ProductListComponent implements OnInit {
next: (res) => { next: (res) => {
this.products.set(res.items); this.products.set(res.items);
this.totalCount.set(res.totalCount); this.totalCount.set(res.totalCount);
this.loading.set(false);
}, },
error: () => this.loading.set(false),
}); });
} }
@@ -7,7 +7,7 @@
(click)="handleBackdrop($event)" (click)="handleBackdrop($event)"
> >
<div <div
class="w-full max-w-md bg-white p-6 shadow-lg" class="iled-fade-in w-full max-w-md bg-white p-6 shadow-lg"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
> >
<h2 id="cart-added-title" class="mb-2 text-xl font-bold"> <h2 id="cart-added-title" class="mb-2 text-xl font-bold">
@@ -0,0 +1,32 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
/**
* Placeholder card matching product-card proportions (CLS-safe).
*/
@Component({
selector: 'app-product-card-skeleton',
standalone: true,
template: `
<div
class="flex h-full flex-col bg-white"
[attr.aria-hidden]="true"
>
<div class="iled-skeleton aspect-square w-full"></div>
<div class="flex flex-1 flex-col gap-2 p-3 sm:p-4">
<div class="iled-skeleton h-4 w-[80%]"></div>
<div class="iled-skeleton hidden h-3 w-full sm:block"></div>
<div class="iled-skeleton mt-auto h-5 w-[33%]"></div>
<div class="iled-skeleton mt-2 h-10 w-full"></div>
</div>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'iled-fade-in block h-full',
'[style.animation-delay.ms]': 'delayMs()',
},
})
export class ProductCardSkeletonComponent {
/** Optional stagger delay for grid reveal. */
readonly delayMs = input(0);
}
+80
View File
@@ -88,9 +88,71 @@
animation: iled-fade-up 0.55s ease-out both; animation: iled-fade-up 0.55s ease-out both;
} }
.iled-fade-in {
animation: iled-fade-in 0.4s ease-out both;
}
.iled-hover-lift { .iled-hover-lift {
@apply transition duration-200 ease-out hover:-translate-y-0.5 hover:shadow-md; @apply transition duration-200 ease-out hover:-translate-y-0.5 hover:shadow-md;
} }
.iled-skeleton {
@apply relative overflow-hidden rounded-sm bg-iled-grey-light/50;
}
.iled-skeleton::after {
content: '';
position: absolute;
inset: 0;
transform: translateX(-100%);
background: linear-gradient(
90deg,
transparent,
rgb(255 255 255 / 0.55),
transparent
);
animation: iled-shimmer 1.35s ease-in-out infinite;
}
.iled-stagger > * {
animation: iled-fade-in 0.4s ease-out both;
}
.iled-stagger > *:nth-child(1) {
animation-delay: 0ms;
}
.iled-stagger > *:nth-child(2) {
animation-delay: 45ms;
}
.iled-stagger > *:nth-child(3) {
animation-delay: 90ms;
}
.iled-stagger > *:nth-child(4) {
animation-delay: 135ms;
}
.iled-stagger > *:nth-child(5) {
animation-delay: 180ms;
}
.iled-stagger > *:nth-child(6) {
animation-delay: 225ms;
}
.iled-stagger > *:nth-child(7) {
animation-delay: 270ms;
}
.iled-stagger > *:nth-child(8) {
animation-delay: 315ms;
}
.iled-stagger > *:nth-child(n + 9) {
animation-delay: 360ms;
}
} }
@layer utilities { @layer utilities {
@@ -134,3 +196,21 @@
transform: translateY(0); transform: translateY(0);
} }
} }
@keyframes iled-fade-in {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes iled-shimmer {
100% {
transform: translateX(100%);
}
}