feat: implement catalog service and product attribute selector component for product details
This commit is contained in:
@@ -1,3 +1,5 @@
|
|||||||
export interface ApiResponse<T> {
|
export interface ApiResponse<T> {
|
||||||
data: T;
|
data: T;
|
||||||
|
message?: string;
|
||||||
|
errors?: Record<string, string[]>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,9 +35,14 @@ export class CatalogService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
getProducto(id: number): Observable<ProductDetail> {
|
getProducto(id: number, variantId?: number): Observable<ProductDetail> {
|
||||||
|
let params = new HttpParams();
|
||||||
|
if (variantId) {
|
||||||
|
params = params.set('variant_id', variantId);
|
||||||
|
}
|
||||||
|
|
||||||
return this.http
|
return this.http
|
||||||
.get<ApiResponse<ProductDetail>>(`${this.tenantApiUrl}/productos/${id}`)
|
.get<ApiResponse<ProductDetail>>(`${this.tenantApiUrl}/productos/${id}`, { params })
|
||||||
.pipe(map((response) => response.data));
|
.pipe(map((response) => response.data));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -77,6 +77,10 @@ export class ProductAttributeSelectorComponent {
|
|||||||
return availability;
|
return availability;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
public reset(): void {
|
||||||
|
this.initializeSelections(this.attributes(), this.defaultVariant());
|
||||||
|
}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const attributes = this.attributes();
|
const attributes = this.attributes();
|
||||||
|
|||||||
@@ -81,13 +81,21 @@
|
|||||||
class="product-detail__cta"
|
class="product-detail__cta"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
type="button"
|
type="button"
|
||||||
[disabled]="!selectedVariant()"
|
[disabled]="!selectedVariant() || variantLoading()"
|
||||||
>
|
>
|
||||||
|
@if (variantLoading()) {
|
||||||
|
<div class="spinner-border spinner-border-sm" role="status"></div>
|
||||||
|
} @else {
|
||||||
Agregar al carrito
|
Agregar al carrito
|
||||||
|
}
|
||||||
</app-button>
|
</app-button>
|
||||||
|
|
||||||
<app-button class="product-detail__cta" type="button" [disabled]="!selectedVariant()">
|
<app-button class="product-detail__cta" type="button" [disabled]="!selectedVariant() || variantLoading()">
|
||||||
|
@if (variantLoading()) {
|
||||||
|
<div class="spinner-border spinner-border-sm" role="status"></div>
|
||||||
|
} @else {
|
||||||
Comprar
|
Comprar
|
||||||
|
}
|
||||||
</app-button>
|
</app-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,10 +12,12 @@ import {
|
|||||||
PLATFORM_ID
|
PLATFORM_ID
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { CommonModule, isPlatformBrowser } from '@angular/common';
|
import { CommonModule, isPlatformBrowser } from '@angular/common';
|
||||||
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||||
import { Subscription } from 'rxjs';
|
import { Subscription } from 'rxjs';
|
||||||
|
|
||||||
import { CatalogService } from '../../../../core/services/catalog/catalog.service';
|
import { CatalogService } from '../../../../core/services/catalog/catalog.service';
|
||||||
|
import { ToastService } from '../../../../core/services/toast.service';
|
||||||
import {
|
import {
|
||||||
ProductAttribute,
|
ProductAttribute,
|
||||||
ProductAttributeOption,
|
ProductAttributeOption,
|
||||||
@@ -39,6 +41,8 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
|
|||||||
private readonly route = inject(ActivatedRoute);
|
private readonly route = inject(ActivatedRoute);
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
private readonly catalogService = inject(CatalogService);
|
private readonly catalogService = inject(CatalogService);
|
||||||
|
private readonly toastService = inject(ToastService);
|
||||||
|
private readonly attributeSelector = viewChild(ProductAttributeSelectorComponent);
|
||||||
private readonly carouselHost = viewChild<ElementRef<HTMLElement>>('carouselHost');
|
private readonly carouselHost = viewChild<ElementRef<HTMLElement>>('carouselHost');
|
||||||
private readonly descriptionBody = viewChild<ElementRef<HTMLElement>>('descriptionBody');
|
private readonly descriptionBody = viewChild<ElementRef<HTMLElement>>('descriptionBody');
|
||||||
private readonly platformId = inject(PLATFORM_ID);
|
private readonly platformId = inject(PLATFORM_ID);
|
||||||
@@ -63,6 +67,7 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
|
|||||||
return [];
|
return [];
|
||||||
});
|
});
|
||||||
protected readonly loading = signal(false);
|
protected readonly loading = signal(false);
|
||||||
|
protected readonly variantLoading = signal(false);
|
||||||
protected readonly error = signal<string | null>(null);
|
protected readonly error = signal<string | null>(null);
|
||||||
protected readonly selectedVariant = signal<ProductVariantMap | null>(null);
|
protected readonly selectedVariant = signal<ProductVariantMap | null>(null);
|
||||||
protected readonly quantity = signal(1);
|
protected readonly quantity = signal(1);
|
||||||
@@ -136,6 +141,39 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private loadProductVariant(productId: number, variantId: number): void {
|
||||||
|
this.variantLoading.set(true);
|
||||||
|
|
||||||
|
this.productSub?.unsubscribe();
|
||||||
|
this.productSub = this.catalogService.getProducto(productId, variantId).subscribe({
|
||||||
|
next: (prod) => {
|
||||||
|
this.product.set(prod);
|
||||||
|
this.variantLoading.set(false);
|
||||||
|
},
|
||||||
|
error: (err: HttpErrorResponse) => {
|
||||||
|
const errorMessage = err.error?.message || 'No pudimos cargar la variante seleccionada.';
|
||||||
|
this.toastService.danger(errorMessage);
|
||||||
|
this.variantLoading.set(false);
|
||||||
|
|
||||||
|
this.product.update((currentProduct) => {
|
||||||
|
if (!currentProduct) return null;
|
||||||
|
const updatedVariantsMap = currentProduct.variants_map.map((v) => {
|
||||||
|
if (v.variant_id === variantId) {
|
||||||
|
return { ...v, stock: 0 };
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...currentProduct,
|
||||||
|
variants_map: updatedVariantsMap
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
this.attributeSelector()?.reset();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
protected goBack(): void {
|
protected goBack(): void {
|
||||||
this.router.navigate(['/']);
|
this.router.navigate(['/']);
|
||||||
}
|
}
|
||||||
@@ -151,10 +189,19 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected onVariantChange(variant: ProductVariantMap | null): void {
|
protected onVariantChange(variant: ProductVariantMap | null): void {
|
||||||
|
if (this.selectedVariant()?.variant_id === variant?.variant_id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.selectedVariant.set(variant);
|
this.selectedVariant.set(variant);
|
||||||
if (variant && this.quantity() > variant.stock) {
|
if (variant && this.quantity() > variant.stock) {
|
||||||
this.quantity.set(Math.max(1, variant.stock));
|
this.quantity.set(Math.max(1, variant.stock));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentProduct = this.product();
|
||||||
|
if (variant && currentProduct && currentProduct.variant?.id !== variant.variant_id) {
|
||||||
|
this.loadProductVariant(currentProduct.id, variant.variant_id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected increaseQuantity(): void {
|
protected increaseQuantity(): void {
|
||||||
|
|||||||
Reference in New Issue
Block a user