Add shop skeleton loading and subtle reveal animations.
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
65bd1695be
commit
1890b50e0d
11 files changed
+268
-29
No files matched your search
@@ -243,16 +243,28 @@
|
||||
<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">
|
||||
<h2 class="font-display mb-4 text-xl font-semibold md:mb-6 md:text-2xl">
|
||||
{{ 'cart.recommended' | translate }}
|
||||
</h2>
|
||||
<div class="grid grid-cols-2 gap-3 sm:gap-6 lg:grid-cols-4">
|
||||
@if (recommendedLoading()) {
|
||||
<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 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>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
ShellComponent,
|
||||
ProductCardComponent,
|
||||
} from '../../shared/layout/layout.components';
|
||||
import { ProductCardSkeletonComponent } from '../../shared/product-card-skeleton.component';
|
||||
import { FreeShippingBarComponent } from '../../shared/free-shipping-bar.component';
|
||||
import { CartService, CatalogService } from '../../core/shop.services';
|
||||
import { ProductListItem } from '../../core/models';
|
||||
@@ -34,6 +35,7 @@ import { ProductListItem } from '../../core/models';
|
||||
imports: [
|
||||
ShellComponent,
|
||||
ProductCardComponent,
|
||||
ProductCardSkeletonComponent,
|
||||
FreeShippingBarComponent,
|
||||
FormsModule,
|
||||
I18nCurrencyPipe,
|
||||
@@ -56,6 +58,8 @@ export class CartPageComponent {
|
||||
readonly i18n = this.#i18n;
|
||||
readonly cart = this.#cart.cart;
|
||||
readonly recommended = signal<ProductListItem[]>([]);
|
||||
readonly recommendedLoading = signal(true);
|
||||
readonly recommendedSkeletonSlots = [0, 1, 2, 3];
|
||||
discountCode = '';
|
||||
readonly discountMessage = signal('');
|
||||
readonly error = signal('');
|
||||
@@ -67,11 +71,15 @@ export class CartPageComponent {
|
||||
effect(() => {
|
||||
this.#i18n.locale();
|
||||
this.#cart.load();
|
||||
this.recommendedLoading.set(true);
|
||||
this.#catalog.getProducts({ pageSize: 8 }).subscribe({
|
||||
next: (res) =>
|
||||
next: (res) => {
|
||||
this.recommended.set(
|
||||
res.items.filter((p) => p.isInStock).slice(0, 4),
|
||||
),
|
||||
);
|
||||
this.recommendedLoading.set(false);
|
||||
},
|
||||
error: () => this.recommendedLoading.set(false),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
</a>
|
||||
</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"
|
||||
>
|
||||
<div
|
||||
@@ -35,7 +36,7 @@
|
||||
</section>
|
||||
|
||||
<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 />
|
||||
</div>
|
||||
</section>
|
||||
@@ -53,10 +54,18 @@
|
||||
{{ 'home.viewAllBestsellers' | translate }}
|
||||
</a>
|
||||
</div>
|
||||
@if (bestsellers().length === 0) {
|
||||
<p class="text-iled-grey-dark">{{ 'common.loading' | translate }}</p>
|
||||
@if (bestsellersLoading()) {
|
||||
<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 {
|
||||
<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) {
|
||||
<app-product-card
|
||||
[product]="product"
|
||||
@@ -80,10 +89,18 @@
|
||||
{{ 'home.viewAllNew' | translate }}
|
||||
</a>
|
||||
</div>
|
||||
@if (newArrivals().length === 0) {
|
||||
<p class="text-iled-grey-dark">{{ 'common.loading' | translate }}</p>
|
||||
@if (newArrivalsLoading()) {
|
||||
<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 {
|
||||
<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) {
|
||||
<app-product-card [product]="product" />
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { LucideLightbulb } from '@lucide/angular';
|
||||
import { I18nService } from '../../core/i18n/i18n.service';
|
||||
import { TranslatePipe } from '../../core/i18n/translate.pipe';
|
||||
import { ShellComponent, ProductCardComponent } from '../../shared/layout/layout.components';
|
||||
import { ProductCardSkeletonComponent } from '../../shared/product-card-skeleton.component';
|
||||
import { TrustStripComponent } from '../../shared/trust-strip.component';
|
||||
import { CatalogService } from '../../core/shop.services';
|
||||
import { ProductListItem } from '../../core/models';
|
||||
@@ -20,6 +21,7 @@ import { ProductListItem } from '../../core/models';
|
||||
imports: [
|
||||
ShellComponent,
|
||||
ProductCardComponent,
|
||||
ProductCardSkeletonComponent,
|
||||
TrustStripComponent,
|
||||
RouterLink,
|
||||
LucideLightbulb,
|
||||
@@ -33,6 +35,9 @@ export class HomeComponent {
|
||||
readonly #i18n = inject(I18nService);
|
||||
readonly bestsellers = signal<ProductListItem[]>([]);
|
||||
readonly newArrivals = signal<ProductListItem[]>([]);
|
||||
readonly bestsellersLoading = signal(true);
|
||||
readonly newArrivalsLoading = signal(true);
|
||||
readonly skeletonSlots = [0, 1, 2, 3];
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
@@ -42,11 +47,21 @@ export class HomeComponent {
|
||||
}
|
||||
|
||||
#load(): void {
|
||||
this.bestsellersLoading.set(true);
|
||||
this.newArrivalsLoading.set(true);
|
||||
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({
|
||||
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>
|
||||
|
||||
@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">
|
||||
|
||||
@@ -257,15 +288,23 @@
|
||||
|
||||
<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">
|
||||
|
||||
@for (item of related(); track item.id) {
|
||||
|
||||
<app-product-card [product]="item" />
|
||||
|
||||
@if (relatedLoading()) {
|
||||
<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 relatedSkeletonSlots; 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 (item of related(); track item.id) {
|
||||
<app-product-card [product]="item" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
</section>
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
StarRatingComponent,
|
||||
ProductCardComponent,
|
||||
} from '../../shared/layout/layout.components';
|
||||
import { ProductCardSkeletonComponent } from '../../shared/product-card-skeleton.component';
|
||||
import {
|
||||
CatalogService,
|
||||
CartService,
|
||||
@@ -48,6 +49,7 @@ import { ProductImagePipe } from '../../core/product-image.pipe';
|
||||
ShellComponent,
|
||||
StarRatingComponent,
|
||||
ProductCardComponent,
|
||||
ProductCardSkeletonComponent,
|
||||
FormsModule,
|
||||
DecimalPipe,
|
||||
I18nCurrencyPipe,
|
||||
@@ -75,11 +77,14 @@ export class ProductDetailComponent implements OnInit {
|
||||
|
||||
readonly product = signal<ProductDetail | null>(null);
|
||||
readonly related = signal<ProductListItem[]>([]);
|
||||
readonly relatedLoading = signal(true);
|
||||
readonly loading = signal(true);
|
||||
readonly reviews = signal<ProductReviews | null>(null);
|
||||
readonly selectedVariant = signal<ProductVariant | null>(null);
|
||||
readonly activeImage = signal<string | null>(null);
|
||||
readonly quantity = signal(1);
|
||||
readonly cartModalOpen = signal(false);
|
||||
readonly relatedSkeletonSlots = [0, 1, 2, 3];
|
||||
#slug = '';
|
||||
|
||||
reviewName = '';
|
||||
@@ -111,12 +116,16 @@ export class ProductDetailComponent implements OnInit {
|
||||
}
|
||||
|
||||
#loadProduct(slug: string): void {
|
||||
this.loading.set(true);
|
||||
this.relatedLoading.set(true);
|
||||
this.product.set(null);
|
||||
this.#catalog.getProduct(slug).subscribe({
|
||||
next: (p) => {
|
||||
this.product.set(p);
|
||||
const def = p.variants.find((v) => v.isDefault) ?? p.variants[0];
|
||||
this.selectedVariant.set(def ?? null);
|
||||
this.activeImage.set(p.images[0]?.url ?? null);
|
||||
this.loading.set(false);
|
||||
this.#seo.apply({
|
||||
title: `${p.name} — iLED`,
|
||||
description: p.shortDescription || p.description,
|
||||
@@ -125,9 +134,14 @@ export class ProductDetailComponent implements OnInit {
|
||||
type: 'product',
|
||||
});
|
||||
},
|
||||
error: () => this.loading.set(false),
|
||||
});
|
||||
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() } }}
|
||||
</h1>
|
||||
|
||||
@if (products().length === 0) {
|
||||
<div class="py-16 text-center">
|
||||
@if (loading()) {
|
||||
<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">
|
||||
{{ 'products.noResults' | translate }}
|
||||
</p>
|
||||
@@ -107,7 +117,7 @@
|
||||
</button>
|
||||
</div>
|
||||
} @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) {
|
||||
<app-product-card
|
||||
[product]="product"
|
||||
|
||||
@@ -15,13 +15,20 @@ import {
|
||||
ShellComponent,
|
||||
ProductCardComponent,
|
||||
} from '../../shared/layout/layout.components';
|
||||
import { ProductCardSkeletonComponent } from '../../shared/product-card-skeleton.component';
|
||||
import { CatalogService } from '../../core/shop.services';
|
||||
import { ProductFilterOptions, ProductListItem } from '../../core/models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-product-list',
|
||||
standalone: true,
|
||||
imports: [ShellComponent, ProductCardComponent, FormsModule, TranslatePipe],
|
||||
imports: [
|
||||
ShellComponent,
|
||||
ProductCardComponent,
|
||||
ProductCardSkeletonComponent,
|
||||
FormsModule,
|
||||
TranslatePipe,
|
||||
],
|
||||
templateUrl: './product-list.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
@@ -35,6 +42,8 @@ export class ProductListComponent implements OnInit {
|
||||
readonly filters = signal<ProductFilterOptions | null>(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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
(click)="handleBackdrop($event)"
|
||||
>
|
||||
<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()"
|
||||
>
|
||||
<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);
|
||||
}
|
||||
@@ -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%);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user