+ @if (loading()) {
+
+ @for (i of skeletonSlots; track i) {
+
+ }
+
+ } @else if (products().length === 0) {
+
{{ 'products.noResults' | translate }}
@@ -107,7 +117,7 @@
} @else {
-
+
@for (product of products(); track product.id; let i = $index) {
(null);
readonly totalCount = signal(0);
readonly filtersOpen = signal(false);
+ readonly loading = signal(true);
+ readonly skeletonSlots = [0, 1, 2, 3, 4, 5];
minPrice = 0;
maxPrice = 1000;
@@ -106,6 +115,7 @@ export class ProductListComponent implements OnInit {
this.maxPrice = swap;
}
+ this.loading.set(true);
this.#catalog
.getProducts({
page: this.page,
@@ -121,7 +131,9 @@ export class ProductListComponent implements OnInit {
next: (res) => {
this.products.set(res.items);
this.totalCount.set(res.totalCount);
+ this.loading.set(false);
},
+ error: () => this.loading.set(false),
});
}
diff --git a/src/shop/src/app/shared/cart-added-modal.component.html b/src/shop/src/app/shared/cart-added-modal.component.html
index b0a2c43..c1990ad 100644
--- a/src/shop/src/app/shared/cart-added-modal.component.html
+++ b/src/shop/src/app/shared/cart-added-modal.component.html
@@ -7,7 +7,7 @@
(click)="handleBackdrop($event)"
>
diff --git a/src/shop/src/app/shared/product-card-skeleton.component.ts b/src/shop/src/app/shared/product-card-skeleton.component.ts
new file mode 100644
index 0000000..efe030c
--- /dev/null
+++ b/src/shop/src/app/shared/product-card-skeleton.component.ts
@@ -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: `
+
+ `,
+ 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);
+}
diff --git a/src/shop/src/styles.css b/src/shop/src/styles.css
index 54023b8..386746f 100644
--- a/src/shop/src/styles.css
+++ b/src/shop/src/styles.css
@@ -88,9 +88,71 @@
animation: iled-fade-up 0.55s ease-out both;
}
+ .iled-fade-in {
+ animation: iled-fade-in 0.4s ease-out both;
+ }
+
.iled-hover-lift {
@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 {
@@ -134,3 +196,21 @@
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%);
+ }
+}