26 Commits

Author SHA1 Message Date
3cefaf85d9 feat(qr-modal): implement QR modal component and integrate with tickets page 2026-07-21 14:55:46 -03:00
472dead0ff feat(tickets): separate active and inactive tickets, update selection logic and styles 2026-07-21 14:35:38 -03:00
fa8032c557 feat(ticket): enhance ticket component with disabled and expired states, update styles and tests 2026-07-21 14:25:52 -03:00
6c5e10feb2 feat(ticket): update ticket title styles and adjust QR button dimensions 2026-07-21 14:14:35 -03:00
63541a3593 feat(ticket): adjust QR button styles for better responsiveness 2026-07-21 14:12:50 -03:00
bc37a91c7f feat(tickets): implement tickets page with ticket component and service 2026-07-21 14:12:23 -03:00
54826a7f8a feat(ticket): add reusable ticket component with actions and styles 2026-07-21 13:37:31 -03:00
8f57af3cf5 feat(purchase-item): enhance layout for items without images and add description handling 2026-07-21 12:21:26 -03:00
da6614a9f8 feat(auth): update session handling on 401 responses and refactor error propagation 2026-07-21 10:36:48 -03:00
8e44fa5a46 feat(purchase-detail): update item details structure and handle null values in mapping 2026-07-21 10:22:32 -03:00
938d15bf83 feat(checkout): implement QR payment polling and validation logic 2026-07-21 09:52:19 -03:00
dafaa85480 feat(cart): implement add to cart functionality and display empty cart message 2026-07-20 17:11:06 -03:00
98e899c5bf feat(product-list): enhance layout handling with responsive design and computed properties 2026-07-20 16:59:57 -03:00
0c0d2b559d feat(catalog): refactor product and variant interfaces for improved structure and clarity 2026-07-20 16:46:17 -03:00
a233a93d16 feat(store-home-page): add margin styling for adjacent store sections 2026-07-20 16:24:24 -03:00
b0e97fc577 feat(store-home-page): refactor to use catalog structure and update product loading logic 2026-07-20 16:08:18 -03:00
5165ea0716 feat(product-list): implement pagination and update items to use paginated response 2026-07-20 14:39:12 -03:00
c0eb3369c5 feat(product-list): replace ProductCard with ProductColumnWithImage and update related components 2026-07-20 14:27:24 -03:00
625990f594 feat(product-vertical-with-cart-card): add reusable vertical product card component with cart functionality 2026-07-17 14:14:05 -03:00
d4c671dc7a refactor(product-row-card): move variant selection logic into constructor for better clarity 2026-07-17 13:47:40 -03:00
944c12a7f3 feat(product-row-card): update variant selection logic to default to first variant if none selected 2026-07-17 13:45:36 -03:00
9a4e2a03e9 feat(product-card): update title color for better visibility and readability
feat(quantity-selector): change background to inherit for consistent styling
2026-07-17 13:45:16 -03:00
40cc2b840d feat(product-row-card): add reusable product row card component with styling and functionality 2026-07-17 11:54:12 -03:00
45c2ee7a2f feat(store-header): update brand slot dimensions for improved layout 2026-07-16 16:07:41 -03:00
b64c627702 feat(checkout): update payment intent payload to use 'transfer_payer_dni' and remove placeholder from DNI input 2026-07-16 11:38:10 -03:00
fc425543ee feat(checkout): add DNI input handling for transfer payments and update related components 2026-07-16 11:28:33 -03:00
87 changed files with 3342 additions and 1077 deletions

View File

@@ -9,7 +9,8 @@
}
.store-layout__brand-slot {
min-width: 10rem;
min-width: 150px;
max-width:40px;
}
.store-layout__brand-logo-shell {

View File

@@ -1,4 +1,4 @@
import { HttpClient } from '@angular/common/http';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { computed, signal } from '@angular/core';
@@ -69,7 +69,7 @@ describe('authInterceptor', () => {
httpController.verify();
});
it('clears the local session when an authenticated request returns 401', () => {
it('propagates a 401 without clearing the local session', () => {
const authService = createAuthServiceStub('expired-token');
TestBed.configureTestingModule({
@@ -83,14 +83,21 @@ describe('authInterceptor', () => {
const client = TestBed.inject(HttpClient);
const httpController = TestBed.inject(HttpTestingController);
let receivedError: unknown;
let receivedStatus: number | null = null;
client.get(`${environment.url}me`).subscribe({
error: () => undefined
error: (error: HttpErrorResponse) => {
receivedError = error;
receivedStatus = error.status;
}
});
const request = httpController.expectOne(`${environment.url}me`);
request.flush({ message: 'Unauthorized' }, { status: 401, statusText: 'Unauthorized' });
expect(authService.clearSession).toHaveBeenCalledTimes(1);
expect(receivedError).toBeInstanceOf(HttpErrorResponse);
expect(receivedStatus).toBe(401);
expect(authService.clearSession).not.toHaveBeenCalled();
httpController.verify();
});
});

View File

@@ -1,12 +1,6 @@
import {
HttpErrorResponse,
HttpEvent,
HttpHandlerFn,
HttpInterceptorFn,
HttpRequest
} from '@angular/common/http';
import { HttpEvent, HttpHandlerFn, HttpInterceptorFn, HttpRequest } from '@angular/common/http';
import { inject } from '@angular/core';
import { Observable, catchError, throwError } from 'rxjs';
import { Observable } from 'rxjs';
import { AuthService } from './auth.service';
@@ -25,13 +19,5 @@ export const authInterceptor: HttpInterceptorFn = (
})
: request;
return next(authenticatedRequest).pipe(
catchError((error: unknown) => {
if (error instanceof HttpErrorResponse && error.status === 401 && token) {
authService.clearSession();
}
return throwError(() => error);
})
);
return next(authenticatedRequest);
};

View File

@@ -95,7 +95,7 @@ describe('AuthService', () => {
httpController.verify();
});
it('clears the session when bootstrap receives 401 from /me', async () => {
it('propagates a 401 from /me without clearing the session', async () => {
cookieStore['shopit.auth.token'] = 'expired-token';
TestBed.configureTestingModule({
@@ -116,11 +116,11 @@ describe('AuthService', () => {
request.flush({ message: 'Unauthorized' }, { status: 401, statusText: 'Unauthorized' });
await expect(bootstrapPromise).resolves.toBeUndefined();
await expect(bootstrapPromise).rejects.toMatchObject({ status: 401 });
expect(service.user()).toBeNull();
expect(service.token()).toBeNull();
expect(service.isAuthenticated()).toBe(false);
expect(cookieStore['shopit.auth.token']).toBeUndefined();
expect(service.token()).toBe('expired-token');
expect(service.isAuthenticated()).toBe(true);
expect(cookieStore['shopit.auth.token']).toBe('expired-token');
httpController.verify();
});
@@ -168,7 +168,7 @@ describe('AuthService', () => {
httpController.verify();
});
it('clears local session even when logout request fails', () => {
it('propagates logout errors without clearing the local session', () => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
@@ -202,9 +202,9 @@ describe('AuthService', () => {
expect(logoutRequest.request.method).toBe('POST');
logoutRequest.flush({ message: 'Unauthorized' }, { status: 401, statusText: 'Unauthorized' });
expect(service.token()).toBeNull();
expect(service.user()).toBeNull();
expect(cookieStore['shopit.auth.token']).toBeUndefined();
expect(service.token()).toBe('plain-text-token');
expect(service.user()?.email).toBe('ada@example.com');
expect(cookieStore['shopit.auth.token']).toBe('plain-text-token');
httpController.verify();
});

View File

@@ -1,4 +1,4 @@
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { HttpClient } from '@angular/common/http';
import { isPlatformBrowser, isPlatformServer } from '@angular/common';
import { computed, inject, Injectable, PLATFORM_ID, signal, TransferState, makeStateKey } from '@angular/core';
import { firstValueFrom, map, Observable, of, tap } from 'rxjs';
@@ -57,10 +57,7 @@ export class AuthService {
}
return this.http.post<void>(`${environment.url}logout`, {}).pipe(
tap({
next: () => this.clearSession(),
error: () => this.clearSession()
})
tap(() => this.clearSession())
);
}
@@ -78,18 +75,9 @@ export class AuthService {
return;
}
try {
const user = await firstValueFrom(this.loadCurrentUser());
if (isPlatformServer(this.platformId)) {
this.transferState.set(AUTH_USER_SSR_STATE_KEY, user);
}
} catch (error) {
if (this.isUnauthorizedError(error)) {
this.clearSession();
return;
}
throw error;
const user = await firstValueFrom(this.loadCurrentUser());
if (isPlatformServer(this.platformId)) {
this.transferState.set(AUTH_USER_SSR_STATE_KEY, user);
}
}
@@ -115,8 +103,4 @@ export class AuthService {
this.userState.set(user);
this.cookieService.set(AUTH_TOKEN_COOKIE_KEY, token);
}
private isUnauthorizedError(error: unknown): error is HttpErrorResponse {
return error instanceof HttpErrorResponse && error.status === 401;
}
}

View File

@@ -3,14 +3,12 @@ export interface CartItemProduct {
imagen: string | null;
}
export type BuyableType = 'variant' | 'bundle';
export interface CartItem {
id: number;
cantidad: number;
precio_unitario: string;
buyable_type: BuyableType;
buyable_id: number;
catalog_item_id: number;
variant_id: number | null;
product: CartItemProduct | null;
}

View File

@@ -21,8 +21,8 @@ describe('CartService', () => {
id: 1,
cantidad: 2,
precio_unitario: '10.00',
buyable_type: 'variant',
buyable_id: 10,
catalog_item_id: 5,
variant_id: 10,
product: {
nombre: 'Test Product (Size: M)',
imagen: null,
@@ -88,7 +88,7 @@ describe('CartService', () => {
});
it('should add item and update signal', () => {
service.addItem('variant', 10, 2).subscribe((res) => {
service.addItem(5, 10, 2).subscribe((res) => {
expect(res.data).toEqual(mockCart);
expect(service.cart()).toEqual(mockCart);
});
@@ -96,8 +96,8 @@ describe('CartService', () => {
const req = httpMock.expectOne('http://api.test/tenants/acme/cart/items');
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual({
buyable_type: 'variant',
buyable_id: 10,
catalog_item_id: 5,
variant_id: 10,
cantidad: 2,
});
expect(req.request.withCredentials).toBe(true);

View File

@@ -4,7 +4,7 @@ import { catchError, map, Observable, tap } from 'rxjs';
import { ApiResponse } from '../api-response.interface';
import { TenantService } from '../tenant.service';
import { BuyableType, Cart } from './cart.interface';
import { Cart } from './cart.interface';
@Injectable({
providedIn: 'root',
@@ -45,15 +45,15 @@ export class CartService {
}
addItem(
buyableType: BuyableType,
buyableId: number,
catalogItemId: number,
variantId: number | null,
cantidad: number,
): Observable<ApiResponse<Cart>> {
this.isUpdatingState.set(true);
return this.http
.post<
ApiResponse<Cart>
>(`${this.tenantApiUrl}/cart/items`, { buyable_type: buyableType, buyable_id: buyableId, cantidad }, { withCredentials: true })
>(`${this.tenantApiUrl}/cart/items`, { catalog_item_id: catalogItemId, variant_id: variantId, cantidad }, { withCredentials: true })
.pipe(
tap((response) => {
this.cartState.set(response.data);

View File

@@ -1,3 +1,5 @@
import { ApiPaginatedResponse } from '../api-paginated-response.interface';
export interface Product {
id: number;
category_id: number;
@@ -31,25 +33,59 @@ export interface ProductAttribute {
export type InventoryPolicy = 'tracked' | 'unlimited';
export interface ProductVariant {
export interface CatalogItemVariant {
id: number;
inventory_policy: InventoryPolicy;
cantidad_maxima: number | null;
cantidad_vendida: number;
definitions: Record<string, string>;
stock_tecnico: number | null;
values: Record<string, string>;
}
export interface SelectedCatalogItemVariant extends CatalogItemVariant {
images: string[];
}
export interface ProductVariantMap {
variant_id: number;
export interface CatalogItemDetail {
id: number;
category_id: number | null;
brand_id: number | null;
slug: string;
nombre: string;
descripcion: string | null;
precio: string;
category: string | null;
brand: string | null;
inventory_policy: InventoryPolicy;
cantidad_maxima: number | null;
cantidad_vendida: number;
attributes: Record<string, string>;
has_tickets: boolean;
minimum_use_date: string | null;
maximum_use_date: string | null;
attributes: ProductAttribute[];
variants: CatalogItemVariant[];
selected_variant?: SelectedCatalogItemVariant;
stock_tecnico?: number | null;
images?: string[];
}
export interface ProductDetail extends Product {
attributes: ProductAttribute[];
variants_map: ProductVariantMap[];
variant: ProductVariant | null;
export type CatalogProductLayout = 'row' | 'column_with_image' | 'column_with_cart';
export interface CatalogFeaturedItemVariant {
id: number;
stock_tecnico: number | null;
values: Record<string, string>;
}
export interface CatalogFeaturedItem {
id: number;
nombre: string;
descripcion?: string | null;
precio: number | string;
image?: string | null;
stock_tecnico?: number | null;
variants?: CatalogFeaturedItemVariant[];
}
export interface CatalogFeaturedGroup {
id: number;
title: string;
layout: CatalogProductLayout;
group_order: number;
items: ApiPaginatedResponse<CatalogFeaturedItem[]>;
}

View File

@@ -6,16 +6,17 @@ import { ApiPaginationQueryParams } from '../api-pagination-query-params.interfa
import { ApiPaginatedResponse } from '../api-paginated-response.interface';
import { ApiResponse } from '../api-response.interface';
import { TenantService } from '../tenant.service';
import { Product, ProductDetail } from './catalog.interface';
import {
CatalogFeaturedGroup,
CatalogFeaturedItem,
CatalogItemDetail,
Product,
} from './catalog.interface';
type HttpParamValue =
| string
| number
| boolean
| readonly (string | number | boolean)[];
type HttpParamValue = string | number | boolean | readonly (string | number | boolean)[];
@Injectable({
providedIn: 'root'
providedIn: 'root',
})
export class CatalogService {
private readonly http = inject(HttpClient);
@@ -25,24 +26,34 @@ export class CatalogService {
return this.tenantService.getTenantApiUrl();
}
getProductos(params?: ApiPaginationQueryParams): Observable<ApiPaginatedResponse<Product[]>> {
return this.http.get<ApiPaginatedResponse<Product[]>>(`${this.tenantApiUrl}/productos`, {
params: this.buildHttpParams(params),
});
}
getProductos(
params?: ApiPaginationQueryParams
): Observable<ApiPaginatedResponse<Product[]>> {
return this.http.get<ApiPaginatedResponse<Product[]>>(
`${this.tenantApiUrl}/productos`,
{ params: this.buildHttpParams(params) }
getCatalog(): Observable<CatalogFeaturedGroup[]> {
return this.http.get<CatalogFeaturedGroup[]>(`${this.tenantApiUrl}/catalog`);
}
getFeaturedGroupItems(
featuredGroupId: number,
params?: ApiPaginationQueryParams,
): Observable<ApiPaginatedResponse<CatalogFeaturedItem[]>> {
return this.http.get<ApiPaginatedResponse<CatalogFeaturedItem[]>>(
`${this.tenantApiUrl}/catalog/featured-groups/${featuredGroupId}/items`,
{ params: this.buildHttpParams(params) },
);
}
getProducto(id: number, variantId?: number): Observable<ProductDetail> {
getCatalogItem(id: number, variantId?: number): Observable<CatalogItemDetail> {
let params = new HttpParams();
if (variantId) {
params = params.set('variant_id', variantId);
}
return this.http
.get<ApiResponse<ProductDetail>>(`${this.tenantApiUrl}/productos/${id}`, { params })
.get<ApiResponse<CatalogItemDetail>>(`${this.tenantApiUrl}/catalog-items/${id}`, { params })
.pipe(map((response) => response.data));
}
@@ -59,7 +70,7 @@ export class CatalogService {
return acc;
},
{}
{},
);
return new HttpParams({ fromObject });

View File

@@ -24,7 +24,7 @@ export interface PurchaseSummaryResponse extends PurchaseStatusResponse {
export interface PurchaseDetailAttributeResponse {
name: string;
value: string | null;
value: string | number | boolean | null;
}
export interface PurchaseDetailItemResponse {
@@ -32,16 +32,15 @@ export interface PurchaseDetailItemResponse {
quantity: number;
unit_price: string;
line_total: string;
product: {
id: number;
source_catalog_item_id: number | null;
source_variant_id: number | null;
item_details: {
nombre: string;
descripcion: string | null;
slug: string;
imagen: string | null;
} | null;
variant: {
id: number;
attributes: PurchaseDetailAttributeResponse[];
} | null;
};
}
export interface PurchaseDetailResponse extends PurchaseStatusResponse {
@@ -52,10 +51,11 @@ export interface PurchaseDetailResponse extends PurchaseStatusResponse {
created_at: string | null;
payment_method: string | null;
dni: string | null;
transfer_payer_dni: string | null;
telefono: string | null;
nombre_apellido: string | null;
email: string | null;
items_source: 'purchase' | 'cart' | null;
items_source: 'purchase';
items: PurchaseDetailItemResponse[];
subtotal: string;
total: string;
@@ -84,7 +84,7 @@ export class CheckoutService {
async generatePaymentIntent(tenantCode: string, purchaseId: number, method: 'qr' | 'transfer' | 'telepagos', payerDni?: string): Promise<any> {
const payload: any = { method };
if (payerDni) {
payload.payer_dni = payerDni;
payload.transfer_payer_dni = payerDni;
}
const response = await firstValueFrom(
this.http.post<any>(`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/payment-intent`, payload)

View File

@@ -23,12 +23,9 @@ export class CookieService {
return null;
}
set(name: string, value: string, days = 7): void {
set(name: string, value: string): void {
if (isPlatformBrowser(this.platformId)) {
const date = new Date();
date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000);
const expires = `expires=${date.toUTCString()}`;
this.document.cookie = `${name}=${value};${expires};path=/;SameSite=Lax`;
this.document.cookie = `${name}=${value};path=/;SameSite=Lax`;
}
}

View File

@@ -1,37 +1,23 @@
import '@angular/compiler';
import { Component, inject } from '@angular/core';
import { TestBed, getTestBed } from '@angular/core/testing';
import {
BrowserTestingModule,
platformBrowserTesting
} from '@angular/platform-browser/testing';
import {
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { firstValueFrom } from 'rxjs';
import { ConfirmDeleteModalComponent } from '../../shared/components/confirm-delete-modal/confirm-delete-modal.component';
import { ConfirmModalComponent } from '../../shared/components/confirm-modal/confirm-modal.component';
import { QrModalComponent } from '../../shared/components/qr-modal/qr-modal.component';
import { SimpleModalComponent } from '../../shared/components/simple-modal/simple-modal.component';
import {
MODAL_DATA,
ModalRef,
ModalService
} from './modal.service';
import { MODAL_DATA, ModalRef, ModalService } from './modal.service';
@Component({
template: ''
template: '',
})
class FirstTestModalComponent {}
@Component({
template: ''
template: '',
})
class SecondTestModalComponent {}
@@ -40,10 +26,7 @@ describe('ModalService', () => {
beforeAll(() => {
try {
getTestBed().initTestEnvironment(
BrowserTestingModule,
platformBrowserTesting()
);
getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
} catch {
// Test environment may already be initialized by another setup entrypoint.
}
@@ -55,7 +38,7 @@ describe('ModalService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [ModalService]
providers: [ModalService],
});
service = TestBed.inject(ModalService);
@@ -66,7 +49,7 @@ describe('ModalService', () => {
title: 'Confirmar compra',
data: { productId: 10 },
size: 'lg',
closeOnBackdrop: false
closeOnBackdrop: false,
});
const activeModal = service.activeModal();
@@ -80,7 +63,7 @@ describe('ModalService', () => {
size: 'lg',
closeOnBackdrop: false,
closeOnEscape: true,
showCloseButton: true
showCloseButton: true,
});
});
@@ -115,7 +98,7 @@ describe('ModalService', () => {
firstRef.afterClosed$.subscribe(firstClosedSpy);
const secondRef = service.open(SecondTestModalComponent, {
title: 'Segundo modal'
title: 'Segundo modal',
});
expect(firstClosedSpy).toHaveBeenCalledWith(undefined);
@@ -126,7 +109,7 @@ describe('ModalService', () => {
it('injects MODAL_DATA and ModalRef into opened components through the host injector contract', () => {
const ref = service.open(DataTestModalComponent, {
data: { amount: 3 }
data: { amount: 3 },
});
const activeModal = service.activeModal();
@@ -138,7 +121,7 @@ describe('ModalService', () => {
it('opens the standard confirm modal with default labels', () => {
const result$ = service.openConfirm({
title: 'Confirmar compra',
content: 'Esto confirmara la compra actual.'
content: 'Esto confirmara la compra actual.',
});
const activeModal = service.activeModal();
@@ -150,19 +133,19 @@ describe('ModalService', () => {
data: {
content: 'Esto confirmara la compra actual.',
confirmLabel: 'Confirmar',
cancelLabel: 'Cancelar'
cancelLabel: 'Cancelar',
},
size: 'md',
closeOnBackdrop: true,
closeOnEscape: true,
showCloseButton: true
showCloseButton: true,
});
});
it('maps the confirm modal close result to true', async () => {
const result$ = service.openConfirm({
title: 'Confirmar compra',
content: 'Esto confirmara la compra actual.'
content: 'Esto confirmara la compra actual.',
});
const activeModal = service.activeModal();
const resultPromise = firstValueFrom(result$);
@@ -175,7 +158,7 @@ describe('ModalService', () => {
it('maps dismissing a confirm modal to false', async () => {
const result$ = service.openConfirmDelete({
title: 'Eliminar producto',
content: 'Se eliminara el producto.'
content: 'Se eliminara el producto.',
});
const activeModal = service.activeModal();
const resultPromise = firstValueFrom(result$);
@@ -194,7 +177,7 @@ describe('ModalService', () => {
size: 'lg',
closeOnBackdrop: false,
closeOnEscape: false,
showCloseButton: false
showCloseButton: false,
});
const activeModal = service.activeModal();
@@ -205,19 +188,19 @@ describe('ModalService', () => {
data: {
content: 'Se eliminara el producto.',
confirmLabel: 'Eliminar',
cancelLabel: 'Conservar'
cancelLabel: 'Conservar',
},
size: 'lg',
closeOnBackdrop: false,
closeOnEscape: false,
showCloseButton: false
showCloseButton: false,
});
});
it('opens the simple modal with default button label', () => {
service.openSimple({
title: 'Aviso',
content: 'Este es un aviso simple.'
content: 'Este es un aviso simple.',
});
const activeModal = service.activeModal();
@@ -227,19 +210,19 @@ describe('ModalService', () => {
title: 'Aviso',
data: {
content: 'Este es un aviso simple.',
buttonLabel: 'Entendido'
buttonLabel: 'Entendido',
},
size: 'md',
closeOnBackdrop: true,
closeOnEscape: true,
showCloseButton: true
showCloseButton: true,
});
});
it('maps the simple modal close result to undefined', async () => {
const result$ = service.openSimple({
title: 'Aviso',
content: 'Este es un aviso simple.'
content: 'Este es un aviso simple.',
});
const activeModal = service.activeModal();
const resultPromise = firstValueFrom(result$);
@@ -248,10 +231,35 @@ describe('ModalService', () => {
await expect(resultPromise).resolves.toBeUndefined();
});
it('opens the QR modal with the ticket information and compact size', () => {
service.openQr({
title: 'Entrada general',
id: 42,
date: '09 de octubre',
ticket: 'ticket-payload',
});
const activeModal = service.activeModal();
expect(activeModal?.component).toBe(QrModalComponent);
expect(activeModal?.config).toEqual({
title: 'Entrada general',
data: {
id: 42,
date: '09 de octubre',
ticket: 'ticket-payload',
},
size: 'qr',
closeOnBackdrop: true,
closeOnEscape: true,
showCloseButton: true,
});
});
});
@Component({
template: ''
template: '',
})
class DataTestModalComponent {
readonly data = inject(MODAL_DATA);

View File

@@ -2,14 +2,11 @@ import { InjectionToken, Injectable, Type, signal } from '@angular/core';
import { Observable, Subject, map } from 'rxjs';
import { ConfirmDeleteModalComponent } from '../../shared/components/confirm-delete-modal/confirm-delete-modal.component';
import { ConfirmModalComponent } from '../../shared/components/confirm-modal/confirm-modal.component';
import { QrModalComponent } from '../../shared/components/qr-modal/qr-modal.component';
import { SimpleModalComponent } from '../../shared/components/simple-modal/simple-modal.component';
export type ModalSize = 'sm' | 'md' | 'lg' | 'xl' | 'full';
export type ModalDismissReason =
| 'backdrop'
| 'escape'
| 'programmatic'
| 'replaced';
export type ModalSize = 'qr' | 'sm' | 'md' | 'lg' | 'xl' | 'full';
export type ModalDismissReason = 'backdrop' | 'escape' | 'programmatic' | 'replaced';
export interface ModalConfig<TData = unknown> {
title?: string;
@@ -20,11 +17,10 @@ export interface ModalConfig<TData = unknown> {
showCloseButton?: boolean;
}
export interface NormalizedModalConfig<TData = unknown>
extends Omit<
ModalConfig<TData>,
'size' | 'closeOnBackdrop' | 'closeOnEscape' | 'showCloseButton'
> {
export interface NormalizedModalConfig<TData = unknown> extends Omit<
ModalConfig<TData>,
'size' | 'closeOnBackdrop' | 'closeOnEscape' | 'showCloseButton'
> {
size: ModalSize;
closeOnBackdrop: boolean;
closeOnEscape: boolean;
@@ -37,8 +33,7 @@ export interface ConfirmModalData {
cancelLabel: string;
}
export interface ConfirmModalConfig
extends Omit<ModalConfig<ConfirmModalData>, 'data'> {
export interface ConfirmModalConfig extends Omit<ModalConfig<ConfirmModalData>, 'data'> {
content: string;
confirmLabel?: string;
cancelLabel?: string;
@@ -49,12 +44,24 @@ export interface SimpleModalData {
buttonLabel: string;
}
export interface SimpleModalConfig
extends Omit<ModalConfig<SimpleModalData>, 'data'> {
export interface SimpleModalConfig extends Omit<ModalConfig<SimpleModalData>, 'data'> {
content: string;
buttonLabel?: string;
}
export interface QrModalData {
id: string | number;
date: string;
ticket: string;
}
export interface QrModalConfig extends Omit<ModalConfig<QrModalData>, 'data' | 'title'> {
title: string;
id: string | number;
date: string;
ticket: string;
}
export interface ActiveModalState<TResult = unknown, TData = unknown> {
component: Type<unknown>;
config: NormalizedModalConfig<TData>;
@@ -70,25 +77,24 @@ const DEFAULT_MODAL_CONFIG: Pick<
size: 'md',
closeOnBackdrop: true,
closeOnEscape: true,
showCloseButton: true
showCloseButton: true,
};
const DEFAULT_CONFIRM_MODAL_LABELS = {
confirmLabel: 'Confirmar',
cancelLabel: 'Cancelar'
cancelLabel: 'Cancelar',
} satisfies Pick<ConfirmModalData, 'confirmLabel' | 'cancelLabel'>;
export class ModalRef<TResult = unknown> {
private readonly afterClosedSubject = new Subject<TResult | undefined>();
private closed = false;
readonly afterClosed$: Observable<TResult | undefined> =
this.afterClosedSubject.asObservable();
readonly afterClosed$: Observable<TResult | undefined> = this.afterClosedSubject.asObservable();
readonly dismissReason = signal<ModalDismissReason | null>(null);
constructor(
private readonly closeHandler: (result?: TResult) => void,
private readonly dismissHandler: (reason: ModalDismissReason) => void
private readonly dismissHandler: (reason: ModalDismissReason) => void,
) {}
close(result?: TResult): void {
@@ -120,7 +126,7 @@ export class ModalRef<TResult = unknown> {
}
@Injectable({
providedIn: 'root'
providedIn: 'root',
})
export class ModalService {
private readonly activeModalState = signal<ActiveModalState | null>(null);
@@ -128,35 +134,31 @@ export class ModalService {
open<TComponent, TResult = unknown, TData = unknown>(
component: Type<TComponent>,
config: ModalConfig<TData> = {}
config: ModalConfig<TData> = {},
): ModalRef<TResult> {
this.activeModalState()?.ref.dismiss('replaced');
let ref!: ModalRef<TResult>;
ref = new ModalRef<TResult>(
(result) => this.close(ref, result),
(reason) => this.dismiss(ref, reason)
(reason) => this.dismiss(ref, reason),
);
this.activeModalState.set({
component,
config: this.normalizeConfig(config),
ref: ref as ModalRef<unknown>
ref: ref as ModalRef<unknown>,
});
return ref;
}
openConfirm(config: ConfirmModalConfig): Observable<boolean> {
return this.openConfirmRef(config).afterClosed$.pipe(
map((result) => result === true)
);
return this.openConfirmRef(config).afterClosed$.pipe(map((result) => result === true));
}
openConfirmDelete(config: ConfirmModalConfig): Observable<boolean> {
return this.openConfirmDeleteRef(config).afterClosed$.pipe(
map((result) => result === true)
);
return this.openConfirmDeleteRef(config).afterClosed$.pipe(map((result) => result === true));
}
openConfirmRef(config: ConfirmModalConfig): ModalRef<boolean> {
@@ -164,23 +166,30 @@ export class ModalService {
}
openConfirmDeleteRef(config: ConfirmModalConfig): ModalRef<boolean> {
return this.open(
ConfirmDeleteModalComponent,
this.buildConfirmModalConfig(config)
);
return this.open(ConfirmDeleteModalComponent, this.buildConfirmModalConfig(config));
}
openSimple(config: SimpleModalConfig): Observable<void> {
return this.openSimpleRef(config).afterClosed$.pipe(
map(() => undefined)
);
return this.openSimpleRef(config).afterClosed$.pipe(map(() => undefined));
}
openSimpleRef(config: SimpleModalConfig): ModalRef<void> {
return this.open(
SimpleModalComponent,
this.buildSimpleModalConfig(config)
);
return this.open(SimpleModalComponent, this.buildSimpleModalConfig(config));
}
openQr(config: QrModalConfig): Observable<void> {
return this.openQrRef(config).afterClosed$.pipe(map(() => undefined));
}
openQrRef(config: QrModalConfig): ModalRef<void> {
const { title, id, date, ticket, size = 'qr', ...modalConfig } = config;
return this.open(QrModalComponent, {
...modalConfig,
title,
size,
data: { id, date, ticket },
});
}
private close<TResult>(ref: ModalRef<TResult>, result?: TResult): void {
@@ -194,7 +203,7 @@ export class ModalService {
private dismiss<TResult>(
ref: ModalRef<TResult>,
reason: ModalDismissReason = 'programmatic'
reason: ModalDismissReason = 'programmatic',
): void {
if (this.activeModalState()?.ref !== ref) {
return;
@@ -204,18 +213,14 @@ export class ModalService {
this.activeModalState.set(null);
}
private normalizeConfig<TData>(
config: ModalConfig<TData>
): NormalizedModalConfig<TData> {
private normalizeConfig<TData>(config: ModalConfig<TData>): NormalizedModalConfig<TData> {
return {
...DEFAULT_MODAL_CONFIG,
...config
...config,
};
}
private buildConfirmModalConfig(
config: ConfirmModalConfig
): ModalConfig<ConfirmModalData> {
private buildConfirmModalConfig(config: ConfirmModalConfig): ModalConfig<ConfirmModalData> {
const {
content,
confirmLabel = DEFAULT_CONFIRM_MODAL_LABELS.confirmLabel,
@@ -228,26 +233,20 @@ export class ModalService {
data: {
content,
confirmLabel,
cancelLabel
}
cancelLabel,
},
};
}
private buildSimpleModalConfig(
config: SimpleModalConfig
): ModalConfig<SimpleModalData> {
const {
content,
buttonLabel = 'Entendido',
...modalConfig
} = config;
private buildSimpleModalConfig(config: SimpleModalConfig): ModalConfig<SimpleModalData> {
const { content, buttonLabel = 'Entendido', ...modalConfig } = config;
return {
...modalConfig,
data: {
content,
buttonLabel
}
buttonLabel,
},
};
}
}

View File

@@ -3,9 +3,7 @@
<div class="card-body">
<span class="eyebrow">Componentes reutilizables</span>
<h1>Button + Input</h1>
<p class="lead mb-4">
Pagina dedicada a probar variantes de los componentes compartidos.
</p>
<p class="lead mb-4">Pagina dedicada a probar variantes de los componentes compartidos.</p>
<div class="button-showcase">
<div class="button-group">
@@ -26,9 +24,7 @@
<app-button variant="secondary" [disabled]="true">Secondary</app-button>
<app-button variant="danger" [disabled]="true">Danger</app-button>
<app-button variant="danger-secondary" [disabled]="true">
Danger secondary
</app-button>
<app-button variant="danger-secondary" [disabled]="true"> Danger secondary </app-button>
<app-button variant="cancel" [disabled]="true">Cancel</app-button>
</div>
</div>
@@ -60,13 +56,8 @@
<div class="button-group">
<h2>Toasts Persistentes (No se van)</h2>
<div class="button-grid">
<app-button (click)="triggerPersistentToast('info')">
Info Persistente
</app-button>
<app-button
variant="secondary"
(click)="triggerPersistentToast('success')"
>
<app-button (click)="triggerPersistentToast('info')"> Info Persistente </app-button>
<app-button variant="secondary" (click)="triggerPersistentToast('success')">
Success Persistente
</app-button>
<app-button variant="danger" (click)="triggerPersistentToast('danger')">
@@ -91,9 +82,7 @@
<app-button variant="danger-secondary" (click)="openWideModal()">
Abrir modal ancho
</app-button>
<app-button (click)="openSimpleModal()">
Abrir simple modal
</app-button>
<app-button (click)="openSimpleModal()"> Abrir simple modal </app-button>
</div>
<div class="modal-showcase__result" data-testid="modal-last-result">
{{ lastModalResult }}
@@ -106,9 +95,9 @@
<div class="icon-button-showcase">
<h2>Icon Buttons</h2>
<p class="text-muted mb-4">
Botones de icono pequenos con color base <code>#666666</code>,
deshabilitados con <code>#A0A0A0</code> y estados hover/active con color
de marca (a excepcion de trash que usa danger).
Botones de icono pequenos con color base <code>#666666</code>, deshabilitados con
<code>#A0A0A0</code> y estados hover/active con color de marca (a excepcion de trash que
usa danger).
</p>
<div class="icon-button-showcase__grid">
@@ -155,9 +144,7 @@
<div class="icon-button-showcase mt-4">
<h2>Cart Icons</h2>
<p class="text-muted mb-4">
Icono de carrito con badge opcional indicando cantidad.
</p>
<p class="text-muted mb-4">Icono de carrito con badge opcional indicando cantidad.</p>
<div class="icon-button-showcase__grid">
<div class="icon-button-showcase__row">
@@ -200,12 +187,7 @@
<div class="input-field">
<label for="number-input">Numero</label>
<app-input
id="number-input"
type="number"
placeholder="0"
[(value)]="numberValue"
/>
<app-input id="number-input" type="number" placeholder="0" [(value)]="numberValue" />
</div>
<div class="input-field">
@@ -220,12 +202,7 @@
<div class="input-field">
<label for="user-input">Usuario</label>
<app-input
id="user-input"
type="user"
placeholder="Nombre de usuario"
[(value)]="user"
/>
<app-input id="user-input" type="user" placeholder="Nombre de usuario" [(value)]="user" />
</div>
<div class="input-field">
@@ -250,22 +227,12 @@
<div class="input-field">
<label for="amount-input">Moneda</label>
<app-input
id="amount-input"
type="currency"
placeholder="0"
[(value)]="amount"
/>
<app-input id="amount-input" type="currency" placeholder="0" [(value)]="amount" />
</div>
<div class="input-field">
<label for="percent-input">Porcentaje</label>
<app-input
id="percent-input"
type="percent"
placeholder="0"
[(value)]="percent"
/>
<app-input id="percent-input" type="percent" placeholder="0" [(value)]="percent" />
</div>
<div class="input-field">
@@ -313,9 +280,7 @@
/>
</div>
<app-button variant="secondary" (click)="clearFile()">
Limpiar archivo
</app-button>
<app-button variant="secondary" (click)="clearFile()"> Limpiar archivo </app-button>
</div>
</div>
@@ -324,8 +289,7 @@
<div class="paginator-showcase">
<h2>Paginator</h2>
<p class="text-muted mb-0">
Componente shared para navegar por paginas con primera, anterior,
siguiente y ultima.
Componente shared para navegar por paginas con primera, anterior, siguiente y ultima.
</p>
<app-paginator
[page]="currentPage"
@@ -339,11 +303,11 @@
<div class="stepper-showcase">
<h2>Form Stepper</h2>
<p class="text-muted mb-4">
Stepper vertical reutilizable. Cada paso maneja sus propios botones de navegacion. No se puede avanzar si el paso no es valido.
Stepper vertical reutilizable. Cada paso maneja sus propios botones de navegacion. No se
puede avanzar si el paso no es valido.
</p>
<app-stepper #demoStepper>
<!-- Paso 1: Siempre valido -->
<app-step label="Informacion Personal" [isValid]="true">
<div class="row g-3">
@@ -353,7 +317,12 @@
</div>
<div class="col-md-6 input-field">
<label for="stepper-email">Email</label>
<app-input id="stepper-email" type="email" placeholder="mail@empresa.com" [(value)]="email" />
<app-input
id="stepper-email"
type="email"
placeholder="mail@empresa.com"
[(value)]="email"
/>
</div>
</div>
<div class="d-flex justify-content-end mt-4">
@@ -368,7 +337,12 @@
<div class="row g-3">
<div class="col-md-6 input-field">
<label for="stepper-password">Contraseña</label>
<app-input id="stepper-password" type="password-toggle" placeholder="********" [(value)]="password" />
<app-input
id="stepper-password"
type="password-toggle"
placeholder="********"
[(value)]="password"
/>
</div>
<div class="col-md-6 input-field">
<label for="stepper-amount">Presupuesto mensual</label>
@@ -377,9 +351,7 @@
</div>
<div class="stepper-validity-control mt-3 d-flex align-items-center gap-3">
<span class="text-muted small">
Simular validez del paso:
</span>
<span class="text-muted small"> Simular validez del paso: </span>
<app-button
[variant]="testStep2Valid ? 'secondary' : 'primary'"
(click)="testStep2Valid = !testStep2Valid"
@@ -418,7 +390,6 @@
</app-button>
</div>
</app-step>
</app-stepper>
</div>
@@ -454,7 +425,6 @@
</div>
}
</div>
</div>
</section>
@@ -607,17 +577,17 @@
<section class="component-demo card shadow-sm mt-4">
<div class="card-body">
<span class="eyebrow">Reusable Component</span>
<h2 class="mb-4">Tarjetas de Producto (ProductCard)</h2>
<h2 class="mb-4">Tarjetas de Producto (Column With Image)</h2>
<p class="text-muted mb-4">
Las tarjetas se adaptan al tamano del contenedor padre. A continuacion se
presentan dentro de una grilla de Bootstrap.
Las tarjetas se adaptan al tamano del contenedor padre. A continuacion se presentan dentro
de una grilla de Bootstrap.
</p>
<div class="row">
@for (product of testProducts; track product.title) {
<div class="col-12 col-md-6 col-lg-4 mb-4 d-flex align-items-stretch">
<app-product-card
<app-product-column-with-image
[imageUrl]="product.imageUrl"
[title]="product.title"
[originalPrice]="product.originalPrice"
@@ -631,16 +601,58 @@
</div>
</section>
<app-store-section
title="PRODUCTOS"
class="component-demo store-section-demo mt-5"
>
<section class="component-demo card shadow-sm mt-4">
<div class="card-body">
<span class="eyebrow">Reusable Component</span>
<h2 class="mb-4">Tarjetas de Producto (Row Layout)</h2>
<p class="text-muted mb-4">Diseño horizontal que ocupa el 100% del ancho disponible.</p>
<div class="row">
<div class="col-12 mb-4 d-flex align-items-stretch">
<app-product-row-card
[title]="testRowProduct.title"
[description]="testRowProduct.description"
[price]="testRowProduct.price"
[variants]="testRowProduct.variants"
(buy)="onProductBuy(testRowProduct.title)"
(addToCart)="triggerToast('success')"
/>
</div>
</div>
</div>
</section>
<section class="component-demo card shadow-sm mt-4">
<div class="card-body">
<span class="eyebrow">Reusable Component</span>
<h2 class="mb-4">Tarjetas de Producto (Vertical With Cart Layout)</h2>
<p class="text-muted mb-4">
Diseño vertical con selector de cantidad y acciones de compra y carrito.
</p>
<div class="row justify-content-center justify-content-md-start">
<div class="col-12 col-sm-9 col-md-6 col-lg-5 col-xl-4 mb-4 d-flex align-items-stretch">
<app-product-vertical-with-cart-card
[title]="testVerticalWithCartProduct.title"
[description]="testVerticalWithCartProduct.description"
[price]="testVerticalWithCartProduct.price"
(buy)="onProductBuy(testVerticalWithCartProduct.title)"
(addToCart)="triggerToast('success')"
/>
</div>
</div>
</div>
</section>
<app-store-section title="PRODUCTOS" class="component-demo store-section-demo mt-5">
<div class="row">
@for (product of testProducts; track product.title) {
<div
class="col-12 col-md-6 col-lg-4 mb-4 d-flex align-items-stretch store-section-demo__item"
>
<app-product-card
<app-product-column-with-image
[imageUrl]="product.imageUrl"
[title]="product.title"
[originalPrice]="product.originalPrice"
@@ -653,4 +665,3 @@
</div>
</app-store-section>
</main>

View File

@@ -130,7 +130,7 @@ h1 {
}
.store-section-demo__item {
app-product-card {
app-product-column-with-image {
width: 100%;
}
}

View File

@@ -1,14 +1,13 @@
import { Component, inject } from '@angular/core';
import { ButtonComponent } from '../../../../shared/components/button/button.component';
import {
CartComponent,
CartItemMock
} from '../../../../shared/components/cart/cart.component';
import { CartComponent, CartItemMock } from '../../../../shared/components/cart/cart.component';
import { CartIconComponent } from '../../../../shared/components/cart-icon/cart-icon.component';
import { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.component';
import { InputComponent } from '../../../../shared/components/input/input.component';
import { PaginatorComponent } from '../../../../shared/components/paginator/paginator.component';
import { ProductCardComponent } from '../../../../shared/components/product-card/product-card.component';
import { ProductColumnWithImageComponent } from '../../../../shared/components/product-column-with-image/product-column-with-image.component';
import { ProductRowCardComponent } from '../../../../shared/components/product-row-card/product-row-card.component';
import { ProductVerticalWithCartCardComponent } from '../../../../shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component';
import { StoreSectionComponent } from '../../../../shared/components/store-section/store-section.component';
import { ModalService } from '../../../../core/services/modal.service';
import { TenantService } from '../../../../core/services/tenant.service';
@@ -23,15 +22,17 @@ import { StepComponent } from '../../../../shared/components/stepper/step.compon
CartComponent,
InputComponent,
PaginatorComponent,
ProductCardComponent,
ProductColumnWithImageComponent,
ProductRowCardComponent,
ProductVerticalWithCartCardComponent,
StoreSectionComponent,
IconButtonComponent,
CartIconComponent,
StepperComponent,
StepComponent
StepComponent,
],
templateUrl: './reutilizables-test-page.component.html',
styleUrl: './reutilizables-test-page.component.scss'
styleUrl: './reutilizables-test-page.component.scss',
})
export class ReutilizablesTestPageComponent {
private readonly tenantService = inject(TenantService);
@@ -68,24 +69,41 @@ export class ReutilizablesTestPageComponent {
originalPrice: 95000,
discount: 20,
transferPrice: 74800,
imageUrl: '/images/pantalon-scuba.png'
imageUrl: '/images/pantalon-scuba.png',
},
{
title: 'Zapatillas de running azul',
originalPrice: 120000,
discount: 15,
transferPrice: 98000,
imageUrl: '/images/zapatillas-running.png'
imageUrl: '/images/zapatillas-running.png',
},
{
title: 'Remera basica blanca (Sin Descuento)',
originalPrice: 32000,
discount: null,
transferPrice: 30000,
imageUrl: null
}
imageUrl: null,
},
];
protected readonly testRowProduct = {
title: 'ENTRADA GENERAL',
description:
'Acceso total al predio. No incluye acceso a estacionamiento. Niños menores de 5 años ingresan gratis.',
price: 10000,
variants: [
{ label: '10 de Octubre', value: '10-oct' },
{ label: '11 de Octubre', value: '11-oct' },
],
};
protected readonly testVerticalWithCartProduct = {
title: 'PANCHO',
description: 'Pancho con aderezo a elección.',
price: 11500,
};
protected readonly cartMockItems: CartItemMock[] = [
{
imageUrl: null,
@@ -96,8 +114,8 @@ export class ReutilizablesTestPageComponent {
quantity: 1,
attributes: [
{ label: 'Color', value: 'Beige' },
{ label: 'Talle', value: 'S' }
]
{ label: 'Talle', value: 'S' },
],
},
{
imageUrl: null,
@@ -108,8 +126,8 @@ export class ReutilizablesTestPageComponent {
quantity: 1,
attributes: [
{ label: 'Color', value: 'Beige' },
{ label: 'Talle', value: 'S' }
]
{ label: 'Talle', value: 'S' },
],
},
{
imageUrl: null,
@@ -120,8 +138,8 @@ export class ReutilizablesTestPageComponent {
quantity: 1,
attributes: [
{ label: 'Color', value: 'Negro' },
{ label: 'Talle', value: 'XL' }
]
{ label: 'Talle', value: 'XL' },
],
},
{
imageUrl: null,
@@ -132,9 +150,9 @@ export class ReutilizablesTestPageComponent {
quantity: 2,
attributes: [
{ label: 'Color', value: 'Gris' },
{ label: 'Talle', value: 'M' }
]
}
{ label: 'Talle', value: 'M' },
],
},
];
protected onProductBuy(productTitle: string): void {
@@ -162,45 +180,32 @@ export class ReutilizablesTestPageComponent {
protected triggerToast(type: 'success' | 'danger' | 'info'): void {
if (type === 'success') {
this.toastService.success(
'Operacion exitosa. El producto se ha guardado correctamente.'
);
this.toastService.success('Operacion exitosa. El producto se ha guardado correctamente.');
} else if (type === 'danger') {
this.toastService.danger(
'Alerta de error. Hubo un problema al procesar la solicitud.'
);
this.toastService.danger('Alerta de error. Hubo un problema al procesar la solicitud.');
} else if (type === 'info') {
this.toastService.info(
'Informacion del sistema: Hay una nueva actualizacion disponible.'
);
this.toastService.info('Informacion del sistema: Hay una nueva actualizacion disponible.');
}
}
protected triggerPersistentToast(type: 'success' | 'danger' | 'info'): void {
if (type === 'success') {
this.toastService.success(
'Exito persistente. Esta alerta no se cerrara sola.',
0
);
this.toastService.success('Exito persistente. Esta alerta no se cerrara sola.', 0);
} else if (type === 'danger') {
this.toastService.danger(
'Error persistente. Por favor, atienda este problema y cierrelo manualmente.',
0
0,
);
} else if (type === 'info') {
this.toastService.info(
'Informacion persistente: Mantendremos este aviso en pantalla.',
0
);
this.toastService.info('Informacion persistente: Mantendremos este aviso en pantalla.', 0);
}
}
protected openBasicModal(): void {
this.openConfirmModal({
title: 'Confirmar accion',
content:
'Caso base para verificar apertura, cierre y devolucion de resultado.',
confirmLabel: 'Confirmar'
content: 'Caso base para verificar apertura, cierre y devolucion de resultado.',
confirmLabel: 'Confirmar',
});
}
@@ -209,18 +214,17 @@ export class ReutilizablesTestPageComponent {
title: 'Eliminar producto',
content:
'Esta accion eliminara el producto del catalogo. Podras volver a crearlo manualmente.',
confirmLabel: 'Eliminar'
confirmLabel: 'Eliminar',
});
}
protected openLockedModal(): void {
this.openConfirmModal({
title: 'Modal bloqueado',
content:
'Este modal no se cierra tocando el backdrop ni con la tecla Escape.',
content: 'Este modal no se cierra tocando el backdrop ni con la tecla Escape.',
confirmLabel: 'Entendido',
closeOnBackdrop: false,
closeOnEscape: false
closeOnEscape: false,
});
}
@@ -229,18 +233,20 @@ export class ReutilizablesTestPageComponent {
title: 'Modal ancho',
content: 'Demuestra una variante visual mas amplia para contenido mas pesado.',
confirmLabel: 'Seguir',
size: 'xl'
size: 'xl',
});
}
protected openSimpleModal(): void {
this.modalService.openSimple({
title: 'Mensaje del sistema',
content: 'Este es un mensaje simple del sistema que no requiere confirmación.',
buttonLabel: 'Entendido'
}).subscribe(() => {
this.lastModalResult = 'Simple modal cerrado';
});
this.modalService
.openSimple({
title: 'Mensaje del sistema',
content: 'Este es un mensaje simple del sistema que no requiere confirmación.',
buttonLabel: 'Entendido',
})
.subscribe(() => {
this.lastModalResult = 'Simple modal cerrado';
});
}
private openConfirmModal(config: Parameters<ModalService['openConfirm']>[0]): void {
@@ -249,13 +255,9 @@ export class ReutilizablesTestPageComponent {
});
}
private openConfirmDelete(
config: Parameters<ModalService['openConfirmDelete']>[0]
): void {
private openConfirmDelete(config: Parameters<ModalService['openConfirmDelete']>[0]): void {
this.modalService.openConfirmDelete(config).subscribe((confirmed) => {
this.lastModalResult = `Resultado: ${confirmed}`;
});
}
}

View File

@@ -27,13 +27,12 @@ describe('ProductAttributeSelectorComponent', () => {
it('keeps an unlimited option available when maximum quantity is null', () => {
const fixture = TestBed.createComponent(ProductAttributeSelectorComponent);
fixture.componentRef.setInput('attributes', [sizeAttribute]);
fixture.componentRef.setInput('variantsMap', [
fixture.componentRef.setInput('inventoryPolicy', 'unlimited');
fixture.componentRef.setInput('variants', [
{
variant_id: 1,
inventory_policy: 'unlimited',
cantidad_maxima: null,
cantidad_vendida: 0,
attributes: { size: 'S' },
id: 1,
stock_tecnico: null,
values: { size: 'S' },
},
]);
fixture.detectChanges();
@@ -48,20 +47,17 @@ describe('ProductAttributeSelectorComponent', () => {
it('disables tracked options without available stock', () => {
const fixture = TestBed.createComponent(ProductAttributeSelectorComponent);
fixture.componentRef.setInput('attributes', [sizeAttribute]);
fixture.componentRef.setInput('variantsMap', [
fixture.componentRef.setInput('inventoryPolicy', 'tracked');
fixture.componentRef.setInput('variants', [
{
variant_id: 1,
inventory_policy: 'tracked',
cantidad_maxima: 0,
cantidad_vendida: 0,
attributes: { size: 'S' },
id: 1,
stock_tecnico: 0,
values: { size: 'S' },
},
{
variant_id: 2,
inventory_policy: 'tracked',
cantidad_maxima: 2,
cantidad_vendida: 0,
attributes: { size: 'M' },
id: 2,
stock_tecnico: 2,
values: { size: 'M' },
},
]);
fixture.detectChanges();

View File

@@ -10,10 +10,10 @@ import {
} from '@angular/core';
import { CommonModule } from '@angular/common';
import {
CatalogItemVariant,
InventoryPolicy,
ProductAttribute,
ProductAttributeOption,
ProductVariant,
ProductVariantMap,
} from '../../../../core/services/catalog/catalog.interface';
@Component({
@@ -26,16 +26,17 @@ import {
})
export class ProductAttributeSelectorComponent {
public attributes = input<ProductAttribute[]>([]);
public variantsMap = input<ProductVariantMap[]>([]);
public defaultVariant = input<ProductVariant | null>(null);
public variants = input<CatalogItemVariant[]>([]);
public selectedVariant = input<CatalogItemVariant | null>(null);
public inventoryPolicy = input.required<InventoryPolicy>();
public variantChange = output<ProductVariantMap | null>();
public variantChange = output<CatalogItemVariant | null>();
protected readonly selectedAttributeOptions = signal<Record<number, number>>({});
protected readonly availableOptions = computed(() => {
const selections = this.selectedAttributeOptions();
const variants = this.variantsMap();
const variants = this.variants();
const attributes = this.attributes();
const availability: Record<number, Record<number, boolean>> = {};
@@ -48,10 +49,7 @@ export class ProductAttributeSelectorComponent {
const isAvailable = variants.some((variant) => {
if (!this.isVariantAvailable(variant)) return false;
const variantAttrValue = this.getDefaultVariantAttributeValue(
attribute,
variant.attributes,
);
const variantAttrValue = this.getDefaultVariantAttributeValue(attribute, variant.values);
if (!variantAttrValue || this.normalizeText(variantAttrValue) !== optionNormalized) {
return false;
}
@@ -65,10 +63,7 @@ export class ProductAttributeSelectorComponent {
const selectedNormalized = this.normalizeText(
selectedOption.value || selectedOption.label,
);
const vAttrValue = this.getDefaultVariantAttributeValue(
otherAttr,
variant.attributes,
);
const vAttrValue = this.getDefaultVariantAttributeValue(otherAttr, variant.values);
if (!vAttrValue || this.normalizeText(vAttrValue) !== selectedNormalized) {
return false;
}
@@ -86,13 +81,13 @@ export class ProductAttributeSelectorComponent {
});
public reset(): void {
this.initializeSelections(this.attributes(), this.defaultVariant());
this.initializeSelections(this.attributes(), this.selectedVariant());
}
constructor() {
effect(() => {
const attributes = this.attributes();
const defaultVariant = this.defaultVariant();
const defaultVariant = this.selectedVariant();
untracked(() => {
this.initializeSelections(attributes, defaultVariant);
@@ -101,11 +96,11 @@ export class ProductAttributeSelectorComponent {
effect(() => {
const selections = this.selectedAttributeOptions();
const variantsMap = this.variantsMap();
const variants = this.variants();
const attributes = this.attributes();
untracked(() => {
this.emitMatchingVariant(selections, variantsMap, attributes);
this.emitMatchingVariant(selections, variants, attributes);
});
});
}
@@ -137,7 +132,7 @@ export class ProductAttributeSelectorComponent {
private initializeSelections(
attributes: ProductAttribute[],
defaultVariant: ProductVariant | null,
defaultVariant: CatalogItemVariant | null,
): void {
const selections: Record<number, number> = {};
@@ -153,13 +148,13 @@ export class ProductAttributeSelectorComponent {
private findDefaultOptionId(
attribute: ProductAttribute,
variant: ProductVariant | null,
variant: CatalogItemVariant | null,
): number | null {
if (!variant) {
return null;
}
const defaultValue = this.getDefaultVariantAttributeValue(attribute, variant.definitions);
const defaultValue = this.getDefaultVariantAttributeValue(attribute, variant.values);
if (!defaultValue) {
return null;
}
@@ -206,8 +201,8 @@ export class ProductAttributeSelectorComponent {
.toLowerCase();
}
private isVariantAvailable(variant: ProductVariantMap): boolean {
return variant.inventory_policy === 'unlimited' || (variant.cantidad_maxima ?? 0) > 0;
private isVariantAvailable(variant: CatalogItemVariant): boolean {
return this.inventoryPolicy() === 'unlimited' || (variant.stock_tecnico ?? 0) > 0;
}
private findFirstHexValue(value: unknown): string | null {
@@ -240,10 +235,10 @@ export class ProductAttributeSelectorComponent {
private emitMatchingVariant(
selections: Record<number, number>,
variantsMap: ProductVariantMap[],
variants: CatalogItemVariant[],
attributes: ProductAttribute[],
): void {
if (attributes.length === 0 || variantsMap.length === 0) {
if (attributes.length === 0 || variants.length === 0) {
this.variantChange.emit(null);
return;
}
@@ -267,12 +262,12 @@ export class ProductAttributeSelectorComponent {
return;
}
const matchingVariant = variantsMap.find((vMap) => {
const matchingVariant = variants.find((variant) => {
return attributes.every((attr) => {
const selectedValue = selectedValuesById[attr.id];
const vMapValue = this.getDefaultVariantAttributeValue(attr, vMap.attributes);
if (!vMapValue) return false;
return this.normalizeText(vMapValue) === selectedValue;
const variantValue = this.getDefaultVariantAttributeValue(attr, variant.values);
if (!variantValue) return false;
return this.normalizeText(variantValue) === selectedValue;
});
});

View File

@@ -1,8 +1,11 @@
<div class="sidebar-container">
<h2 class="sidebar-title">MI CUENTA</h2>
<div class="divider"></div>
<nav class="sidebar-nav">
<a routerLink="/mi-cuenta/datos-personales" routerLinkActive="active" class="nav-link">Datos Personales</a>
<a routerLink="/mi-cuenta/compras" routerLinkActive="active" class="nav-link">Mis compras</a>
</nav>
</div>
<div class="sidebar-container">
<h2 class="sidebar-title">MI CUENTA</h2>
<div class="divider"></div>
<nav class="sidebar-nav">
<a routerLink="/mi-cuenta/datos-personales" routerLinkActive="active" class="nav-link"
>Datos Personales</a
>
<a routerLink="/mi-cuenta/compras" routerLinkActive="active" class="nav-link">Mis compras</a>
<a routerLink="/mi-cuenta/tickets" routerLinkActive="active" class="nav-link">Mis tickets</a>
</nav>
</div>

View File

@@ -1,15 +1,15 @@
<article class="w-100 py-3 rounded-0 cart-item">
<div class="position-relative overflow-hidden cart-item-media">
@if (item.discountPercentage) {
<span class="position-absolute top-0 start-0 z-1 rounded-0 cart-item-discount-badge">-{{ item.discountPercentage }}%</span>
}
<article class="w-100 py-3 rounded-0 cart-item" [class.cart-item--without-image]="!item.imageUrl">
@if (item.imageUrl) {
<div class="position-relative overflow-hidden cart-item-media">
@if (item.discountPercentage) {
<span class="position-absolute top-0 start-0 z-1 rounded-0 cart-item-discount-badge"
>-{{ item.discountPercentage }}%</span
>
}
@if (item.imageUrl) {
<img class="w-100 h-100 object-fit-cover d-block" [src]="item.imageUrl" [alt]="item.title" />
} @else {
<div class="w-100 h-100 cart-item-placeholder" aria-hidden="true"></div>
}
</div>
</div>
}
<div class="d-flex flex-column justify-content-between w-100 h-100 py-1 cart-item-content">
<div class="d-grid cart-item-top-row align-items-start">
@@ -17,33 +17,57 @@
<div class="cart-item-prices">
@if (item.originalPrice) {
<span class="text-decoration-line-through cart-item-original-price">$ {{ item.originalPrice }}</span>
<span class="text-decoration-line-through cart-item-original-price"
>$ {{ item.originalPrice }}</span
>
}
<span class="fw-bold cart-item-discounted-price">$ {{ item.price }}</span>
</div>
</div>
<div
class="d-grid cart-item-attributes"
[class.cart-item-attributes--quantity-only]="item.attributes.length === 0"
>
<div class="cart-item-attribute">
<span class="cart-item-attribute-label">Cantidad: </span>
<span class="fw-bold">{{ item.quantity }} unidad{{ item.quantity === 1 ? '' : 'es' }}</span>
@if (item.imageUrl) {
<div
class="d-grid cart-item-attributes"
[class.cart-item-attributes--quantity-only]="item.attributes.length === 0"
>
<div class="cart-item-attribute">
<span class="cart-item-attribute-label">Cantidad: </span>
<span class="fw-bold"
>{{ item.quantity }} unidad{{ item.quantity === 1 ? '' : 'es' }}</span
>
</div>
@for (attribute of item.attributes; track attribute.label + attribute.value) {
<div class="cart-item-attribute">
<span class="cart-item-attribute-label">{{ attribute.label }}: </span>
<span class="fw-bold">{{ attribute.value }}</span>
</div>
}
</div>
@for (attribute of item.attributes; track attribute.label + attribute.value) {
<div class="cart-item-attribute">
<span class="cart-item-attribute-label">{{ attribute.label }}: </span>
<span class="fw-bold">{{ attribute.value }}</span>
@if (item.transferPrice) {
<div class="cart-item-transfer-price">
<span class="cart-item-price-label">Precio por transferencia</span>
<span class="fw-bold">${{ item.transferPrice }}</span>
</div>
}
</div>
} @else {
<p class="m-0 cart-item-description">{{ item.description }}</p>
@if (item.transferPrice) {
<div class="cart-item-transfer-price">
<span class="cart-item-price-label">Precio por transferencia</span>
<span class="fw-bold">${{ item.transferPrice }}</span>
<div class="d-grid cart-item-bottom-details">
@for (attribute of item.attributes; track attribute.label + attribute.value) {
<div class="cart-item-attribute">
<span class="cart-item-attribute-label">{{ attribute.label }}: </span>
<span class="fw-bold">{{ attribute.value }}</span>
</div>
}
<div class="cart-item-attribute">
<span class="cart-item-attribute-label">Cantidad: </span>
<span class="fw-bold"
>{{ item.quantity }} unidad{{ item.quantity === 1 ? '' : 'es' }}</span
>
</div>
</div>
}
</div>

View File

@@ -13,6 +13,10 @@
gap: 0.75rem;
background-color: transparent;
position: relative;
&--without-image {
grid-template-columns: minmax(0, 1fr);
}
}
.cart-item-media {
@@ -44,12 +48,6 @@
line-height: 1;
}
.cart-item-placeholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #e0e0e0, #f5f5f5);
}
.cart-item-content {
gap: 0.5rem;
}
@@ -116,3 +114,16 @@
display: grid;
justify-items: end;
}
.cart-item-description {
color: #666666;
font-size: 9px;
font-weight: 325;
line-height: 1.35;
white-space: pre-line;
}
.cart-item-bottom-details {
gap: 0.125rem;
justify-items: end;
}

View File

@@ -0,0 +1,68 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { beforeEach, describe, expect, it } from 'vitest';
import { PurchaseItem, PurchaseItemViewModel } from './purchase-item';
describe('PurchaseItem', () => {
let fixture: ComponentFixture<PurchaseItem>;
const item: PurchaseItemViewModel = {
id: 1,
title: 'Entrada general',
description: 'Acceso al evento durante toda la jornada.',
imageUrl: null,
quantity: 2,
attributes: [{ label: 'Turno', value: 'Tarde' }],
price: '20000.00',
transferPrice: '18000.00',
};
beforeEach(async () => {
await TestBed.configureTestingModule({ imports: [PurchaseItem] }).compileComponents();
fixture = TestBed.createComponent(PurchaseItem);
});
it('uses the image-free layout and moves description and item details', () => {
fixture.componentInstance.item = item;
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(
element.querySelector('.cart-item')?.classList.contains('cart-item--without-image'),
).toBe(true);
expect(element.querySelector('.cart-item-media')).toBeNull();
expect(element.querySelector('.cart-item-description')?.textContent?.trim()).toBe(
item.description,
);
expect(element.querySelector('.cart-item-bottom-details')?.textContent).toContain(
'Turno: Tarde',
);
expect(element.querySelector('.cart-item-bottom-details')?.textContent).toContain(
'Cantidad: 2 unidades',
);
expect(element.querySelector('.cart-item-transfer-price')).toBeNull();
});
it('keeps the existing layout when the product has an image', () => {
fixture.componentInstance.item = {
...item,
imageUrl: 'https://example.com/product.png',
};
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(
element.querySelector('.cart-item')?.classList.contains('cart-item--without-image'),
).toBe(false);
expect(element.querySelector('.cart-item-media img')).not.toBeNull();
expect(element.querySelector('.cart-item-description')).toBeNull();
expect(element.querySelector('.cart-item-attributes')?.textContent).toContain(
'Cantidad: 2 unidades',
);
expect(element.querySelector('.cart-item-transfer-price')?.textContent).toContain(
'Precio por transferencia',
);
});
});

View File

@@ -3,6 +3,7 @@ import { Component, Input } from '@angular/core';
export type PurchaseItemViewModel = {
id: number;
title: string;
description: string | null;
imageUrl: string | null;
quantity: number;
attributes: Array<{

View File

@@ -2,7 +2,10 @@ import { CommonModule } from '@angular/common';
import { Component, OnInit, inject, signal } from '@angular/core';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { CheckoutService, PurchaseDetailResponse } from '../../../../../../core/services/checkout.service';
import {
CheckoutService,
PurchaseDetailResponse,
} from '../../../../../../core/services/checkout.service';
import { TenantService } from '../../../../../../core/services/tenant.service';
import { ToastService } from '../../../../../../core/services/toast.service';
import { PurchaseItem, PurchaseItemViewModel } from '../../components/purchase-item/purchase-item';
@@ -79,12 +82,13 @@ export class PurchaseDetailPage implements OnInit {
private mapItems(purchase: PurchaseDetailResponse): PurchaseItemViewModel[] {
return purchase.items.map((item) => ({
id: item.id,
title: item.product?.nombre ?? 'Producto sin nombre',
imageUrl: item.product?.imagen ?? null,
title: item.item_details.nombre || 'Producto sin nombre',
description: item.item_details.descripcion,
imageUrl: item.item_details.imagen,
quantity: item.quantity,
attributes: (item.variant?.attributes ?? []).map((attribute) => ({
attributes: item.item_details.attributes.map((attribute) => ({
label: attribute.name,
value: attribute.value,
value: attribute.value === null ? null : String(attribute.value),
})),
price: item.line_total,
originalPrice: null,

View File

@@ -0,0 +1,49 @@
<article class="ticket" [class.ticket--disabled]="disabled()" [attr.aria-disabled]="disabled()">
@if (!disabled()) {
<label class="ticket__selection">
<input
type="checkbox"
[checked]="selected()"
(change)="selected.set($any($event.target).checked)"
/>
<span class="visually-hidden">Seleccionar ticket {{ title() }}</span>
</label>
}
<div class="ticket__details">
<h3 class="ticket__title">{{ title() }}</h3>
<p class="ticket__id">ID: {{ ticketId() }}</p>
</div>
@if (date() || expired()) {
<div class="ticket__status">
@if (expired()) {
<span class="ticket__expired">VENCIDO</span>
}
@if (date(); as ticketDate) {
<time class="ticket__date">{{ ticketDate }}</time>
}
</div>
}
<div class="ticket__actions">
<app-button
variant="primary"
buttonClass="ticket__qr-button"
[disabled]="disabled()"
(click)="viewQr.emit()"
>
<i class="fa-solid fa-qrcode" aria-hidden="true"></i>
<span>Ver QR</span>
</app-button>
@if (!disabled()) {
<app-icon-button
variant="download"
ariaLabel="Descargar ticket"
(clicked)="download.emit()"
/>
<app-icon-button variant="share" ariaLabel="Compartir ticket" (clicked)="share.emit()" />
}
</div>
</article>

View File

@@ -0,0 +1,132 @@
:host {
display: block;
width: 100%;
}
.ticket {
display: grid;
grid-template-columns: auto minmax(0, 1fr) 78px auto;
align-items: center;
width: 100%;
min-height: 76px;
gap: 16px;
padding: 16px 0;
color: #666666;
}
.ticket__selection {
display: flex;
margin: 0;
cursor: pointer;
}
.ticket__selection input {
width: 21px;
height: 21px;
margin: 0;
accent-color: var(--tenant-primary, #009933);
cursor: pointer;
}
.ticket__details {
min-width: 0;
}
.ticket__title,
.ticket__id {
overflow: hidden;
margin: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
.ticket__title {
font-size: 13px;
font-weight: 700;
line-height: 1.25;
text-transform: uppercase;
}
.ticket__id {
margin-top: 3px;
font-size: 12px;
line-height: 1.4;
}
.ticket__date {
display: block;
font-size: 10px;
line-height: 1.4;
white-space: nowrap;
}
.ticket__status {
text-align: center;
}
.ticket__expired {
display: block;
font-size: 10px;
font-weight: 700;
line-height: 1.4;
text-transform: uppercase;
}
.ticket__actions {
display: flex;
align-items: center;
gap: 8px;
}
:host ::ng-deep button.ticket__qr-button {
width: 115px;
min-width: 0;
max-width: 115px;
min-height: 36px;
padding-inline: 14px;
}
.ticket--disabled {
grid-template-columns: minmax(0, 1fr) 78px auto;
color: #8a8a8a;
}
:host ::ng-deep .ticket--disabled button.ticket__qr-button:disabled {
--bs-btn-disabled-bg: #8a8a8a;
--bs-btn-disabled-border-color: #8a8a8a;
--bs-btn-disabled-color: #ffffff;
--bs-btn-disabled-opacity: 1;
}
.ticket__selection input:focus-visible {
outline: 2px solid var(--tenant-primary, #009933);
outline-offset: 2px;
}
@media (max-width: 767.98px) {
.ticket {
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 10px;
}
.ticket__status {
grid-column: 2;
}
.ticket__actions {
grid-column: 3;
grid-row: 1 / span 2;
}
.ticket--disabled {
grid-template-columns: minmax(0, 1fr) auto;
}
.ticket--disabled .ticket__status {
grid-column: 1;
}
.ticket--disabled .ticket__actions {
grid-column: 2;
}
}

View File

@@ -0,0 +1,64 @@
import { TestBed } from '@angular/core/testing';
import { describe, expect, it, vi } from 'vitest';
import { TicketComponent } from './ticket.component';
describe('TicketComponent', () => {
it('renders its data and emits its actions', async () => {
await TestBed.configureTestingModule({ imports: [TicketComponent] }).compileComponents();
const fixture = TestBed.createComponent(TicketComponent);
fixture.componentRef.setInput('title', 'ENTRADA GENERAL');
fixture.componentRef.setInput('ticketId', '0000000000');
fixture.componentRef.setInput('date', '09 de octubre');
const viewQr = vi.fn();
const download = vi.fn();
const share = vi.fn();
fixture.componentInstance.viewQr.subscribe(viewQr);
fixture.componentInstance.download.subscribe(download);
fixture.componentInstance.share.subscribe(share);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(element.textContent).toContain('ENTRADA GENERAL');
expect(element.textContent).toContain('ID: 0000000000');
element.querySelector<HTMLButtonElement>('app-button button')?.click();
element.querySelector<HTMLButtonElement>('[aria-label="Descargar ticket"]')?.click();
element.querySelector<HTMLButtonElement>('[aria-label="Compartir ticket"]')?.click();
expect(viewQr).toHaveBeenCalledOnce();
expect(download).toHaveBeenCalledOnce();
expect(share).toHaveBeenCalledOnce();
});
it('renders the expired disabled state without secondary actions', async () => {
await TestBed.configureTestingModule({ imports: [TicketComponent] }).compileComponents();
const fixture = TestBed.createComponent(TicketComponent);
fixture.componentRef.setInput('ticketId', '0000000000');
fixture.componentRef.setInput('date', '09 de octubre');
fixture.componentRef.setInput('disabled', true);
fixture.componentRef.setInput('expired', true);
const viewQr = vi.fn();
fixture.componentInstance.viewQr.subscribe(viewQr);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const ticket = element.querySelector<HTMLElement>('.ticket');
const qrButton = element.querySelector<HTMLButtonElement>('app-button button');
expect(ticket?.classList.contains('ticket--disabled')).toBe(true);
expect(ticket?.getAttribute('aria-disabled')).toBe('true');
expect(element.textContent).toContain('VENCIDO');
expect(qrButton?.disabled).toBe(true);
expect(element.querySelector('[aria-label="Descargar ticket"]')).toBeNull();
expect(element.querySelector('[aria-label="Compartir ticket"]')).toBeNull();
expect(element.querySelector('input[type="checkbox"]')).toBeNull();
qrButton?.click();
expect(viewQr).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,24 @@
import { ChangeDetectionStrategy, Component, input, model, output } from '@angular/core';
import { ButtonComponent } from '../../../../../../../../shared/components/button/button.component';
import { IconButtonComponent } from '../../../../../../../../shared/components/icon-button/icon-button.component';
@Component({
selector: 'app-ticket',
imports: [ButtonComponent, IconButtonComponent],
templateUrl: './ticket.component.html',
styleUrl: './ticket.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TicketComponent {
readonly title = input('ENTRADA GENERAL');
readonly ticketId = input.required<string | number>();
readonly date = input<string | null>(null);
readonly disabled = input(false);
readonly expired = input(false);
readonly selected = model(false);
readonly viewQr = output<void>();
readonly download = output<void>();
readonly share = output<void>();
}

View File

@@ -0,0 +1,33 @@
import { HttpClient } from '@angular/common/http';
import { inject, Injectable } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { TenantService } from '../../../../../../core/services/tenant.service';
export interface TicketResponse {
id: number;
tenant_code: string;
ticket: string;
name: string;
description: string | null;
source_catalog_item_id: number | null;
source_variant_id: number | null;
starts_at: string | null;
expires_at: string | null;
used_at: string | null;
is_valid: boolean;
is_expired: boolean;
is_used: boolean;
}
@Injectable()
export class TicketService {
private readonly http = inject(HttpClient);
private readonly tenantService = inject(TenantService);
getTickets(): Promise<TicketResponse[]> {
return firstValueFrom(
this.http.get<{ data: TicketResponse[] }>(`${this.tenantService.getTenantApiUrl()}/tickets`),
).then((response) => response.data ?? []);
}
}

View File

@@ -0,0 +1,65 @@
<section class="tickets-page">
<h1 class="tickets-page__title">MIS TICKETS</h1>
<div class="tickets-list" aria-busy="{{ isLoading() }}">
<header class="tickets-list__header">
<label class="tickets-list__select-all">
<input
type="checkbox"
[checked]="allSelected()"
[disabled]="activeTickets().length === 0"
(change)="toggleAll($any($event.target).checked)"
/>
<span>TICKETS ACTIVOS</span>
</label>
<div class="tickets-list__bulk-actions">
<span class="tickets-list__bulk-action">
<app-icon-button variant="download" ariaLabel="Descargar tickets seleccionados" />
<small>Descargar</small>
</span>
<span class="tickets-list__bulk-action">
<app-icon-button variant="share" ariaLabel="Compartir tickets seleccionados" />
<small>Compartir</small>
</span>
</div>
</header>
@if (isLoading()) {
@for (item of [1, 2, 3, 4]; track item) {
<div class="tickets-list__skeleton" aria-hidden="true"></div>
}
} @else if (tickets().length) {
@for (ticket of activeTickets(); track ticket.id) {
<app-ticket
[title]="ticket.name"
[ticketId]="ticket.id"
[date]="formatDate(ticket)"
[selected]="isSelected(ticket.id)"
(selectedChange)="toggleTicket(ticket.id, $event)"
(viewQr)="viewQr(ticket)"
/>
}
@if (inactiveTickets().length) {
<section class="tickets-list__inactive" aria-labelledby="inactive-tickets-title">
<header class="tickets-list__header tickets-list__header--inactive">
<h2 id="inactive-tickets-title">TICKETS INACTIVOS</h2>
</header>
@for (ticket of inactiveTickets(); track ticket.id) {
<app-ticket
[title]="ticket.name"
[ticketId]="ticket.id"
[date]="formatDate(ticket)"
[disabled]="true"
[expired]="ticket.is_expired"
/>
}
</section>
}
} @else {
<p class="tickets-list__empty">Aún no tenés tickets disponibles.</p>
}
</div>
</section>

View File

@@ -0,0 +1,96 @@
.tickets-page {
width: 100%;
max-width: 720px;
}
.tickets-page__title {
margin: 0 0 20px;
color: #a0a0a0;
font-size: 15px;
font-weight: 700;
}
.tickets-list__header {
display: flex;
min-height: 58px;
align-items: center;
justify-content: space-between;
padding-bottom: 12px;
border-bottom: 1px solid #dddddd;
}
.tickets-list__header--inactive {
justify-content: flex-start;
}
.tickets-list__header--inactive h2 {
margin: 0;
color: #a0a0a0;
font-size: 14px;
font-weight: 700;
}
.tickets-list__inactive {
margin-top: 44px;
}
.tickets-list__select-all {
display: flex;
align-items: center;
gap: 16px;
margin: 0;
color: #a0a0a0;
cursor: pointer;
font-size: 14px;
font-weight: 700;
}
.tickets-list__select-all input {
width: 21px;
height: 21px;
margin: 0;
accent-color: var(--tenant-primary, #009933);
}
.tickets-list__bulk-actions {
display: flex;
gap: 18px;
}
.tickets-list__bulk-action {
display: flex;
flex-direction: column;
align-items: center;
color: #a0a0a0;
font-size: 11px;
}
.tickets-list app-ticket {
border-bottom: 1px solid #dddddd;
}
.tickets-list__empty {
padding: 32px 0;
color: #666666;
text-align: center;
}
.tickets-list__skeleton {
height: 77px;
border-bottom: 1px solid #dddddd;
background: linear-gradient(90deg, transparent, #f3f3f3, transparent);
background-size: 200% 100%;
animation: loading 1.4s infinite;
}
@keyframes loading {
to {
background-position: -200% 0;
}
}
@media (max-width: 767.98px) {
.tickets-page__title {
margin-top: 24px;
}
}

View File

@@ -0,0 +1,118 @@
import { By } from '@angular/platform-browser';
import { TestBed } from '@angular/core/testing';
import { describe, expect, it, vi } from 'vitest';
import { ToastService } from '../../../../../../core/services/toast.service';
import { ModalService } from '../../../../../../core/services/modal.service';
import { TicketComponent } from './components/ticket/ticket.component';
import { TicketResponse, TicketService } from './ticket.service';
import { TicketsPage } from './tickets-page';
const ticket = (overrides: Partial<TicketResponse>): TicketResponse => ({
id: 1,
tenant_code: 'tenant',
ticket: 'ticket-1',
name: 'Entrada general',
description: null,
source_catalog_item_id: null,
source_variant_id: null,
starts_at: '2026-10-09T00:00:00.000Z',
expires_at: null,
used_at: null,
is_valid: true,
is_expired: false,
is_used: false,
...overrides,
});
describe('TicketsPage', () => {
it('separates expired and used tickets into the disabled inactive list', async () => {
const tickets = [
ticket({ id: 1 }),
ticket({ id: 2, is_valid: false, is_expired: true }),
ticket({ id: 3, is_valid: false, is_used: true, used_at: '2026-10-10T00:00:00.000Z' }),
];
await TestBed.configureTestingModule({
imports: [TicketsPage],
providers: [{ provide: ToastService, useValue: { danger: vi.fn() } }],
})
.overrideComponent(TicketsPage, {
set: {
providers: [
{ provide: TicketService, useValue: { getTickets: () => Promise.resolve(tickets) } },
],
},
})
.compileComponents();
const fixture = TestBed.createComponent(TicketsPage);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const ticketComponents = fixture.debugElement.queryAll(By.directive(TicketComponent));
const [activeTicket, expiredTicket, usedTicket] = ticketComponents.map(
(element) => element.componentInstance as TicketComponent,
);
expect(ticketComponents).toHaveLength(3);
expect(activeTicket.disabled()).toBe(false);
expect(expiredTicket.disabled()).toBe(true);
expect(expiredTicket.expired()).toBe(true);
expect(usedTicket.disabled()).toBe(true);
expect(usedTicket.expired()).toBe(false);
expect(fixture.nativeElement.textContent).toContain('TICKETS INACTIVOS');
const page = fixture.nativeElement as HTMLElement;
const inactiveElements = Array.from(
page.querySelectorAll<HTMLElement>('.tickets-list__inactive app-ticket'),
);
expect(inactiveElements).toHaveLength(2);
inactiveElements.forEach((element) => {
expect(element.querySelector('.ticket--disabled')).not.toBeNull();
expect(element.querySelector<HTMLButtonElement>('app-button button')?.disabled).toBe(true);
expect(element.querySelector('input[type="checkbox"]')).toBeNull();
expect(element.querySelector('[aria-label="Descargar ticket"]')).toBeNull();
expect(element.querySelector('[aria-label="Compartir ticket"]')).toBeNull();
});
});
it('opens the QR modal with the selected ticket data', async () => {
const modalService = { openQr: vi.fn() };
await TestBed.configureTestingModule({
imports: [TicketsPage],
providers: [
{ provide: ToastService, useValue: { danger: vi.fn() } },
{ provide: ModalService, useValue: modalService },
],
})
.overrideComponent(TicketsPage, {
set: {
providers: [
{
provide: TicketService,
useValue: { getTickets: () => Promise.resolve([ticket({})]) },
},
],
},
})
.compileComponents();
const fixture = TestBed.createComponent(TicketsPage);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const page = fixture.nativeElement as HTMLElement;
page.querySelector<HTMLButtonElement>('app-ticket app-button button')?.click();
expect(modalService.openQr).toHaveBeenCalledWith({
title: 'Entrada general',
id: 1,
date: '09-octubre',
ticket: 'ticket-1',
});
});
});

View File

@@ -0,0 +1,89 @@
import { Component, computed, inject, OnInit, signal } from '@angular/core';
import { ModalService } from '../../../../../../core/services/modal.service';
import { ToastService } from '../../../../../../core/services/toast.service';
import { IconButtonComponent } from '../../../../../../shared/components/icon-button/icon-button.component';
import { TicketComponent } from './components/ticket/ticket.component';
import { TicketResponse, TicketService } from './ticket.service';
@Component({
selector: 'app-tickets-page',
imports: [IconButtonComponent, TicketComponent],
providers: [TicketService],
templateUrl: './tickets-page.html',
styleUrl: './tickets-page.scss',
})
export class TicketsPage implements OnInit {
private readonly ticketService = inject(TicketService);
private readonly toastService = inject(ToastService);
private readonly modalService = inject(ModalService);
protected readonly tickets = signal<TicketResponse[]>([]);
protected readonly activeTickets = computed(() =>
this.tickets().filter((ticket) => !this.isInactive(ticket)),
);
protected readonly inactiveTickets = computed(() =>
this.tickets().filter((ticket) => this.isInactive(ticket)),
);
protected readonly selectedIds = signal<Set<number>>(new Set());
protected readonly isLoading = signal(true);
protected readonly allSelected = computed(
() =>
this.activeTickets().length > 0 && this.selectedIds().size === this.activeTickets().length,
);
async ngOnInit(): Promise<void> {
try {
this.tickets.set(await this.ticketService.getTickets());
} catch {
this.toastService.danger('Hubo un error al cargar los tickets');
} finally {
this.isLoading.set(false);
}
}
protected toggleAll(selected: boolean): void {
this.selectedIds.set(
selected ? new Set(this.activeTickets().map((ticket) => ticket.id)) : new Set(),
);
}
protected toggleTicket(ticketId: number, selected: boolean): void {
this.selectedIds.update((current) => {
const next = new Set(current);
selected ? next.add(ticketId) : next.delete(ticketId);
return next;
});
}
protected isSelected(ticketId: number): boolean {
return this.selectedIds().has(ticketId);
}
protected isInactive(ticket: TicketResponse): boolean {
return ticket.is_expired || ticket.is_used;
}
protected formatDate(ticket: TicketResponse): string | null {
const value = ticket.starts_at ?? ticket.expires_at;
if (!value) return null;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return null;
return new Intl.DateTimeFormat('es-AR', {
day: '2-digit',
month: 'long',
timeZone: 'UTC',
}).format(date);
}
protected viewQr(ticket: TicketResponse): void {
this.modalService.openQr({
title: ticket.name,
id: ticket.id,
date: this.formatDate(ticket) ?? '',
ticket: ticket.ticket,
});
}
}

View File

@@ -16,13 +16,18 @@
[selectedPaymentMethod]="selectedPaymentMethod()"
[copiedTransferField]="copiedTransferField()"
[transferAccount]="transferAccount()"
[transferDni]="transferDni()"
[isGeneratingIntent]="isPaymentLoading()"
[qrData]="qrData()"
[qrPaymentStatus]="qrPaymentStatus()"
[isCheckingQrPayment]="isCheckingQrPayment()"
[transferValidationStatus]="transferValidationStatus()"
(paymentMethodChange)="selectPaymentMethod($event)"
(copyTransferValue)="copyTransferValue($event.field, $event.value)"
(cancelStep)="onCancel()"
(generateTransferIntent)="generateTransferIntent($event)"
(complete)="onComplete()"
(retryQrPolling)="retryQrPolling()"
/>
</app-step>
</app-stepper>

View File

@@ -0,0 +1,200 @@
import { signal } from '@angular/core';
import { getTestBed, TestBed } from '@angular/core/testing';
import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
import { Router } from '@angular/router';
import { of } from 'rxjs';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { AuthService } from '../../../../core/services/auth/auth.service';
import { CartService } from '../../../../core/services/cart/cart.service';
import { CheckoutService } from '../../../../core/services/checkout.service';
import { TenantService } from '../../../../core/services/tenant.service';
import { CheckoutPageComponent } from './checkout-page.component';
describe('CheckoutPageComponent payment validation', () => {
let checkoutServiceStub: {
generatePaymentIntent: ReturnType<typeof vi.fn>;
getPurchase: ReturnType<typeof vi.fn>;
};
let cartServiceStub: {
cart: ReturnType<typeof signal>;
isUpdating: ReturnType<typeof signal<boolean>>;
loadCart: ReturnType<typeof vi.fn>;
clearCart: ReturnType<typeof vi.fn>;
};
let routerStub: { navigate: ReturnType<typeof vi.fn> };
beforeAll(() => {
try {
getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
} catch {
// Test environment may already be initialized by another setup entrypoint.
}
});
beforeEach(async () => {
vi.useFakeTimers();
checkoutServiceStub = {
generatePaymentIntent: vi.fn().mockResolvedValue({
qr_data: { qr_code: 'qr-value' },
}),
getPurchase: vi.fn().mockResolvedValue({ status: 'pending_payment' }),
};
cartServiceStub = {
cart: signal({
id: 10,
tenant_codigo: 'tenant-test',
status: 'active',
items: [],
subtotal: '0.00',
}),
isUpdating: signal(false),
loadCart: vi.fn().mockReturnValue(of({})),
clearCart: vi.fn(),
};
cartServiceStub.clearCart.mockImplementation(() => {
cartServiceStub.cart.set({
id: null,
tenant_codigo: 'tenant-test',
status: 'active',
items: [],
subtotal: '0.00',
});
});
routerStub = { navigate: vi.fn() };
await TestBed.configureTestingModule({
imports: [CheckoutPageComponent],
providers: [
{ provide: CheckoutService, useValue: checkoutServiceStub },
{ provide: CartService, useValue: cartServiceStub },
{ provide: TenantService, useValue: { tenant: signal({ codigo: 'tenant-test' }) } },
{ provide: AuthService, useValue: { user: signal(null) } },
{ provide: Router, useValue: routerStub },
],
})
.overrideComponent(CheckoutPageComponent, { set: { template: '' } })
.compileComponents();
});
afterEach(() => {
vi.useRealTimers();
TestBed.resetTestingModule();
});
function createComponent(): any {
const fixture = TestBed.createComponent(CheckoutPageComponent);
fixture.detectChanges();
fixture.componentInstance['createdPurchaseId'].set(25);
return { fixture, component: fixture.componentInstance as any };
}
it('polls QR after five seconds and navigates only when payment is paid', async () => {
checkoutServiceStub.getPurchase
.mockResolvedValueOnce({ status: 'pending_payment' })
.mockResolvedValueOnce({ status: 'paid' });
const { component } = createComponent();
await component.selectPaymentMethod('qr');
expect(checkoutServiceStub.getPurchase).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(5_000);
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(1);
expect(routerStub.navigate).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(5_000);
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(2);
expect(cartServiceStub.clearCart).toHaveBeenCalledOnce();
expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25]);
expect(checkoutServiceStub.generatePaymentIntent).toHaveBeenCalledTimes(1);
});
it('shows the QR verification state while each status request is in progress', async () => {
let resolvePurchase!: (purchase: { status: string }) => void;
checkoutServiceStub.getPurchase.mockReturnValue(
new Promise((resolve) => {
resolvePurchase = resolve;
}),
);
const { fixture, component } = createComponent();
const statusRequest = component.checkQrPayment(component.qrPollingRunId);
expect(component.isCheckingQrPayment()).toBe(true);
resolvePurchase({ status: 'pending_payment' });
await statusRequest;
expect(component.isCheckingQrPayment()).toBe(false);
fixture.destroy();
});
it('stops QR polling after five minutes and can restart it', async () => {
const { component } = createComponent();
await component.selectPaymentMethod('qr');
await vi.advanceTimersByTimeAsync(300_000);
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(60);
expect(component.qrPaymentStatus()).toBe('timed_out');
component.retryQrPolling();
expect(component.qrPaymentStatus()).toBe('waiting');
await vi.advanceTimersByTimeAsync(5_000);
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(61);
});
it('cancels QR polling when payment method changes or component is destroyed', async () => {
const first = createComponent();
await first.component.selectPaymentMethod('qr');
await first.component.selectPaymentMethod('transfer');
await vi.advanceTimersByTimeAsync(10_000);
expect(checkoutServiceStub.getPurchase).not.toHaveBeenCalled();
const second = createComponent();
await second.component.selectPaymentMethod('qr');
second.fixture.destroy();
await vi.advanceTimersByTimeAsync(10_000);
expect(checkoutServiceStub.getPurchase).not.toHaveBeenCalled();
});
it('checks a transfer once and redirects to purchase status while pending', async () => {
const { component } = createComponent();
component.selectedPaymentMethod.set('transfer');
await component.onComplete();
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(1);
expect(component.transferValidationStatus()).toBe('pending');
expect(cartServiceStub.clearCart).not.toHaveBeenCalled();
expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25]);
await vi.advanceTimersByTimeAsync(30_000);
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(1);
await component.onComplete();
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(1);
});
it('navigates after a transfer is confirmed as paid', async () => {
checkoutServiceStub.getPurchase.mockResolvedValue({ status: 'paid' });
const { component } = createComponent();
component.selectedPaymentMethod.set('transfer');
await component.onComplete();
expect(cartServiceStub.clearCart).toHaveBeenCalledOnce();
expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25]);
});
it('shows a retryable state when transfer validation fails', async () => {
vi.spyOn(console, 'error').mockImplementation(() => undefined);
checkoutServiceStub.getPurchase.mockRejectedValue(new Error('network error'));
const { component } = createComponent();
component.selectedPaymentMethod.set('transfer');
await component.onComplete();
expect(component.transferValidationStatus()).toBe('error');
expect(cartServiceStub.clearCart).not.toHaveBeenCalled();
expect(routerStub.navigate).not.toHaveBeenCalled();
});
});

View File

@@ -4,6 +4,7 @@ import {
computed,
effect,
inject,
OnDestroy,
OnInit,
signal,
untracked,
@@ -28,8 +29,10 @@ import {
CheckoutForm,
PaymentMethod,
PaymentMethodOption,
QrPaymentStatus,
TransferAccount,
TransferField,
TransferValidationStatus,
} from './checkout-page.models';
@Component({
@@ -46,13 +49,21 @@ import {
styleUrl: './checkout-page.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CheckoutPageComponent implements OnInit {
export class CheckoutPageComponent implements OnInit, OnDestroy {
private readonly formBuilder = inject(FormBuilder);
private readonly cartService = inject(CartService);
private readonly router = inject(Router);
private readonly tenantService = inject(TenantService);
private readonly checkoutService = inject(CheckoutService);
private readonly authService = inject(AuthService);
private readonly qrPollingIntervalMs = 5_000;
private readonly qrPollingMaxAttempts = 60;
private qrPollingTimeoutId: ReturnType<typeof setTimeout> | null = null;
private qrPollingAttempts = 0;
private qrPollingRunId = 0;
private paymentMethodRequestId = 0;
private navigationStarted = false;
@ViewChild(StepperComponent) stepper!: StepperComponent;
@@ -87,6 +98,7 @@ export class CheckoutPageComponent implements OnInit {
protected readonly selectedPaymentMethod = signal<PaymentMethod>('qr');
protected readonly copiedTransferField = signal<TransferField | null>(null);
protected readonly transferAccount = signal<TransferAccount | null>(null);
protected readonly transferDni = signal<string>('');
protected readonly isCreatingPurchase = signal(false);
protected readonly createdPurchaseId = signal<number | null>(null);
@@ -95,6 +107,9 @@ export class CheckoutPageComponent implements OnInit {
() => this.isGeneratingIntent() || this.cartService.isUpdating(),
);
protected readonly qrData = signal<string | null>(null);
protected readonly qrPaymentStatus = signal<QrPaymentStatus>('idle');
protected readonly isCheckingQrPayment = signal(false);
protected readonly transferValidationStatus = signal<TransferValidationStatus>('idle');
constructor() {
effect(() => {
@@ -104,7 +119,7 @@ export class CheckoutPageComponent implements OnInit {
untracked(() => {
const purchaseId = this.createdPurchaseId();
if (cart && purchaseId) {
if (cart && purchaseId && !this.navigationStarted) {
// Trigger payment intent generation when cart changes and we are on the payment step
void this.selectPaymentMethod(this.selectedPaymentMethod());
}
@@ -132,6 +147,10 @@ export class CheckoutPageComponent implements OnInit {
this.cartService.loadCart().subscribe();
}
ngOnDestroy(): void {
this.stopQrPolling();
}
private mapCartItemToMock(item: CartItem): CartItemMock {
const fullName = item.product?.nombre ?? '';
let product = fullName;
@@ -201,11 +220,24 @@ export class CheckoutPageComponent implements OnInit {
}
protected onCancel(): void {
this.stopQrPolling();
void this.router.navigate(['/']);
}
protected async selectPaymentMethod(method: PaymentMethod): Promise<void> {
if (this.navigationStarted) {
return;
}
this.stopQrPolling();
this.qrPaymentStatus.set('idle');
this.transferValidationStatus.set('idle');
this.selectedPaymentMethod.set(method);
const requestId = ++this.paymentMethodRequestId;
if (method === 'qr') {
this.qrData.set(null);
}
const purchaseId = this.createdPurchaseId();
const tenant = this.tenantService.tenant();
@@ -229,7 +261,12 @@ export class CheckoutPageComponent implements OnInit {
);
if (method === 'qr' && response.qr_data?.qr_code) {
if (requestId !== this.paymentMethodRequestId || this.selectedPaymentMethod() !== 'qr') {
return;
}
this.qrData.set(response.qr_data.qr_code);
this.startQrPolling();
}
} catch (error) {
console.error('Failed to generate payment intent:', error);
@@ -246,6 +283,8 @@ export class CheckoutPageComponent implements OnInit {
return;
}
this.transferDni.set(dni);
this.transferValidationStatus.set('idle');
this.isGeneratingIntent.set(true);
try {
const response = await this.checkoutService.generatePaymentIntent(
@@ -291,16 +330,140 @@ export class CheckoutPageComponent implements OnInit {
const purchaseId = this.createdPurchaseId();
const tenant = this.tenantService.tenant();
if (!purchaseId || !tenant) {
if (
!purchaseId ||
!tenant ||
this.navigationStarted ||
this.transferValidationStatus() === 'checking'
) {
return;
}
this.transferValidationStatus.set('checking');
try {
await this.checkoutService.completePurchase(tenant.codigo, purchaseId);
this.cartService.clearCart();
void this.router.navigate(['/checkout/status', purchaseId]);
const purchase = await this.checkoutService.getPurchase(tenant.codigo, purchaseId);
if (purchase.status === 'paid') {
this.handleConfirmedPayment(purchaseId);
return;
}
if (purchase.status === 'pending_payment') {
this.transferValidationStatus.set('pending');
this.navigateToPurchaseStatus(purchaseId);
return;
}
this.transferValidationStatus.set('pending');
} catch (error) {
console.error('Failed to complete purchase:', error);
console.error('Failed to validate transfer payment:', error);
this.transferValidationStatus.set('error');
}
}
protected retryQrPolling(): void {
if (this.selectedPaymentMethod() === 'qr' && this.qrData()) {
this.startQrPolling();
}
}
private startQrPolling(): void {
this.stopQrPolling();
this.qrPollingAttempts = 0;
this.qrPaymentStatus.set('waiting');
const runId = this.qrPollingRunId;
this.scheduleQrPoll(runId);
}
private scheduleQrPoll(runId: number): void {
this.qrPollingTimeoutId = setTimeout(() => {
this.qrPollingTimeoutId = null;
void this.checkQrPayment(runId);
}, this.qrPollingIntervalMs);
}
private async checkQrPayment(runId: number): Promise<void> {
if (runId !== this.qrPollingRunId) {
return;
}
const purchaseId = this.createdPurchaseId();
const tenant = this.tenantService.tenant();
if (!purchaseId || !tenant || this.selectedPaymentMethod() !== 'qr') {
this.stopQrPolling();
return;
}
this.qrPollingAttempts += 1;
this.isCheckingQrPayment.set(true);
try {
const purchase = await this.checkoutService.getPurchase(tenant.codigo, purchaseId);
if (runId !== this.qrPollingRunId) {
return;
}
if (purchase.status === 'paid') {
this.handleConfirmedPayment(purchaseId);
return;
}
if (purchase.status === 'rejected' || purchase.status === 'cancelled') {
this.stopQrPolling();
this.qrPaymentStatus.set('failed');
return;
}
} catch (error) {
console.error('Failed to validate QR payment:', error);
} finally {
if (runId === this.qrPollingRunId) {
this.isCheckingQrPayment.set(false);
}
}
if (runId !== this.qrPollingRunId) {
return;
}
if (this.qrPollingAttempts >= this.qrPollingMaxAttempts) {
this.stopQrPolling();
this.qrPaymentStatus.set('timed_out');
return;
}
this.scheduleQrPoll(runId);
}
private stopQrPolling(): void {
this.qrPollingRunId += 1;
this.isCheckingQrPayment.set(false);
if (this.qrPollingTimeoutId !== null) {
clearTimeout(this.qrPollingTimeoutId);
this.qrPollingTimeoutId = null;
}
}
private handleConfirmedPayment(purchaseId: number): void {
this.navigateToPurchaseStatus(purchaseId, true);
}
private navigateToPurchaseStatus(purchaseId: number, clearCart = false): void {
if (this.navigationStarted) {
return;
}
this.navigationStarted = true;
this.stopQrPolling();
if (clearCart) {
this.cartService.clearCart();
}
void this.router.navigate(['/checkout/status', purchaseId]);
}
}

View File

@@ -2,6 +2,8 @@ import { FormControl, FormGroup } from '@angular/forms';
export type PaymentMethod = 'qr' | 'transfer' | 'telepagos';
export type TransferField = 'cvu' | 'alias';
export type QrPaymentStatus = 'idle' | 'waiting' | 'timed_out' | 'failed';
export type TransferValidationStatus = 'idle' | 'checking' | 'pending' | 'error';
export interface PaymentMethodOption {
id: PaymentMethod;

View File

@@ -40,11 +40,18 @@
<h3 class="payment-panel__title">Cargando información de pago...</h3>
</div>
} @else if (selectedPaymentMethod() === 'qr') {
<app-checkout-payment-qr [qrData]="qrData()" />
<app-checkout-payment-qr
[qrData]="qrData()"
[paymentStatus]="qrPaymentStatus()"
[isCheckingPayment]="isCheckingQrPayment()"
(retryPolling)="retryQrPolling.emit()"
/>
} @else if (selectedPaymentMethod() === 'transfer') {
<app-checkout-payment-transfer
[transferAccount]="transferAccount()"
[transferDni]="transferDni()"
[copiedTransferField]="copiedTransferField()"
[validationStatus]="transferValidationStatus()"
(copyTransferValue)="requestCopy($event.field, $event.value)"
(submitDni)="generateTransferIntent.emit($event)"
(completePurchase)="complete.emit()"

View File

@@ -1,15 +1,21 @@
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
import { ButtonComponent } from '../../../../shared/components/button/button.component';
import { CheckoutPaymentQrComponent } from './components/checkout-payment-qr/checkout-payment-qr.component';
import { CheckoutPaymentTelepagosComponent } from './components/checkout-payment-telepagos/checkout-payment-telepagos.component';
import { CheckoutPaymentTransferComponent } from './components/checkout-payment-transfer/checkout-payment-transfer.component';
import { PaymentMethod, PaymentMethodOption, TransferAccount, TransferField } from './checkout-page.models';
import {
PaymentMethod,
PaymentMethodOption,
QrPaymentStatus,
TransferAccount,
TransferField,
TransferValidationStatus,
} from './checkout-page.models';
@Component({
selector: 'app-checkout-payment-step',
standalone: true,
imports: [ButtonComponent, CheckoutPaymentQrComponent, CheckoutPaymentTelepagosComponent, CheckoutPaymentTransferComponent],
imports: [CheckoutPaymentQrComponent, CheckoutPaymentTelepagosComponent, CheckoutPaymentTransferComponent],
templateUrl: './checkout-payment-step.component.html',
styleUrl: './checkout-payment-step.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
@@ -19,14 +25,19 @@ export class CheckoutPaymentStepComponent {
readonly selectedPaymentMethod = input.required<PaymentMethod>();
readonly copiedTransferField = input<TransferField | null>(null);
readonly transferAccount = input<TransferAccount | null>(null);
readonly transferDni = input<string>('');
readonly isGeneratingIntent = input<boolean>(false);
readonly qrData = input<string | null>(null);
readonly qrPaymentStatus = input<QrPaymentStatus>('idle');
readonly isCheckingQrPayment = input<boolean>(false);
readonly transferValidationStatus = input<TransferValidationStatus>('idle');
readonly paymentMethodChange = output<PaymentMethod>();
readonly copyTransferValue = output<{ field: TransferField; value: string }>();
readonly cancelStep = output<void>();
readonly complete = output<void>();
readonly generateTransferIntent = output<string>();
readonly retryQrPolling = output<void>();
protected selectPaymentMethod(method: PaymentMethod): void {
this.paymentMethodChange.emit(method);

View File

@@ -10,5 +10,25 @@
<span class="qr-code__finder qr-code__finder--tr"></span>
<span class="qr-code__finder qr-code__finder--bl"></span>
}
@if (isCheckingPayment()) {
<div class="payment-verification" role="status" aria-live="polite">
<span class="payment-verification__spinner" aria-hidden="true"></span>
<span class="payment-verification__message">Verificando pago</span>
</div>
}
</div>
@if (paymentStatus() === 'timed_out') {
<p class="payment-status payment-status--warning" role="status">
Todavía no recibimos el pago.
</p>
<app-button type="button" variant="secondary" (click)="retryPolling.emit()">
Volver a verificar
</app-button>
} @else if (paymentStatus() === 'failed') {
<p class="payment-status payment-status--warning" role="alert">
El pago fue rechazado o cancelado.
</p>
}
</div>

View File

@@ -103,3 +103,48 @@
left: 10px;
}
}
.payment-status {
margin: 1rem 0 0;
color: #6f6f6f;
font-size: 0.8rem;
font-weight: 500;
&--warning {
margin-bottom: 0.75rem;
color: #9a6a12;
}
}
.payment-verification {
position: absolute;
inset: 0;
z-index: 2;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.45rem;
background: rgba(255, 255, 255, 0.82);
&__spinner {
width: 30px;
height: 30px;
border: 4px solid rgba(17, 17, 17, 0.2);
border-top-color: #111111;
border-radius: 50%;
animation: payment-verification-spin 0.75s linear infinite;
}
&__message {
color: #111111;
font-size: 0.72rem;
font-weight: 700;
}
}
@keyframes payment-verification-spin {
to {
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,30 @@
import { getTestBed, TestBed } from '@angular/core/testing';
import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
import { beforeAll, describe, expect, it } from 'vitest';
import { CheckoutPaymentQrComponent } from './checkout-payment-qr.component';
describe('CheckoutPaymentQrComponent', () => {
beforeAll(() => {
try {
getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
} catch {
// Test environment may already be initialized by another setup entrypoint.
}
});
it('renders the animated payment verification overlay over the QR', async () => {
await TestBed.configureTestingModule({
imports: [CheckoutPaymentQrComponent],
}).compileComponents();
const fixture = TestBed.createComponent(CheckoutPaymentQrComponent);
fixture.componentRef.setInput('isCheckingPayment', true);
fixture.detectChanges();
const overlay = fixture.nativeElement.querySelector('.payment-verification') as HTMLElement;
expect(overlay).not.toBeNull();
expect(overlay.textContent).toContain('Verificando pago');
expect(overlay.querySelector('.payment-verification__spinner')).not.toBeNull();
});
});

View File

@@ -1,14 +1,19 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
import { QRCodeComponent } from '../../../../../../shared/components/qrcode/qrcode.component';
import { ButtonComponent } from '../../../../../../shared/components/button/button.component';
import { QrPaymentStatus } from '../../checkout-page.models';
@Component({
selector: 'app-checkout-payment-qr',
standalone: true,
imports: [QRCodeComponent],
imports: [QRCodeComponent, ButtonComponent],
templateUrl: './checkout-payment-qr.component.html',
styleUrl: './checkout-payment-qr.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CheckoutPaymentQrComponent {
readonly qrData = input<string | null>(null);
readonly paymentStatus = input<QrPaymentStatus>('idle');
readonly isCheckingPayment = input<boolean>(false);
readonly retryPolling = output<void>();
}

View File

@@ -5,7 +5,6 @@
<div class="col-8">
<app-input
type="text"
placeholder="99.999.999"
[value]="dniControl.value"
(valueChange)="dniControl.setValue($event.toString()); dniControl.markAsDirty()"
[disabled]="isDniDisabled()"
@@ -38,7 +37,7 @@
</form>
</div>
@if (transferAccount()) {
@if (transferAccount() && !isEditing()) {
<div class="payment-panel__divider"></div>
<div class="payment-panel__account">
<p class="payment-panel__eyebrow">DATOS DE CUENTA:</p>
@@ -86,10 +85,21 @@
variant="primary"
hostClass="w-100"
buttonClass="w-100"
[disabled]="validationStatus() === 'checking'"
(click)="completePurchase.emit()"
>
Ya transferí
{{ validationStatus() === 'checking' ? 'Validando pago...' : 'Ya transferí' }}
</app-button>
@if (validationStatus() === 'pending') {
<p class="payment-validation payment-validation--pending" role="status">
Todavía no recibimos la transferencia. Podés volver a verificar.
</p>
} @else if (validationStatus() === 'error') {
<p class="payment-validation payment-validation--error" role="alert">
No pudimos validar la transferencia. Intentá nuevamente.
</p>
}
</div>
}
</div>

View File

@@ -1,7 +1,7 @@
.payment-panel__card {
width: 100%;
max-width: 320px;
min-height: 269px;
min-height: 400px;
padding: 1.5rem 1.75rem;
border-radius: 5px;
background: #ffffff;
@@ -115,3 +115,17 @@
height: 100%;
}
}
.payment-validation {
margin: 0.75rem 0 0;
font-size: 0.8rem;
line-height: 1.35;
&--pending {
color: #8a681d;
}
&--error {
color: #b42318;
}
}

View File

@@ -1,9 +1,9 @@
import { ChangeDetectionStrategy, Component, computed, input, output, signal } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, input, output, signal, OnInit, effect, untracked } from '@angular/core';
import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
import { IconButtonComponent } from '../../../../../../shared/components/icon-button/icon-button.component';
import { ButtonComponent } from '../../../../../../shared/components/button/button.component';
import { InputComponent } from '../../../../../../shared/components/input/input.component';
import { TransferAccount, TransferField } from '../../checkout-page.models';
import { TransferAccount, TransferField, TransferValidationStatus } from '../../checkout-page.models';
@Component({
selector: 'app-checkout-payment-transfer',
@@ -13,9 +13,11 @@ import { TransferAccount, TransferField } from '../../checkout-page.models';
styleUrl: './checkout-payment-transfer.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CheckoutPaymentTransferComponent {
export class CheckoutPaymentTransferComponent implements OnInit {
readonly transferAccount = input<TransferAccount | null>(null);
readonly transferDni = input<string>('');
readonly copiedTransferField = input<TransferField | null>(null);
readonly validationStatus = input<TransferValidationStatus>('idle');
readonly copyTransferValue = output<{ field: TransferField; value: string }>();
readonly submitDni = output<string>();
@@ -29,6 +31,13 @@ export class CheckoutPaymentTransferComponent {
protected readonly isEditing = signal<boolean>(false);
protected readonly isDniDisabled = computed(() => !!this.transferAccount() && !this.isEditing());
ngOnInit(): void {
const dni = this.transferDni();
if (dni) {
this.dniControl.setValue(dni);
}
}
protected requestCopy(field: TransferField, value: string): void {
this.copyTransferValue.emit({ field, value });
}

View File

@@ -41,8 +41,9 @@
<section class="product-detail__section product-detail__section--attributes">
<app-product-attribute-selector
[attributes]="renderableAttributes()"
[variantsMap]="prod.variants_map"
[defaultVariant]="prod.variant"
[variants]="prod.variants"
[selectedVariant]="prod.selected_variant ?? null"
[inventoryPolicy]="prod.inventory_policy"
(variantChange)="onVariantChange($event)"
/>
</section>

View File

@@ -5,7 +5,7 @@ import { By } from '@angular/platform-browser';
import { BehaviorSubject, of, throwError } from 'rxjs';
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { ProductDetail } from '../../../../core/services/catalog/catalog.interface';
import { CatalogItemDetail } from '../../../../core/services/catalog/catalog.interface';
import { CatalogService } from '../../../../core/services/catalog/catalog.service';
import { CartService } from '../../../../core/services/cart/cart.service';
import { ToastService } from '../../../../core/services/toast.service';
@@ -16,7 +16,7 @@ import {
} from './product-detail-page.resolver';
describe('ProductDetailPageComponent', () => {
const mockProduct: ProductDetail = {
const mockProduct: CatalogItemDetail = {
id: 1,
category_id: 10,
brand_id: null,
@@ -27,9 +27,13 @@ describe('ProductDetailPageComponent', () => {
category: 'Tecnología',
brand: 'Sony',
images: ['https://example.com/image.png'],
inventory_policy: 'tracked',
has_tickets: false,
minimum_use_date: null,
maximum_use_date: null,
stock_tecnico: 10,
attributes: [],
variants_map: [],
variant: null,
variants: [],
};
let routeDataSubject: BehaviorSubject<{ productDetailData: ProductDetailResolvedData }>;
@@ -55,7 +59,7 @@ describe('ProductDetailPageComponent', () => {
},
});
catalogServiceStub = {
getProducto: vi.fn(),
getCatalogItem: vi.fn(),
};
routerStub = {
navigate: vi.fn(),
@@ -100,7 +104,7 @@ describe('ProductDetailPageComponent', () => {
}).compileComponents();
}
function resolveProduct(product: ProductDetail): void {
function resolveProduct(product: CatalogItemDetail): void {
routeDataSubject.next({
productDetailData: {
product,
@@ -125,7 +129,7 @@ describe('ProductDetailPageComponent', () => {
const element = fixture.nativeElement as HTMLElement;
expect(catalogServiceStub.getProducto).not.toHaveBeenCalled();
expect(catalogServiceStub.getCatalogItem).not.toHaveBeenCalled();
expect(element.querySelector('app-product-carousel')).not.toBeNull();
expect(element.querySelector('.product-detail__title')?.textContent).toContain(
@@ -150,16 +154,15 @@ describe('ProductDetailPageComponent', () => {
});
it('should use default variant images if present', async () => {
const detailProduct: ProductDetail = {
const detailProduct: CatalogItemDetail = {
...mockProduct,
images: ['https://example.com/product.png'],
variant: {
variants: [{ id: 123, stock_tecnico: 10, values: {} }],
selected_variant: {
id: 123,
inventory_policy: 'tracked',
cantidad_maxima: 10,
cantidad_vendida: 0,
stock_tecnico: 10,
images: ['https://example.com/variant1.png', 'https://example.com/variant2.png'],
definitions: {},
values: {},
},
};
resolveProduct(detailProduct);
@@ -176,10 +179,9 @@ describe('ProductDetailPageComponent', () => {
});
it('should fallback to product images if default variant images are not present', async () => {
const detailProduct: ProductDetail = {
const detailProduct: CatalogItemDetail = {
...mockProduct,
images: ['https://example.com/product.png'],
variant: null,
};
resolveProduct(detailProduct);
@@ -192,10 +194,9 @@ describe('ProductDetailPageComponent', () => {
});
it('should fallback to empty array if no images are present at all', async () => {
const detailProduct: ProductDetail = {
const detailProduct: CatalogItemDetail = {
...mockProduct,
images: [],
variant: null,
};
resolveProduct(detailProduct);
@@ -208,7 +209,7 @@ describe('ProductDetailPageComponent', () => {
});
it('renders generic attributes and preselects default variant options', async () => {
const detailProduct: ProductDetail = {
const detailProduct: CatalogItemDetail = {
...mockProduct,
attributes: [
{
@@ -260,13 +261,12 @@ describe('ProductDetailPageComponent', () => {
],
},
],
variant: {
variants: [{ id: 123, stock_tecnico: 10, values: { color: 'beige', material: 'Cuero' } }],
selected_variant: {
id: 123,
inventory_policy: 'tracked',
cantidad_maxima: 10,
cantidad_vendida: 0,
stock_tecnico: 10,
images: ['https://example.com/variant1.png'],
definitions: {
values: {
color: 'beige',
material: 'Cuero',
},
@@ -312,11 +312,9 @@ describe('ProductDetailPageComponent', () => {
fixture.detectChanges();
fixture.componentInstance['selectedVariant'].set({
variant_id: 1,
inventory_policy: 'tracked',
cantidad_maxima: 10,
cantidad_vendida: 0,
attributes: {},
id: 1,
stock_tecnico: 10,
values: {},
});
fixture.detectChanges();
@@ -387,25 +385,21 @@ describe('ProductDetailPageComponent', () => {
});
it('calls CartService.addItem when variant is selected and Add to Cart is clicked', async () => {
const detailProduct: ProductDetail = {
const detailProduct: CatalogItemDetail = {
...mockProduct,
variant: {
id: 123,
inventory_policy: 'tracked',
cantidad_maxima: 5,
cantidad_vendida: 0,
images: [],
definitions: {},
},
variants_map: [
variants: [
{
variant_id: 123,
inventory_policy: 'tracked',
cantidad_maxima: 5,
cantidad_vendida: 0,
attributes: {},
id: 123,
stock_tecnico: 5,
values: {},
},
],
selected_variant: {
id: 123,
stock_tecnico: 5,
images: [],
values: {},
},
};
resolveProduct(detailProduct);
@@ -414,7 +408,7 @@ describe('ProductDetailPageComponent', () => {
fixture.detectChanges();
// Select the variant
fixture.componentInstance['selectedVariant'].set(detailProduct.variants_map[0]);
fixture.componentInstance['selectedVariant'].set(detailProduct.variants[0]);
fixture.componentInstance['quantity'].set(3);
fixture.detectChanges();
@@ -427,30 +421,50 @@ describe('ProductDetailPageComponent', () => {
addToCartButton.click();
fixture.detectChanges();
expect(cartServiceStub.addItem).toHaveBeenCalledWith('variant', 123, 3);
expect(cartServiceStub.addItem).toHaveBeenCalledWith(1, 123, 3);
expect(toastServiceStub.success).toHaveBeenCalledWith('Producto agregado al carrito');
});
it('shows error toast when CartService.addItem fails', async () => {
const detailProduct: ProductDetail = {
it('adds a catalog item without variants to the cart', async () => {
resolveProduct({
...mockProduct,
variant: {
id: 123,
inventory_policy: 'tracked',
cantidad_maxima: 5,
cantidad_vendida: 0,
images: [],
definitions: {},
},
variants_map: [
variants: [],
stock_tecnico: 4,
});
await configureTestingModule();
const fixture = TestBed.createComponent(ProductDetailPageComponent);
fixture.detectChanges();
const buttons = Array.from(
fixture.nativeElement.querySelectorAll('app-button button'),
) as HTMLButtonElement[];
const addToCartButton = buttons.find(
(button) => button.textContent?.trim() === 'Agregar al carrito',
) as HTMLButtonElement;
addToCartButton.click();
fixture.detectChanges();
expect(cartServiceStub.addItem).toHaveBeenCalledWith(1, null, 1);
});
it('shows error toast when CartService.addItem fails', async () => {
const detailProduct: CatalogItemDetail = {
...mockProduct,
variants: [
{
variant_id: 123,
inventory_policy: 'tracked',
cantidad_maxima: 5,
cantidad_vendida: 0,
attributes: {},
id: 123,
stock_tecnico: 5,
values: {},
},
],
selected_variant: {
id: 123,
stock_tecnico: 5,
images: [],
values: {},
},
};
resolveProduct(detailProduct);
cartServiceStub.addItem.mockReturnValue(throwError(() => new Error('Failed to add')));
@@ -459,7 +473,7 @@ describe('ProductDetailPageComponent', () => {
const fixture = TestBed.createComponent(ProductDetailPageComponent);
fixture.detectChanges();
fixture.componentInstance['selectedVariant'].set(detailProduct.variants_map[0]);
fixture.componentInstance['selectedVariant'].set(detailProduct.variants[0]);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
@@ -478,23 +492,20 @@ describe('ProductDetailPageComponent', () => {
it('allows unlimited variants to increase quantity without a maximum', async () => {
const unlimitedVariant = {
variant_id: 321,
inventory_policy: 'unlimited' as const,
cantidad_maxima: null,
cantidad_vendida: 10,
attributes: {},
id: 321,
stock_tecnico: null,
values: {},
};
resolveProduct({
...mockProduct,
variant: {
inventory_policy: 'unlimited',
selected_variant: {
id: 321,
inventory_policy: 'unlimited',
cantidad_maxima: null,
cantidad_vendida: 10,
stock_tecnico: null,
images: [],
definitions: {},
values: {},
},
variants_map: [unlimitedVariant],
variants: [unlimitedVariant],
});
await configureTestingModule();
@@ -513,23 +524,19 @@ describe('ProductDetailPageComponent', () => {
it('disables purchase actions for tracked variants without stock', async () => {
const trackedVariant = {
variant_id: 654,
inventory_policy: 'tracked' as const,
cantidad_maxima: 0,
cantidad_vendida: 5,
attributes: {},
id: 654,
stock_tecnico: 0,
values: {},
};
resolveProduct({
...mockProduct,
variant: {
selected_variant: {
id: 654,
inventory_policy: 'tracked',
cantidad_maxima: 0,
cantidad_vendida: 5,
stock_tecnico: 0,
images: [],
definitions: {},
values: {},
},
variants_map: [trackedVariant],
variants: [trackedVariant],
});
await configureTestingModule();

View File

@@ -20,8 +20,8 @@ import { CatalogService } from '../../../../core/services/catalog/catalog.servic
import { ToastService } from '../../../../core/services/toast.service';
import { CartService } from '../../../../core/services/cart/cart.service';
import {
ProductDetail,
ProductVariantMap,
CatalogItemDetail,
CatalogItemVariant,
} from '../../../../core/services/catalog/catalog.interface';
import { ProductCarouselComponent } from '../../components/product-carousel/product-carousel.component';
import { ButtonComponent } from '../../../../shared/components/button/button.component';
@@ -62,12 +62,12 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
private observedCarouselPreview: HTMLElement | null = null;
private measurementTimer: ReturnType<typeof setTimeout> | null = null;
protected readonly product = signal<ProductDetail | null>(null);
protected readonly product = signal<CatalogItemDetail | null>(null);
protected readonly carouselImages = computed(() => {
const prod = this.product();
if (!prod) return [];
if (prod.variant?.images && prod.variant.images.length > 0) {
return prod.variant.images;
if (prod.selected_variant?.images && prod.selected_variant.images.length > 0) {
return prod.selected_variant.images;
}
if (prod.images && prod.images.length > 0) {
return prod.images;
@@ -78,15 +78,24 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
protected readonly variantLoading = signal(false);
protected readonly addingToCart = signal(false);
protected readonly error = signal<string | null>(null);
protected readonly selectedVariant = signal<ProductVariantMap | null>(null);
protected readonly selectedVariant = signal<CatalogItemVariant | null>(null);
protected readonly quantity = signal(1);
protected readonly selectedVariantMax = computed<number | null>(() => {
const prod = this.product();
const variant = this.selectedVariant();
return variant ? variant.cantidad_maxima : 1;
if (variant) return variant.stock_tecnico;
if (prod && prod.variants.length === 0) return prod.stock_tecnico ?? null;
return 0;
});
protected readonly selectedVariantAvailable = computed(() => {
const prod = this.product();
if (!prod) return false;
const variant = this.selectedVariant();
return variant !== null && this.isVariantAvailable(variant);
if (variant) return this.isVariantAvailable(variant, prod);
if (prod.variants.length > 0) return false;
return prod.inventory_policy === 'unlimited' || (prod.stock_tecnico ?? 0) > 0;
});
protected readonly descriptionExpanded = signal(false);
protected readonly descriptionMaxHeight = signal(0);
@@ -139,7 +148,7 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
this.variantLoading.set(true);
this.productSub?.unsubscribe();
this.productSub = this.catalogService.getProducto(productId, variantId).subscribe({
this.productSub = this.catalogService.getCatalogItem(productId, variantId).subscribe({
next: (prod) => {
this.applyProduct(prod, false);
this.variantLoading.set(false);
@@ -153,7 +162,7 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
if (!currentProduct) return null;
return {
...currentProduct,
variants_map: currentProduct.variants_map.filter((v) => v.variant_id !== variantId),
variants: currentProduct.variants.filter((variant) => variant.id !== variantId),
};
});
@@ -178,10 +187,10 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
}
}
private applyProduct(prod: ProductDetail, resetQuantity: boolean): void {
private applyProduct(prod: CatalogItemDetail, resetQuantity: boolean): void {
this.product.set(prod);
const matchingVariant =
prod.variants_map.find((v) => v.variant_id === prod.variant?.id) || null;
prod.variants.find((variant) => variant.id === prod.selected_variant?.id) || null;
this.selectedVariant.set(matchingVariant);
if (resetQuantity) {
this.quantity.set(1);
@@ -205,31 +214,32 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
return `$${parts.join(',')}`;
}
protected onVariantChange(variant: ProductVariantMap | null): void {
if (this.selectedVariant()?.variant_id === variant?.variant_id) {
protected onVariantChange(variant: CatalogItemVariant | null): void {
if (this.selectedVariant()?.id === variant?.id) {
return;
}
this.selectedVariant.set(variant);
if (variant && variant.cantidad_maxima !== null && this.quantity() > variant.cantidad_maxima) {
this.quantity.set(Math.max(1, variant.cantidad_maxima));
if (variant && variant.stock_tecnico !== null && this.quantity() > variant.stock_tecnico) {
this.quantity.set(Math.max(1, variant.stock_tecnico));
}
const currentProduct = this.product();
if (variant && currentProduct && currentProduct.variant?.id !== variant.variant_id) {
this.loadProductVariant(currentProduct.id, variant.variant_id);
if (variant && currentProduct && currentProduct.selected_variant?.id !== variant.id) {
this.loadProductVariant(currentProduct.id, variant.id);
}
}
protected addToCart(): void {
const currentProduct = this.product();
const variant = this.selectedVariant();
if (!variant || !this.isVariantAvailable(variant)) {
if (!currentProduct || !this.selectedVariantAvailable()) {
this.toastService.danger('Por favor, selecciona una variante.');
return;
}
this.addingToCart.set(true);
this.cartService.addItem('variant', variant.variant_id, this.quantity()).subscribe({
this.cartService.addItem(currentProduct.id, variant?.id ?? null, this.quantity()).subscribe({
next: (res) => {
const msg = res.message || 'Producto agregado al carrito';
this.toastService.success(msg);
@@ -243,8 +253,8 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
});
}
private isVariantAvailable(variant: ProductVariantMap): boolean {
return variant.inventory_policy === 'unlimited' || (variant.cantidad_maxima ?? 0) > 0;
private isVariantAvailable(variant: CatalogItemVariant, product: CatalogItemDetail): boolean {
return product.inventory_policy === 'unlimited' || (variant.stock_tecnico ?? 0) > 0;
}
protected toggleDescription(): void {

View File

@@ -3,7 +3,7 @@ import { convertToParamMap } from '@angular/router';
import { firstValueFrom, Observable, of, throwError } from 'rxjs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ProductDetail } from '../../../../core/services/catalog/catalog.interface';
import { CatalogItemDetail } from '../../../../core/services/catalog/catalog.interface';
import { CatalogService } from '../../../../core/services/catalog/catalog.service';
import {
PRODUCT_DETAIL_ERROR_MESSAGE,
@@ -13,7 +13,7 @@ import {
} from './product-detail-page.resolver';
describe('productDetailResolver', () => {
const product: ProductDetail = {
const product: CatalogItemDetail = {
id: 1,
category_id: 10,
brand_id: null,
@@ -24,16 +24,20 @@ describe('productDetailResolver', () => {
category: 'Tecnologia',
brand: null,
images: [],
inventory_policy: 'tracked',
has_tickets: false,
minimum_use_date: null,
maximum_use_date: null,
stock_tecnico: 0,
attributes: [],
variants_map: [],
variant: null,
variants: [],
};
let catalogServiceStub: { getProducto: ReturnType<typeof vi.fn> };
let catalogServiceStub: { getCatalogItem: ReturnType<typeof vi.fn> };
beforeEach(() => {
catalogServiceStub = {
getProducto: vi.fn().mockReturnValue(of(product)),
getCatalogItem: vi.fn().mockReturnValue(of(product)),
};
TestBed.configureTestingModule({
@@ -55,7 +59,7 @@ describe('productDetailResolver', () => {
product,
error: null,
});
expect(catalogServiceStub.getProducto).toHaveBeenCalledWith(1);
expect(catalogServiceStub.getCatalogItem).toHaveBeenCalledWith(1);
});
it('returns an error state for invalid ids', async () => {
@@ -67,11 +71,11 @@ describe('productDetailResolver', () => {
product: null,
error: PRODUCT_DETAIL_INVALID_ID_MESSAGE,
});
expect(catalogServiceStub.getProducto).not.toHaveBeenCalled();
expect(catalogServiceStub.getCatalogItem).not.toHaveBeenCalled();
});
it('returns an error state when the request fails', async () => {
catalogServiceStub.getProducto.mockReturnValue(throwError(() => new Error('boom')));
catalogServiceStub.getCatalogItem.mockReturnValue(throwError(() => new Error('boom')));
const result = TestBed.runInInjectionContext(() =>
productDetailResolver(createRouteSnapshot('1'), {} as never),

View File

@@ -2,14 +2,14 @@ import { inject } from '@angular/core';
import { ActivatedRouteSnapshot, ResolveFn } from '@angular/router';
import { catchError, map, of } from 'rxjs';
import { ProductDetail } from '../../../../core/services/catalog/catalog.interface';
import { CatalogItemDetail } from '../../../../core/services/catalog/catalog.interface';
import { CatalogService } from '../../../../core/services/catalog/catalog.service';
export const PRODUCT_DETAIL_ERROR_MESSAGE = 'No pudimos cargar los detalles del producto.';
export const PRODUCT_DETAIL_INVALID_ID_MESSAGE = 'ID de producto invalido';
export interface ProductDetailResolvedData {
product: ProductDetail | null;
product: CatalogItemDetail | null;
error: string | null;
}
@@ -26,7 +26,7 @@ export const productDetailResolver: ResolveFn<ProductDetailResolvedData> = (
}
return inject(CatalogService)
.getProducto(productId)
.getCatalogItem(productId)
.pipe(
map(
(product): ProductDetailResolvedData => ({

View File

@@ -1,51 +1,29 @@
@if (tenant()?.hero_config || tenant()?.event_config) {
<app-hero-banner
<app-hero-banner
[heroConfig]="tenant()?.hero_config"
[eventConfig]="tenant()?.event_config"
></app-hero-banner>
}
<app-store-section title="Productos">
<div class="d-grid gap-4">
@if (error()) {
<p class="alert text-center mb-0 store-home__alert-error">{{ error() }}</p>
} @else if (loading() && !productCards().length) {
<p class="alert alert-light border text-center mb-0">Cargando productos...</p>
} @else if (!productCards().length) {
<p class="alert alert-light border text-center mb-0">
No hay productos disponibles en este momento.
</p>
} @else {
<div class="row row-cols-1 row-cols-md-2 row-cols-xl-4 g-4">
@for (product of productCards(); track product.id; let index = $index) {
<div class="col">
<app-product-card
[title]="product.title"
[originalPrice]="product.originalPrice"
[discount]="product.discount"
[transferPrice]="product.transferPrice"
[imageUrl]="product.imageUrl"
[imagePriority]="index < priorityImageCount"
(buy)="onBuyProduct(product.id)"
/>
</div>
}
</div>
}
@if (loading() && productCards().length) {
<p class="alert alert-light border text-center mb-0 py-2">Actualizando productos...</p>
}
@if (totalPages() > 1) {
<div class="d-flex justify-content-center">
<app-paginator
[page]="currentPage()"
[totalPages]="totalPages()"
[disabled]="loading()"
(pageChange)="onPageChange($event)"
/>
</div>
}
</div>
</app-store-section>
@if (error()) {
<p class="alert text-center mb-0 store-home__alert-error">{{ error() }}</p>
} @else if (loading()) {
<p class="alert alert-light border text-center mb-0">Cargando productos...</p>
} @else if (!catalog().length) {
<p class="alert alert-light border text-center mb-0">
No hay productos disponibles en este momento.
</p>
} @else {
@for (group of catalog(); track group.id) {
<app-store-section [title]="group.title">
<app-product-list
[layout]="group.layout"
[items]="group.items"
[loading]="isGroupLoading(group.id)"
(buy)="onBuyProduct($event)"
(addToCart)="onAddToCart($event)"
(pageChange)="onPageChange(group.id, $event)"
/>
</app-store-section>
}
}

View File

@@ -2,6 +2,11 @@
display: block;
}
app-hero-banner + app-store-section,
app-store-section + app-store-section {
margin-top: clamp(3rem, 6vw, 5rem);
}
.store-home__alert-error {
margin: 0;
border-color: rgba(var(--tenant-danger-rgb, 220, 53, 69), 0.18);

View File

@@ -1,199 +1,164 @@
import { TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { ActivatedRoute } from '@angular/router';
import { Subject, of } from 'rxjs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ApiPaginatedResponse } from '../../../../core/services/api-paginated-response.interface';
import { Product } from '../../../../core/services/catalog/catalog.interface';
import { CartService } from '../../../../core/services/cart/cart.service';
import {
CatalogFeaturedGroup,
CatalogFeaturedItem,
} from '../../../../core/services/catalog/catalog.interface';
import { CatalogService } from '../../../../core/services/catalog/catalog.service';
import { ToastService } from '../../../../core/services/toast.service';
import { ProductListComponent } from '../../../../shared/components/product-list/product-list.component';
import { StoreHomePageComponent } from './store-home-page.component';
import {
STORE_HOME_PRODUCTS_ERROR_MESSAGE,
StoreHomeProductsResolvedData,
StoreHomeCatalogResolvedData,
} from './store-home-page.resolver';
function createPaginatedResponse(
products: Product[],
currentPage: number,
lastPage: number,
): ApiPaginatedResponse<Product[]> {
function createItemsPage(
items: CatalogFeaturedItem[],
currentPage = 1,
lastPage = 1,
): ApiPaginatedResponse<CatalogFeaturedItem[]> {
return {
data: products,
data: items,
meta: {
current_page: currentPage,
from: products.length ? 1 : null,
from: items.length ? 1 : null,
last_page: lastPage,
links: [],
path: '/productos',
path: '/catalog/featured-groups/1/items',
per_page: 12,
to: products.length || null,
total: products.length,
to: items.length || null,
total: items.length,
},
links: {
first: '/productos?page=1',
last: `/productos?page=${lastPage}`,
prev: currentPage > 1 ? `/productos?page=${currentPage - 1}` : null,
next: currentPage < lastPage ? `/productos?page=${currentPage + 1}` : null,
first: '/catalog/featured-groups/1/items?page=1',
last: `/catalog/featured-groups/1/items?page=${lastPage}`,
prev: currentPage > 1 ? `/catalog/featured-groups/1/items?page=${currentPage - 1}` : null,
next:
currentPage < lastPage ? `/catalog/featured-groups/1/items?page=${currentPage + 1}` : null,
},
};
}
function createResolvedData(
response: ApiPaginatedResponse<Product[]>,
): StoreHomeProductsResolvedData {
return {
response,
error: null,
};
}
function provideActivatedRoute(productsData: StoreHomeProductsResolvedData) {
return {
provide: ActivatedRoute,
useValue: {
snapshot: {
data: { productsData },
},
},
};
}
describe('StoreHomePageComponent', () => {
const pageOneProducts: Product[] = [
function createCatalog(
items = pageOneItems,
currentPage = 1,
lastPage = 2,
): CatalogFeaturedGroup[] {
return [
{
id: 1,
category_id: 10,
brand_id: null,
slug: 'auriculares-bluetooth',
nombre: 'Auriculares Bluetooth',
descripcion: 'Auriculares bluetooth de prueba',
precio: '24999',
category: 'Tecnologia',
brand: null,
images: ['/catalog/auriculares.jpg'],
},
{
id: 2,
category_id: 11,
brand_id: null,
slug: 'teclado-mecanico',
nombre: 'Teclado Mecanico',
descripcion: 'Teclado mecanico de prueba',
precio: '18999',
category: 'Tecnologia',
brand: null,
images: [],
id: 7,
title: 'Destacados',
layout: 'column_with_image',
group_order: 0,
items: createItemsPage(items, currentPage, lastPage),
},
];
}
beforeEach(() => {
vi.restoreAllMocks();
});
function provideActivatedRoute(catalogData: StoreHomeCatalogResolvedData) {
return {
provide: ActivatedRoute,
useValue: { snapshot: { data: { catalogData } } },
};
}
it('renders the products resolved by the route before component init', async () => {
const pageOneItems: CatalogFeaturedItem[] = [
{
id: 1,
nombre: 'Auriculares Bluetooth',
precio: '24999.00',
image: '/catalog/auriculares.jpg',
},
{
id: 2,
nombre: 'Teclado Mecanico',
precio: '18999.00',
image: null,
},
];
describe('StoreHomePageComponent', () => {
beforeEach(() => vi.restoreAllMocks());
it('renders the CatalogNew groups resolved by the route', async () => {
const catalogServiceStub = {
getProductos: vi.fn(),
getCatalog: vi.fn(),
getFeaturedGroupItems: vi.fn(),
};
await TestBed.configureTestingModule({
imports: [StoreHomePageComponent],
providers: [
provideActivatedRoute(createResolvedData(createPaginatedResponse(pageOneProducts, 1, 3))),
{
provide: CatalogService,
useValue: catalogServiceStub,
},
provideActivatedRoute({ response: createCatalog(), error: null }),
{ provide: CatalogService, useValue: catalogServiceStub },
],
}).compileComponents();
const fixture = TestBed.createComponent(StoreHomePageComponent);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(catalogServiceStub.getProductos).not.toHaveBeenCalled();
expect(element.querySelector('.store-section__title')?.textContent?.trim()).toBe('Productos');
expect(element.querySelectorAll('app-product-card')).toHaveLength(2);
expect(catalogServiceStub.getCatalog).not.toHaveBeenCalled();
expect(element.querySelector('.store-section__title')?.textContent?.trim()).toBe('Destacados');
expect(element.querySelectorAll('app-product-column-with-image')).toHaveLength(2);
expect(element.textContent).toContain('Auriculares Bluetooth');
expect(element.textContent).toContain('Teclado Mecanico');
expect(element.querySelector('[data-testid="paginator-status"]')?.textContent?.trim()).toBe(
'1/3',
'1/2',
);
expect(element.querySelector('img')?.getAttribute('fetchpriority')).toBe('high');
});
it('requests the next page when the paginator emits a page change', async () => {
it('requests another page for the selected featured group', async () => {
const pageTwoItems = [{ id: 3, nombre: 'Mouse Gamer', precio: '15999.00', image: null }];
const catalogServiceStub = {
getProductos: vi.fn().mockReturnValueOnce(
of(
createPaginatedResponse(
[
{
id: 3,
category_id: 12,
brand_id: null,
slug: 'mouse-gamer',
nombre: 'Mouse Gamer',
descripcion: 'Mouse gamer de prueba',
precio: '15999',
category: 'Tecnologia',
brand: null,
images: [],
},
],
2,
3,
),
),
),
getCatalog: vi.fn(),
getFeaturedGroupItems: vi.fn().mockReturnValue(of(createItemsPage(pageTwoItems, 2, 2))),
};
await TestBed.configureTestingModule({
imports: [StoreHomePageComponent],
providers: [
provideActivatedRoute(createResolvedData(createPaginatedResponse(pageOneProducts, 1, 3))),
{
provide: CatalogService,
useValue: catalogServiceStub,
},
provideActivatedRoute({ response: createCatalog(), error: null }),
{ provide: CatalogService, useValue: catalogServiceStub },
],
}).compileComponents();
const fixture = TestBed.createComponent(StoreHomePageComponent);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
(element.querySelector('[data-testid="paginator-next"]') as HTMLButtonElement).click();
fixture.detectChanges();
expect(catalogServiceStub.getProductos).toHaveBeenCalledTimes(1);
expect(catalogServiceStub.getProductos).toHaveBeenCalledWith({ page: 2 });
expect(element.querySelector('[data-testid="paginator-status"]')?.textContent?.trim()).toBe(
'2/3',
);
expect(catalogServiceStub.getFeaturedGroupItems).toHaveBeenCalledWith(7, { page: 2 });
expect(element.textContent).toContain('Mouse Gamer');
expect(element.querySelector('[data-testid="paginator-status"]')?.textContent?.trim()).toBe(
'2/2',
);
});
it('disables the paginator while a new page request is in flight', async () => {
const nextPageSubject = new Subject<ApiPaginatedResponse<Product[]>>();
it('disables only the group paginator while its page request is in flight', async () => {
const nextPage = new Subject<ApiPaginatedResponse<CatalogFeaturedItem[]>>();
const catalogServiceStub = {
getProductos: vi.fn().mockReturnValueOnce(nextPageSubject.asObservable()),
getCatalog: vi.fn(),
getFeaturedGroupItems: vi.fn().mockReturnValue(nextPage.asObservable()),
};
await TestBed.configureTestingModule({
imports: [StoreHomePageComponent],
providers: [
provideActivatedRoute(createResolvedData(createPaginatedResponse(pageOneProducts, 1, 3))),
{
provide: CatalogService,
useValue: catalogServiceStub,
},
provideActivatedRoute({ response: createCatalog(), error: null }),
{ provide: CatalogService, useValue: catalogServiceStub },
],
}).compileComponents();
const fixture = TestBed.createComponent(StoreHomePageComponent);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
(element.querySelector('[data-testid="paginator-next"]') as HTMLButtonElement).click();
fixture.detectChanges();
@@ -203,10 +168,9 @@ describe('StoreHomePageComponent', () => {
(button) => (button as HTMLButtonElement).disabled,
),
).toBe(true);
expect(element.textContent).toContain('Actualizando productos...');
nextPageSubject.next(createPaginatedResponse(pageOneProducts, 2, 3));
nextPageSubject.complete();
nextPage.next(createItemsPage(pageOneItems, 2, 2));
nextPage.complete();
fixture.detectChanges();
expect(
@@ -216,58 +180,71 @@ describe('StoreHomePageComponent', () => {
).toBe(true);
});
it('shows an empty-state message and hides the paginator when there are no products', async () => {
const catalogServiceStub = {
getProductos: vi.fn(),
};
it('shows an empty state when CatalogNew returns no groups', async () => {
const catalogServiceStub = { getCatalog: vi.fn(), getFeaturedGroupItems: vi.fn() };
await TestBed.configureTestingModule({
imports: [StoreHomePageComponent],
providers: [
provideActivatedRoute(createResolvedData(createPaginatedResponse([], 1, 1))),
{
provide: CatalogService,
useValue: catalogServiceStub,
},
provideActivatedRoute({ response: [], error: null }),
{ provide: CatalogService, useValue: catalogServiceStub },
],
}).compileComponents();
const fixture = TestBed.createComponent(StoreHomePageComponent);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(element.textContent).toContain('No hay productos disponibles en este momento.');
expect(element.querySelector('app-paginator')).toBeNull();
expect(catalogServiceStub.getProductos).not.toHaveBeenCalled();
expect((fixture.nativeElement as HTMLElement).textContent).toContain(
'No hay productos disponibles en este momento.',
);
});
it('shows an error message when the route resolver cannot load the catalog', async () => {
const catalogServiceStub = {
getProductos: vi.fn(),
};
it('shows an error when the catalog resolver fails', async () => {
const catalogServiceStub = { getCatalog: vi.fn(), getFeaturedGroupItems: vi.fn() };
await TestBed.configureTestingModule({
imports: [StoreHomePageComponent],
providers: [
provideActivatedRoute({
response: null,
error: STORE_HOME_PRODUCTS_ERROR_MESSAGE,
}),
{
provide: CatalogService,
useValue: catalogServiceStub,
},
provideActivatedRoute({ response: null, error: STORE_HOME_PRODUCTS_ERROR_MESSAGE }),
{ provide: CatalogService, useValue: catalogServiceStub },
],
}).compileComponents();
const fixture = TestBed.createComponent(StoreHomePageComponent);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect((fixture.nativeElement as HTMLElement).textContent).toContain(
'No pudimos cargar los productos en este momento.',
);
});
expect(element.textContent).toContain('No pudimos cargar los productos en este momento.');
expect(element.querySelectorAll('app-product-card')).toHaveLength(0);
expect(catalogServiceStub.getProductos).not.toHaveBeenCalled();
it('adds a product-list cart event to the cart', async () => {
const catalogServiceStub = { getCatalog: vi.fn(), getFeaturedGroupItems: vi.fn() };
const cartServiceStub = {
addItem: vi.fn().mockReturnValue(
of({ data: {}, message: 'Producto agregado correctamente' }),
),
};
const toastServiceStub = { success: vi.fn(), danger: vi.fn() };
await TestBed.configureTestingModule({
imports: [StoreHomePageComponent],
providers: [
provideActivatedRoute({ response: createCatalog(), error: null }),
{ provide: CatalogService, useValue: catalogServiceStub },
{ provide: CartService, useValue: cartServiceStub },
{ provide: ToastService, useValue: toastServiceStub },
],
}).compileComponents();
const fixture = TestBed.createComponent(StoreHomePageComponent);
fixture.detectChanges();
const productList = fixture.debugElement.query(By.directive(ProductListComponent))
.componentInstance as ProductListComponent;
productList.addToCart.emit({ product: pageOneItems[0], quantity: 3, variant: 12 });
expect(cartServiceStub.addItem).toHaveBeenCalledWith(1, 12, 3);
expect(toastServiceStub.success).toHaveBeenCalledWith('Producto agregado correctamente');
});
});

View File

@@ -3,163 +3,156 @@ import {
Component,
OnDestroy,
OnInit,
computed,
inject,
signal,
} from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http';
import { ActivatedRoute, Router } from '@angular/router';
import { Subscription } from 'rxjs';
import { ApiPaginatedResponse } from '../../../../core/services/api-paginated-response.interface';
import { CartService } from '../../../../core/services/cart/cart.service';
import { CatalogFeaturedGroup } from '../../../../core/services/catalog/catalog.interface';
import { CatalogService } from '../../../../core/services/catalog/catalog.service';
import { Product } from '../../../../core/services/catalog/catalog.interface';
import { ProductCardComponent } from '../../../../shared/components/product-card/product-card.component';
import { PaginatorComponent } from '../../../../shared/components/paginator/paginator.component';
import { StoreSectionComponent } from '../../../../shared/components/store-section/store-section.component';
import { HeroBannerComponent } from '../../../../shared/components/hero-banner/hero-banner.component';
import { TenantService } from '../../../../core/services/tenant.service';
import { ToastService } from '../../../../core/services/toast.service';
import {
ProductListComponent,
ProductListCartEvent,
ProductListItem,
} from '../../../../shared/components/product-list/product-list.component';
import { HeroBannerComponent } from '../../../../shared/components/hero-banner/hero-banner.component';
import { StoreSectionComponent } from '../../../../shared/components/store-section/store-section.component';
import {
STORE_HOME_PRODUCTS_ERROR_MESSAGE,
StoreHomeProductsResolvedData,
StoreHomeCatalogResolvedData,
} from './store-home-page.resolver';
interface StoreHomeProductCardViewModel {
id: number;
title: string;
originalPrice: number;
discount: null;
transferPrice: null;
imageUrl: string | null;
}
@Component({
selector: 'app-store-home-page',
imports: [StoreSectionComponent, ProductCardComponent, PaginatorComponent, HeroBannerComponent],
imports: [StoreSectionComponent, ProductListComponent, HeroBannerComponent],
templateUrl: './store-home-page.component.html',
styleUrl: './store-home-page.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class StoreHomePageComponent implements OnInit, OnDestroy {
private readonly cartService = inject(CartService);
private readonly catalogService = inject(CatalogService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly tenantService = inject(TenantService);
private readonly toastService = inject(ToastService);
protected readonly tenant = this.tenantService.tenant;
private activeRequestId = 0;
private productsRequestSubscription: Subscription | null = null;
protected readonly priorityImageCount = 4;
protected readonly currentPage = signal(1);
protected readonly products = signal<Product[]>([]);
protected readonly totalPages = signal(0);
protected readonly catalog = signal<CatalogFeaturedGroup[]>([]);
protected readonly loading = signal(false);
protected readonly loadingGroupIds = signal<ReadonlySet<number>>(new Set());
protected readonly error = signal<string | null>(null);
protected readonly productCards = computed<StoreHomeProductCardViewModel[]>(() =>
this.products().map((product) => ({
id: product.id,
title: product.nombre,
originalPrice: this.parseProductPrice(product.precio),
discount: null,
transferPrice: null,
imageUrl: product.images?.[0] ?? null,
})),
);
private catalogRequestSubscription: Subscription | null = null;
private readonly groupRequestSubscriptions = new Map<number, Subscription>();
ngOnInit(): void {
const resolvedData = this.route.snapshot.data['productsData'] as
| StoreHomeProductsResolvedData
const resolvedData = this.route.snapshot.data['catalogData'] as
| StoreHomeCatalogResolvedData
| undefined;
if (resolvedData) {
this.applyResolvedData(resolvedData);
return;
}
this.loadProducts(1);
this.loadCatalog();
}
ngOnDestroy(): void {
this.productsRequestSubscription?.unsubscribe();
this.catalogRequestSubscription?.unsubscribe();
this.groupRequestSubscriptions.forEach((subscription) => subscription.unsubscribe());
}
protected onPageChange(page: number): void {
if (page === this.currentPage() || page < 1) {
protected isGroupLoading(groupId: number): boolean {
return this.loadingGroupIds().has(groupId);
}
protected onPageChange(groupId: number, page: number): void {
const group = this.catalog().find((candidate) => candidate.id === groupId);
if (
!group ||
page < 1 ||
page === group.items.meta.current_page ||
this.isGroupLoading(groupId)
) {
return;
}
this.loadProducts(page);
}
this.setGroupLoading(groupId, true);
this.groupRequestSubscriptions.get(groupId)?.unsubscribe();
protected onBuyProduct(productId: number): void {
this.router.navigate(['/producto', productId]);
}
private loadProducts(page: number): void {
this.activeRequestId += 1;
const requestId = this.activeRequestId;
this.productsRequestSubscription?.unsubscribe();
this.loading.set(true);
this.error.set(null);
this.productsRequestSubscription = this.catalogService.getProductos({ page }).subscribe({
next: (response) => {
if (requestId !== this.activeRequestId) {
return;
}
this.applyProductsResponse(response);
const subscription = this.catalogService.getFeaturedGroupItems(groupId, { page }).subscribe({
next: (items) => {
this.catalog.update((groups) =>
groups.map((candidate) =>
candidate.id === groupId ? { ...candidate, items } : candidate,
),
);
this.error.set(null);
},
error: () => {
if (requestId !== this.activeRequestId) {
return;
}
this.error.set(STORE_HOME_PRODUCTS_ERROR_MESSAGE);
this.setGroupLoading(groupId, false);
},
complete: () => this.setGroupLoading(groupId, false),
});
this.products.set([]);
this.totalPages.set(0);
this.groupRequestSubscriptions.set(groupId, subscription);
}
protected onBuyProduct(product: ProductListItem): void {
this.router.navigate(['/producto', product.id]);
}
protected onAddToCart(event: ProductListCartEvent): void {
this.cartService
.addItem(event.product.id, event.variant ?? null, event.quantity)
.subscribe({
next: (response) => {
this.toastService.success(response.message || 'Producto agregado al carrito');
},
error: (error: HttpErrorResponse) => {
const message =
error.error?.message || 'No se pudo agregar el producto al carrito.';
this.toastService.danger(message);
},
});
}
private loadCatalog(): void {
this.loading.set(true);
this.error.set(null);
this.catalogRequestSubscription?.unsubscribe();
this.catalogRequestSubscription = this.catalogService.getCatalog().subscribe({
next: (catalog) => this.catalog.set(catalog),
error: () => {
this.catalog.set([]);
this.loading.set(false);
this.error.set(STORE_HOME_PRODUCTS_ERROR_MESSAGE);
},
complete: () => {
if (requestId !== this.activeRequestId) {
return;
}
this.loading.set(false);
},
complete: () => this.loading.set(false),
});
}
private applyResolvedData(resolvedData: StoreHomeProductsResolvedData): void {
if (resolvedData.error) {
this.products.set([]);
this.totalPages.set(0);
this.loading.set(false);
this.error.set(resolvedData.error);
return;
}
if (resolvedData.response) {
this.applyProductsResponse(resolvedData.response);
this.loading.set(false);
this.error.set(null);
}
private applyResolvedData(resolvedData: StoreHomeCatalogResolvedData): void {
this.catalog.set(resolvedData.response ?? []);
this.loading.set(false);
this.error.set(resolvedData.error);
}
private applyProductsResponse(response: ApiPaginatedResponse<Product[]>): void {
this.products.set(response.data ?? []);
this.currentPage.set(response.meta.current_page);
this.totalPages.set(response.meta.last_page);
}
private parseProductPrice(price: string): number {
const parsedPrice = Number(price);
return Number.isFinite(parsedPrice) ? parsedPrice : 0;
private setGroupLoading(groupId: number, loading: boolean): void {
this.loadingGroupIds.update((current) => {
const updated = new Set(current);
loading ? updated.add(groupId) : updated.delete(groupId);
return updated;
});
}
}

View File

@@ -2,23 +2,22 @@ import { inject } from '@angular/core';
import { ResolveFn } from '@angular/router';
import { catchError, map, of } from 'rxjs';
import { ApiPaginatedResponse } from '../../../../core/services/api-paginated-response.interface';
import { Product } from '../../../../core/services/catalog/catalog.interface';
import { CatalogFeaturedGroup } from '../../../../core/services/catalog/catalog.interface';
import { CatalogService } from '../../../../core/services/catalog/catalog.service';
export const STORE_HOME_PRODUCTS_ERROR_MESSAGE = 'No pudimos cargar los productos en este momento.';
export interface StoreHomeProductsResolvedData {
response: ApiPaginatedResponse<Product[]> | null;
export interface StoreHomeCatalogResolvedData {
response: CatalogFeaturedGroup[] | null;
error: string | null;
}
export const storeHomeProductsResolver: ResolveFn<StoreHomeProductsResolvedData> = () => {
export const storeHomeCatalogResolver: ResolveFn<StoreHomeCatalogResolvedData> = () => {
return inject(CatalogService)
.getProductos({ page: 1 })
.getCatalog()
.pipe(
map(
(response): StoreHomeProductsResolvedData => ({
(response): StoreHomeCatalogResolvedData => ({
response,
error: null,
}),

View File

@@ -8,7 +8,7 @@ import { hasMenuGuard } from '../../core/guards/menu.guard';
import { productDetailResolver } from './pages/product-detail-page/product-detail-page.resolver';
import { RegisterPageComponent } from './pages/register-page/register-page.component';
import { StoreHomePageComponent } from './pages/store-home-page/store-home-page.component';
import { storeHomeProductsResolver } from './pages/store-home-page/store-home-page.resolver';
import { storeHomeCatalogResolver } from './pages/store-home-page/store-home-page.resolver';
export const routes: Routes = [
{
@@ -20,7 +20,7 @@ export const routes: Routes = [
component: StoreHomePageComponent,
canActivate: [hasMenuGuard('index')],
resolve: {
productsData: storeHomeProductsResolver,
catalogData: storeHomeCatalogResolver,
},
},
{
@@ -106,6 +106,14 @@ export const routes: Routes = [
(m) => m.PurchaseDetailPage,
),
},
{
path: 'tickets',
canActivate: [hasMenuGuard('tickets')],
loadComponent: () =>
import('./pages/account-page/pages/tickets-page/tickets-page').then(
(m) => m.TicketsPage,
),
},
{
path: '',
redirectTo: 'datos-personales',

View File

@@ -1,15 +1,16 @@
<article class="w-100 p-3 rounded-0 cart-item">
<div class="position-relative overflow-hidden cart-item-media">
@if (discountPercentage() && discountPercentage()! > 0) {
<span class="position-absolute top-0 start-0 z-1 rounded-0 cart-item-discount-badge">-{{ discountPercentage() }}%</span>
}
<article
class="w-100 p-3 rounded-0 cart-item"
[class.cart-item-with-media]="imageUrl()"
>
@if (imageUrl()) {
<div class="position-relative overflow-hidden cart-item-media">
@if (discountPercentage() && discountPercentage()! > 0) {
<span class="position-absolute top-0 start-0 z-1 rounded-0 cart-item-discount-badge">-{{ discountPercentage() }}%</span>
}
@if (imageUrl()) {
<img class="w-100 h-100 object-fit-cover d-block" [src]="imageUrl()" [alt]="product()" />
} @else {
<div class="w-100 h-100 cart-item-placeholder" aria-hidden="true"></div>
}
</div>
</div>
}
<div class="d-grid min-w-0 cart-item-content">
<div class="d-grid align-items-start cart-item-top-row">

View File

@@ -29,11 +29,15 @@
.cart-item {
display: grid;
grid-template-columns: var(--item-media-size) minmax(0, 1fr);
grid-template-columns: minmax(0, 1fr);
gap: 0.75rem;
background-color: #f5f5f5;
}
.cart-item-with-media {
grid-template-columns: var(--item-media-size) minmax(0, 1fr);
}
.cart-item-media {
width: var(--item-media-size);
aspect-ratio: 1 / 1;
@@ -63,12 +67,6 @@
line-height: 1;
}
.cart-item-placeholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #d2d2d2, #ececec);
}
.cart-item-content {
gap: 0.5rem;
}

View File

@@ -34,27 +34,31 @@
(quantityChange)="onItemQuantityChange(idx, $event)"
(remove)="onItemRemove(idx)"
/>
} @empty {
<p class="cart-empty-message" role="status">El carrito está vacío</p>
}
</div>
<footer class="d-grid gap-1 bg-transparent cart-footer">
<div class="d-flex align-items-baseline justify-content-between gap-3">
<span class="cart-summary-text-muted">Subtotal:</span>
<span class="cart-summary-text-muted-bold">{{ formattedSubtotal() }}</span>
</div>
@if (items().length > 0) {
<footer class="d-grid gap-1 bg-transparent cart-footer">
<div class="d-flex align-items-baseline justify-content-between gap-3">
<span class="cart-summary-text-muted">Subtotal:</span>
<span class="cart-summary-text-muted-bold">{{ formattedSubtotal() }}</span>
</div>
<div class="d-flex align-items-baseline justify-content-between gap-3">
<span class="cart-summary-text-muted">Descuento:</span>
<span class="cart-summary-text-muted-bold">{{ formattedDiscount() }}</span>
</div>
<div class="d-flex align-items-baseline justify-content-between gap-3">
<span class="cart-summary-text-muted">Descuento:</span>
<span class="cart-summary-text-muted-bold">{{ formattedDiscount() }}</span>
</div>
<div class="d-flex align-items-baseline justify-content-between gap-3 mt-1">
<span class="cart-total-text">TOTAL:</span>
<span class="cart-total-text">{{ formattedTotal() }}</span>
</div>
<div class="d-flex align-items-baseline justify-content-between gap-3 mt-1">
<span class="cart-total-text">TOTAL:</span>
<span class="cart-total-text">{{ formattedTotal() }}</span>
</div>
<div class="cart-actions mt-3">
<ng-content />
</div>
</footer>
<div class="cart-actions mt-3">
<ng-content />
</div>
</footer>
}
</section>

View File

@@ -31,8 +31,6 @@
display: block;
}
&::-webkit-scrollbar {
width: 6px;
}
@@ -43,6 +41,15 @@
}
}
.cart-empty-message {
align-self: center;
margin: 0;
padding: 24px;
color: #a0a0a0;
font-size: 14px;
text-align: center;
}
.cart-footer {
padding: 8px 22px 14px 24px;
}

View File

@@ -24,6 +24,44 @@ describe('CartComponent', () => {
TestBed.resetTestingModule();
});
it('shows an empty cart message when there are no items', async () => {
await TestBed.configureTestingModule({
imports: [CartComponent],
providers: [
{
provide: CartService,
useValue: {
cart: signal(null).asReadonly(),
updateItemQuantity: vi.fn(),
removeItem: vi.fn(),
},
},
{
provide: ModalService,
useValue: {},
},
{
provide: ToastService,
useValue: {
success: vi.fn(),
info: vi.fn(),
danger: vi.fn(),
},
},
],
}).compileComponents();
const fixture = TestBed.createComponent(CartComponent);
fixture.detectChanges();
const emptyMessage = fixture.debugElement.query(By.css('.cart-empty-message'));
expect(emptyMessage).not.toBeNull();
expect(emptyMessage.nativeElement.textContent.trim()).toBe('El carrito está vacío');
expect(fixture.debugElement.query(By.css('app-cart-item'))).toBeNull();
expect(fixture.debugElement.query(By.css('.cart-footer'))).toBeNull();
});
it('opens a confirm delete modal before removing an item', async () => {
const removeItem = vi.fn().mockReturnValue(of({ message: 'Producto eliminado.' }));
const openConfirmDelete = vi.fn().mockReturnValue(of(true));
@@ -71,6 +109,8 @@ describe('CartComponent', () => {
]);
fixture.detectChanges();
expect(fixture.debugElement.query(By.css('.cart-item-media'))).toBeNull();
fixture.debugElement.query(By.css('app-cart-item')).triggerEventHandler('remove');
expect(openConfirmDelete).toHaveBeenCalledWith({

View File

@@ -14,7 +14,9 @@
color: #666666;
cursor: pointer;
outline: none;
transition: color 0.15s ease-in-out, transform 0.1s ease-in-out;
transition:
color 0.15s ease-in-out,
transform 0.1s ease-in-out;
border-radius: 4px;
i {
@@ -40,7 +42,7 @@
// Disabled state
&:disabled {
color: #A0A0A0;
color: #a0a0a0;
cursor: not-allowed;
pointer-events: none;
}
@@ -74,3 +76,14 @@
font-size: 23px;
}
}
.icon-btn--download,
.icon-btn--share {
color: #a0a0a0;
i {
width: 15px;
height: 15px;
font-size: 15px;
}
}

View File

@@ -8,14 +8,16 @@ export type IconButtonVariant =
| 'angle-left'
| 'angle-up'
| 'angle-down'
| 'copy';
| 'copy'
| 'download'
| 'share';
@Component({
selector: 'app-icon-button',
imports: [NgClass],
templateUrl: './icon-button.component.html',
styleUrl: './icon-button.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class IconButtonComponent {
readonly variant = input.required<IconButtonVariant>();
@@ -33,7 +35,9 @@ export class IconButtonComponent {
'angle-left': 'fa-solid fa-angle-left',
'angle-up': 'fa-solid fa-angle-up',
'angle-down': 'fa-solid fa-angle-down',
copy: 'fa-solid fa-copy'
copy: 'fa-solid fa-copy',
download: 'fa-solid fa-download',
share: 'fa-solid fa-share-nodes',
};
return classes[this.variant()];
});

View File

@@ -19,6 +19,36 @@
margin: 0;
}
.modal-shell__dialog--qr {
width: min(100%, 15rem);
}
.modal-shell__dialog--qr .modal-shell__content {
min-height: auto;
border-radius: 0.35rem;
background: #ffffff;
}
.modal-shell__dialog--qr .modal-shell__header {
padding: 1.7rem 1rem 0.35rem;
}
.modal-shell__dialog--qr .modal-shell__title {
color: var(--bs-success, #198754);
font-size: 0.8rem;
font-weight: 800;
}
.modal-shell__dialog--qr .modal-shell__header .btn-close {
top: 0.85rem;
right: 0.85rem;
font-size: 0.7rem;
}
.modal-shell__dialog--qr .modal-shell__body {
padding: 0 1rem 1rem;
}
.modal-shell__dialog.modal-sm {
width: min(100%, 24rem);
}
@@ -40,8 +70,7 @@
min-height: 180px;
max-height: calc(100dvh - 2rem);
border-radius: 1.25rem;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 248, 248, 0.98));
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 248, 248, 0.98));
}
.modal-shell__dialog--full .modal-shell__content {
@@ -88,6 +117,7 @@
}
.modal-shell__dialog,
.modal-shell__dialog--qr,
.modal-shell__dialog.modal-sm,
.modal-shell__dialog.modal-lg,
.modal-shell__dialog.modal-xl,

View File

@@ -6,7 +6,7 @@ import {
computed,
input,
output,
viewChild
viewChild,
} from '@angular/core';
import { ModalSize } from '../../../core/services/modal.service';
@@ -16,7 +16,7 @@ import { ModalSize } from '../../../core/services/modal.service';
imports: [NgClass],
templateUrl: './modal-shell.component.html',
styleUrl: './modal-shell.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ModalShellComponent {
private readonly panel = viewChild.required<ElementRef<HTMLElement>>('panel');
@@ -32,10 +32,11 @@ export class ModalShellComponent {
const size = this.size();
return {
'modal-shell__dialog--qr': size === 'qr',
'modal-sm': size === 'sm',
'modal-lg': size === 'lg',
'modal-xl': size === 'xl',
'modal-shell__dialog--full': size === 'full'
'modal-shell__dialog--full': size === 'full',
};
});
@@ -55,8 +56,8 @@ export class ModalShellComponent {
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])'
].join(', ')
'[tabindex]:not([tabindex="-1"])',
].join(', '),
);
(focusTarget ?? panel).focus();

View File

@@ -1,66 +0,0 @@
<div
class="product-card border rounded shadow-sm bg-white overflow-hidden d-flex flex-column h-100"
>
<!-- Image section -->
<div class="product-card__image-container position-relative bg-light">
@if (imageUrl(); as imageSrc) {
<img
[ngSrc]="imageSrc"
[alt]="title()"
[priority]="imagePriority()"
fill
sizes="(min-width: 1200px) 25vw, (min-width: 768px) 50vw, 100vw"
class="product-card__image w-100 h-100 object-fit-cover"
/>
} @else {
<div
class="product-card__image-placeholder w-100 h-100 d-flex align-items-center justify-content-center bg-light text-muted"
>
<i class="fa-solid fa-image fa-2x"></i>
</div>
}
<!-- Discount badge in top right -->
@if (discount() && discount()! > 0) {
<span
class="product-card__discount-badge position-absolute top-0 end-0 bg-primary text-white px-2 py-1"
>
-{{ discount() }}%
</span>
}
</div>
<!-- Content section -->
<div class="product-card__body p-3 d-flex flex-column align-items-center text-center flex-grow-1">
<!-- Product Title -->
<h3 class="product-card__title text-uppercase text-black mb-4">{{ title() }}</h3>
<!-- Prices Area -->
<div class="product-card__prices d-flex align-items-center justify-content-center gap-2">
<!-- Discounted (computado) Price -->
<span class="product-card__price-discounted text-primary">{{
formattedDiscountedPrice()
}}</span>
<!-- Original strikethrough Price (only if discount exists) -->
@if (discount() && discount()! > 0) {
<span class="product-card__price-original text-decoration-line-through">{{
formattedOriginalPrice()
}}</span>
}
</div>
<!-- Transfer Price Info -->
<div class="product-card__price-transfer mb-4">
<span class="product-card__price-transfer-value">{{ formattedTransferPrice() }}</span>
<span class="product-card__price-transfer-label"> con transferencia</span>
</div>
<!-- Purchase Action Button -->
<div class="product-card__action mt-auto w-100">
<app-button variant="primary" class="w-100" (click)="buy.emit()">
{{ buttonText() }}
</app-button>
</div>
</div>
</div>

View File

@@ -1,71 +0,0 @@
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
import { NgOptimizedImage } from '@angular/common';
import { ButtonComponent } from '../button/button.component';
@Component({
selector: 'app-product-card',
standalone: true,
imports: [ButtonComponent, NgOptimizedImage],
templateUrl: './product-card.component.html',
styleUrl: './product-card.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ProductCardComponent {
// Configurable inputs
readonly imageUrl = input<string | null>(null);
readonly title = input<string>('');
readonly originalPrice = input<number>(0);
readonly discount = input<number | null>(null);
readonly transferPrice = input<number | null>(null);
readonly buttonText = input<string>('Comprar');
readonly imagePriority = input<boolean>(false);
// Interactive events
readonly buy = output<void>();
// Computed discounted price
readonly discountedPrice = computed(() => {
const original = this.originalPrice();
const disc = this.discount();
if (!disc || disc <= 0) {
return original;
}
return original * (1 - disc / 100);
});
// Computed transfer price
readonly computedTransferPrice = computed(() => {
const givenTransfer = this.transferPrice();
if (givenTransfer !== null) {
return givenTransfer;
}
// Fallback if not specified: use the discounted price
return this.discountedPrice();
});
// Formatted string for discounted price: "$ 76.000"
readonly formattedDiscountedPrice = computed(() => {
return this.formatCurrency(this.discountedPrice());
});
// Formatted string for original price (if has discount): "$ 95.000"
readonly formattedOriginalPrice = computed(() => {
return this.formatCurrency(this.originalPrice());
});
// Formatted string for transfer price: "$ 74.800"
readonly formattedTransferPrice = computed(() => {
return this.formatCurrency(this.computedTransferPrice());
});
/**
* Helper method to format currency numbers to Argentine Pesos style "$ XX.XXX".
* This manual implementation is safe from runtime/SSR locale variations.
*/
private formatCurrency(value: number): string {
const rounded = Math.round(value);
const parts = rounded.toString().split('.');
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, '.');
return `$${parts.join(',')}`;
}
}

View File

@@ -0,0 +1,61 @@
<article
class="product-column-with-image border rounded shadow-sm bg-white overflow-hidden d-flex flex-column h-100"
>
<div class="product-column-with-image__image-container position-relative bg-light">
@if (imageUrl(); as imageSrc) {
<img
[ngSrc]="imageSrc"
[alt]="title()"
[priority]="imagePriority()"
fill
sizes="(min-width: 1200px) 25vw, (min-width: 768px) 50vw, 100vw"
class="product-column-with-image__image w-100 h-100 object-fit-cover"
/>
} @else {
<div
class="product-column-with-image__image-placeholder w-100 h-100 d-flex align-items-center justify-content-center bg-light text-muted"
>
<i class="fa-solid fa-image fa-2x"></i>
</div>
}
@if (discount() && discount()! > 0) {
<span
class="product-column-with-image__discount-badge position-absolute top-0 end-0 bg-primary text-white px-2 py-1"
>
-{{ discount() }}%
</span>
}
</div>
<div
class="product-column-with-image__body p-3 d-flex flex-column align-items-center text-center flex-grow-1"
>
<h3 class="product-column-with-image__title text-uppercase text-black mb-4">{{ title() }}</h3>
<div class="product-column-with-image__prices d-flex align-items-center justify-content-center gap-2">
<span class="product-column-with-image__price-discounted text-primary">
{{ formattedDiscountedPrice() }}
</span>
@if (discount() && discount()! > 0) {
<span class="product-column-with-image__price-original text-decoration-line-through">
{{ formattedOriginalPrice() }}
</span>
}
</div>
<div class="product-column-with-image__price-transfer mb-4">
<span class="product-column-with-image__price-transfer-value">{{
formattedTransferPrice()
}}</span>
<span class="product-column-with-image__price-transfer-label"> con transferencia</span>
</div>
<div class="product-column-with-image__action mt-auto w-100">
<app-button variant="primary" class="w-100" (click)="buy.emit()">
{{ buttonText() }}
</app-button>
</div>
</div>
</article>

View File

@@ -4,10 +4,12 @@
height: 100%;
}
.product-card {
.product-column-with-image {
width: 100%;
max-height: 411px;
transition: transform 0.2s ease, box-shadow 0.2s ease;
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
&:hover {
transform: translateY(-2px);
@@ -41,11 +43,10 @@
font-weight: 700;
}
&__price-original {
font-size: 11px;
font-weight: 325;
color: #DDDDDD;
color: #dddddd;
}
&__price-transfer {
@@ -73,4 +74,4 @@
}
}
}
}
}

View File

@@ -0,0 +1,48 @@
import { NgOptimizedImage } from '@angular/common';
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
import { ButtonComponent } from '../button/button.component';
@Component({
selector: 'app-product-column-with-image',
imports: [ButtonComponent, NgOptimizedImage],
templateUrl: './product-column-with-image.component.html',
styleUrl: './product-column-with-image.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ProductColumnWithImageComponent {
readonly imageUrl = input<string | null>(null);
readonly title = input<string>('');
readonly originalPrice = input<number>(0);
readonly discount = input<number | null>(null);
readonly transferPrice = input<number | null>(null);
readonly buttonText = input<string>('Comprar');
readonly imagePriority = input<boolean>(false);
readonly buy = output<void>();
readonly discountedPrice = computed(() => {
const original = this.originalPrice();
const discount = this.discount();
return !discount || discount <= 0 ? original : original * (1 - discount / 100);
});
readonly computedTransferPrice = computed(() => {
return this.transferPrice() ?? this.discountedPrice();
});
readonly formattedDiscountedPrice = computed(() => this.formatCurrency(this.discountedPrice()));
readonly formattedOriginalPrice = computed(() => this.formatCurrency(this.originalPrice()));
readonly formattedTransferPrice = computed(() =>
this.formatCurrency(this.computedTransferPrice()),
);
private formatCurrency(value: number): string {
const rounded = Math.round(value);
const parts = rounded.toString().split('.');
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, '.');
return `$${parts.join(',')}`;
}
}

View File

@@ -0,0 +1,51 @@
<div
class="product-list"
[class.product-list--row]="effectiveLayout() === 'row'"
[class.product-list--column]="effectiveLayout() !== 'row'"
>
@for (item of items().data; track item.id; let index = $index) {
<div class="product-list__item">
@switch (effectiveLayout()) {
@case ('row') {
<app-product-row-card
[title]="item.nombre"
[description]="item.descripcion ?? ''"
[price]="price(item)"
[variants]="variantsFor(item)"
(buy)="buy.emit(item)"
(addToCart)="emitRowCart(item, $event)"
/>
}
@case ('column_with_cart') {
<app-product-vertical-with-cart-card
[title]="item.nombre"
[description]="item.descripcion ?? ''"
[price]="price(item)"
(buy)="buy.emit(item)"
(addToCart)="emitColumnCart(item, $event)"
/>
}
@default {
<app-product-column-with-image
[imageUrl]="item.image ?? null"
[title]="item.nombre"
[originalPrice]="price(item)"
[imagePriority]="index < 4"
(buy)="buy.emit(item)"
/>
}
}
</div>
}
</div>
@if (items().meta.last_page > 1) {
<div class="product-list__paginator d-flex justify-content-center">
<app-paginator
[page]="items().meta.current_page"
[totalPages]="items().meta.last_page"
[disabled]="loading()"
(pageChange)="pageChange.emit($event)"
/>
</div>
}

View File

@@ -0,0 +1,61 @@
:host {
display: block;
width: 100%;
}
.product-list {
display: grid;
width: 100%;
gap: 1.5rem;
&--row {
grid-template-columns: minmax(0, 1fr);
.product-list__item {
width: 100%;
}
}
&--column {
container-name: product-list;
container-type: inline-size;
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: stretch;
.product-list__item {
flex: 0 0 100%;
max-width: 100%;
}
}
&__item {
min-width: 0;
}
}
@container product-list (min-width: 544px) {
.product-list--column .product-list__item {
flex-basis: calc((100% - 1.5rem) / 2);
max-width: calc((100% - 1.5rem) / 2);
}
}
@container product-list (min-width: 828px) {
.product-list--column .product-list__item {
flex-basis: calc((100% - 3rem) / 3);
max-width: calc((100% - 3rem) / 3);
}
}
@container product-list (min-width: 1112px) {
.product-list--column .product-list__item {
flex-basis: calc((100% - 4.5rem) / 4);
max-width: calc((100% - 4.5rem) / 4);
}
}
.product-list__paginator {
margin-top: 1.5rem;
}

View File

@@ -0,0 +1,131 @@
import { TestBed, getTestBed } from '@angular/core/testing';
import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { ApiPaginatedResponse } from '../../../core/services/api-paginated-response.interface';
import { ProductListComponent, ProductListItem, ProductListLayout } from './product-list.component';
describe('ProductListComponent', () => {
const items: ProductListItem[] = [
{
id: 1,
nombre: 'Producto uno',
descripcion: 'Primera descripcion',
precio: '100.00',
image: '/images/one.png',
variants: [],
},
{
id: 2,
nombre: 'Producto dos',
descripcion: 'Segunda descripcion',
precio: 200,
image: null,
variants: [],
},
];
beforeAll(() => {
try {
getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
} catch {
// Test environment may already be initialized by another setup entrypoint.
}
});
afterEach(() => {
vi.unstubAllGlobals();
TestBed.resetTestingModule();
});
async function render(layout: ProductListLayout) {
await TestBed.configureTestingModule({ imports: [ProductListComponent] }).compileComponents();
const fixture = TestBed.createComponent(ProductListComponent);
fixture.componentRef.setInput('layout', layout);
fixture.componentRef.setInput('items', paginatedItems());
fixture.detectChanges();
return fixture;
}
function paginatedItems(currentPage = 1, lastPage = 2): ApiPaginatedResponse<ProductListItem[]> {
return {
data: items,
meta: {
current_page: currentPage,
from: 1,
last_page: lastPage,
links: [],
path: '/catalog/featured-groups/1/items',
per_page: 12,
to: 2,
total: 13,
},
links: {
first: '/catalog/featured-groups/1/items?page=1',
last: '/catalog/featured-groups/1/items?page=2',
prev: null,
next: '/catalog/featured-groups/1/items?page=2',
},
};
}
it('renders every row product at full available width', async () => {
const fixture = await render('row');
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('.product-list--row')).not.toBeNull();
expect(element.querySelectorAll('app-product-row-card')).toHaveLength(2);
expect(element.querySelector('app-product-column-with-image')).toBeNull();
});
it('replaces the row layout with column with cart on mobile', async () => {
const matchMedia = vi.fn().mockReturnValue({
matches: true,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
} as unknown as MediaQueryList);
vi.stubGlobal('matchMedia', matchMedia);
const fixture = await render('row');
await fixture.whenStable();
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(matchMedia).toHaveBeenCalledWith('(max-width: 767.98px)');
expect(element.querySelector('.product-list--column')).not.toBeNull();
expect(element.querySelectorAll('app-product-vertical-with-cart-card')).toHaveLength(2);
expect(element.querySelector('app-product-row-card')).toBeNull();
});
it('renders image products next to each other in the column grid', async () => {
const fixture = await render('column_with_image');
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('.product-list--column')).not.toBeNull();
expect(element.querySelectorAll('app-product-column-with-image')).toHaveLength(2);
});
it('renders cart products next to each other in the column grid', async () => {
const fixture = await render('column_with_cart');
expect(
(fixture.nativeElement as HTMLElement).querySelectorAll(
'app-product-vertical-with-cart-card',
),
).toHaveLength(2);
});
it('renders pagination and emits the requested page', async () => {
const fixture = await render('row');
const pageChangeSpy = vi.fn();
fixture.componentInstance.pageChange.subscribe(pageChangeSpy);
const nextButton = (fixture.nativeElement as HTMLElement).querySelector(
'[data-testid="paginator-next"]',
) as HTMLButtonElement;
nextButton.click();
expect(pageChangeSpy).toHaveBeenCalledWith(2);
});
});

View File

@@ -0,0 +1,104 @@
import {
afterNextRender,
ChangeDetectionStrategy,
Component,
computed,
DestroyRef,
inject,
input,
output,
signal,
} from '@angular/core';
import { ApiPaginatedResponse } from '../../../core/services/api-paginated-response.interface';
import {
CatalogFeaturedItem,
CatalogFeaturedItemVariant,
CatalogProductLayout,
} from '../../../core/services/catalog/catalog.interface';
import { PaginatorComponent } from '../paginator/paginator.component';
import { ProductColumnWithImageComponent } from '../product-column-with-image/product-column-with-image.component';
import {
ProductRowCardComponent,
Variant as RowVariant,
} from '../product-row-card/product-row-card.component';
import { ProductVerticalWithCartCardComponent } from '../product-vertical-with-cart-card/product-vertical-with-cart-card.component';
export type ProductListLayout = CatalogProductLayout;
export type ProductListVariant = CatalogFeaturedItemVariant;
export type ProductListItem = CatalogFeaturedItem;
export interface ProductListCartEvent {
product: ProductListItem;
quantity: number;
variant?: number | null;
}
@Component({
selector: 'app-product-list',
imports: [
ProductColumnWithImageComponent,
PaginatorComponent,
ProductRowCardComponent,
ProductVerticalWithCartCardComponent,
],
templateUrl: './product-list.component.html',
styleUrl: './product-list.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ProductListComponent {
private readonly destroyRef = inject(DestroyRef);
private readonly mobile = signal(false);
readonly layout = input.required<ProductListLayout>();
readonly items = input.required<ApiPaginatedResponse<ProductListItem[]>>();
readonly loading = input(false);
readonly buy = output<ProductListItem>();
readonly addToCart = output<ProductListCartEvent>();
readonly pageChange = output<number>();
protected readonly effectiveLayout = computed<ProductListLayout>(() =>
this.mobile() && this.layout() === 'row' ? 'column_with_cart' : this.layout(),
);
constructor() {
afterNextRender(() => {
const mobileQuery = window.matchMedia('(max-width: 767.98px)');
const updateMobile = (event: MediaQueryList | MediaQueryListEvent): void =>
this.mobile.set(event.matches);
updateMobile(mobileQuery);
mobileQuery.addEventListener('change', updateMobile);
this.destroyRef.onDestroy(() => mobileQuery.removeEventListener('change', updateMobile));
});
}
protected price(item: ProductListItem): number {
const price = Number(item.precio);
return Number.isFinite(price) ? price : 0;
}
protected variantsFor(item: ProductListItem): RowVariant[] {
return (item.variants ?? []).map((variant) => ({
value: variant.id,
label: Object.values(variant.values).join(' / ') || `Variante ${variant.id}`,
}));
}
protected emitRowCart(
product: ProductListItem,
event: { quantity: number; variant: unknown },
): void {
this.addToCart.emit({
product,
quantity: event.quantity,
variant: typeof event.variant === 'number' ? event.variant : null,
});
}
protected emitColumnCart(product: ProductListItem, event: { quantity: number }): void {
this.addToCart.emit({ product, quantity: event.quantity });
}
}

View File

@@ -0,0 +1,47 @@
<div class="product-row-card border rounded bg-white shadow-sm d-flex justify-content-between p-3 gap-3">
<!-- Left Side: Title and Description -->
<div class="product-row-card__info d-flex flex-column justify-content-center flex-grow-1">
<h3 class="product-row-card__title text-uppercase mb-1 m-0">
{{ title() }}
</h3>
@if (description()) {
<p class="product-row-card__description m-0 mt-1">
{{ description() }}
</p>
}
</div>
<!-- Right Side: Actions and Pricing -->
<div class="product-row-card__actions d-flex flex-column gap-2 justify-content-center">
<!-- Top Row: Price and Comprar button -->
<div class="d-flex align-items-center justify-content-end gap-3">
<span class="product-row-card__price text-primary">
{{ formattedPrice() }}
</span>
<div class="product-row-card__btn-wrapper">
<app-button variant="primary" (click)="buy.emit()">
Comprar
</app-button>
</div>
</div>
<!-- Bottom Row: Select, Quantity, and Agregar al carrito button -->
<div class="d-flex align-items-center justify-content-end gap-2">
@if (variants().length > 0) {
<select class="form-select product-row-card__select" [(ngModel)]="selectedVariant">
@for (variant of variants(); track variant.value) {
<option [ngValue]="variant.value">{{ variant.label }}</option>
}
</select>
}
<app-quantity-selector [(quantity)]="quantity" ></app-quantity-selector>
<div class="product-row-card__btn-wrapper">
<app-button variant="secondary" (click)="onAddToCart()">
Agregar al carrito
</app-button>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,63 @@
:host {
display: block;
width: 100%;
}
.product-row-card {
width: 100%;
background-color: #f8f9fa; /* slightly light bg matching the screenshot if needed, but let's use white or #f8f9fa based on the border */
border-color: #e9ecef !important;
&__title {
font-size: 16px;
font-weight: 700;
color: #666666;
letter-spacing: 0.5px;
}
&__description {
font-size: 13px;
color: #777; /* lighter grey for text */
line-height: 1.4;
max-width: 600px;
/* Simulate the bold text for 'Niños menores...' if it was HTML.
Since it's passed as string we just let it be, unless we parse it. */
}
&__price {
font-size: 20px;
font-weight: 800;
color: var(--tenant-primary, #009933) !important; /* Green matching screenshot */
}
&__select {
width: auto;
min-width: 150px;
font-size: 14px;
height: 38px;
color: #666;
border-color: #ccc;
cursor: pointer;
&:focus {
border-color: var(--tenant-primary);
box-shadow: 0 0 0 0.25rem rgba(0, 153, 51, 0.25);
}
}
&__btn-wrapper {
min-width: 160px; /* To make both buttons equal width as in screenshot */
app-button {
display: block;
width: 100%;
::ng-deep .btn {
width: 100%;
font-weight: 600;
border-radius: 4px;
}
}
}
}

View File

@@ -0,0 +1,69 @@
import { ChangeDetectionStrategy, Component, computed, effect, input, output, model } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ButtonComponent } from '../button/button.component';
import { QuantitySelectorComponent } from '../quantity-selector/quantity-selector.component';
export interface Variant {
label: string;
value: any;
}
@Component({
selector: 'app-product-row-card',
standalone: true,
imports: [ButtonComponent, QuantitySelectorComponent, FormsModule],
templateUrl: './product-row-card.component.html',
styleUrl: './product-row-card.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ProductRowCardComponent {
// Configurable inputs
readonly title = input<string>('');
readonly description = input<string>('');
readonly price = input<number>(0);
readonly variants = input<Variant[]>([]);
// Internal state models
readonly quantity = model<number>(1);
readonly selectedVariant = model<any>(null);
// Interactive events
readonly buy = output<void>();
readonly addToCart = output<{ quantity: number; variant: any }>();
constructor() {
effect(() => {
const variants = this.variants();
const selectedVariant = this.selectedVariant();
if (
variants.length > 0 &&
!variants.some((variant) => Object.is(variant.value, selectedVariant))
) {
this.selectedVariant.set(variants[0].value);
}
});
}
// Formatted string for price: "$ 10.000"
readonly formattedPrice = computed(() => {
return this.formatCurrency(this.price());
});
protected onAddToCart(): void {
this.addToCart.emit({
quantity: this.quantity(),
variant: this.selectedVariant()
});
}
/**
* Helper method to format currency numbers to Argentine Pesos style "$ XX.XXX".
*/
private formatCurrency(value: number): string {
const rounded = Math.round(value);
const parts = rounded.toString().split('.');
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, '.');
return `$ ${parts.join(',')}`;
}
}

View File

@@ -0,0 +1,21 @@
<article class="product-vertical-with-cart-card">
<div class="product-vertical-with-cart-card__content">
<h3 class="product-vertical-with-cart-card__title">{{ title() }}</h3>
@if (description()) {
<p class="product-vertical-with-cart-card__description">{{ description() }}</p>
}
</div>
<div class="product-vertical-with-cart-card__purchase">
<div class="product-vertical-with-cart-card__summary">
<span class="product-vertical-with-cart-card__price">{{ formattedPrice() }}</span>
<app-quantity-selector [(quantity)]="quantity" />
</div>
<div class="product-vertical-with-cart-card__actions">
<app-button variant="primary" (click)="buy.emit()">Comprar</app-button>
<app-button variant="secondary" (click)="onAddToCart()"> Agregar al carrito </app-button>
</div>
</div>
</article>

View File

@@ -0,0 +1,103 @@
:host {
display: block;
width: 100%;
min-width: 0;
max-width: 100%;
height: 100%;
box-sizing: border-box;
}
.product-vertical-with-cart-card {
display: flex;
flex-direction: column;
justify-content: space-between;
width: 100%;
min-width: 0;
max-width: 100%;
height: 100%;
box-sizing: border-box;
padding: 30px 20px;
overflow: hidden;
background-color: #ffffff;
border: 1px solid #dedede;
border-radius: 7px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
&__content {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
&__title {
margin: 0;
color: #666666;
font-size: 15px;
font-weight: 700;
line-height: 1.2;
text-transform: uppercase;
}
&__description {
margin: 23px 0 ;
color: #6f6f6f;
font-size: 11px;
font-weight: 300;
line-height: 1.45;
}
&__purchase {
display: grid;
gap: 24px;
}
&__summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding-inline: 12px;
}
&__price {
color: var(--tenant-primary, #009933);
font-size: 22px;
font-weight: 800;
line-height: 1;
white-space: nowrap;
}
&__actions {
display: grid;
gap: 10px;
}
&__actions app-button {
display: block;
width: 100%;
}
&__actions ::ng-deep .btn {
width: 100%;
min-height: 40px;
min-width: 0;
border-radius: 7px;
font-size: 15px;
}
}
@media (max-width: 420px) {
.product-vertical-with-cart-card {
padding: 42px 20px 21px;
&__purchase {
gap: 24px;
}
&__summary {
padding-inline: 12px;
}
}
}

View File

@@ -0,0 +1,118 @@
import { TestBed, getTestBed } from '@angular/core/testing';
import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { ProductVerticalWithCartCardComponent } from './product-vertical-with-cart-card.component';
describe('ProductVerticalWithCartCardComponent', () => {
beforeAll(() => {
try {
getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
} catch {
// Test environment may already be initialized by another setup entrypoint.
}
});
afterEach(() => {
TestBed.resetTestingModule();
});
async function createComponent(description = 'Pancho con aderezo a elección.') {
await TestBed.configureTestingModule({
imports: [ProductVerticalWithCartCardComponent],
}).compileComponents();
const fixture = TestBed.createComponent(ProductVerticalWithCartCardComponent);
fixture.componentRef.setInput('title', 'Pancho');
fixture.componentRef.setInput('description', description);
fixture.componentRef.setInput('price', 11500);
fixture.detectChanges();
return fixture;
}
it('renders the product information and formats the price', async () => {
const fixture = await createComponent();
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('.product-vertical-with-cart-card__title')?.textContent).toContain(
'Pancho',
);
expect(
element.querySelector('.product-vertical-with-cart-card__description')?.textContent,
).toContain('Pancho con aderezo a elección.');
expect(
element.querySelector('.product-vertical-with-cart-card__price')?.textContent?.trim(),
).toBe('$ 11.500');
});
it('does not render an empty description', async () => {
const fixture = await createComponent('');
expect(
(fixture.nativeElement as HTMLElement).querySelector(
'.product-vertical-with-cart-card__description',
),
).toBeNull();
});
it('updates the quantity with the reusable quantity selector', async () => {
const fixture = await createComponent();
const buttons = fixture.nativeElement.querySelectorAll(
'app-quantity-selector button',
) as NodeListOf<HTMLButtonElement>;
expect(fixture.componentInstance.quantity()).toBe(1);
buttons[1].click();
fixture.detectChanges();
expect(fixture.componentInstance.quantity()).toBe(2);
buttons[0].click();
fixture.detectChanges();
expect(fixture.componentInstance.quantity()).toBe(1);
});
it('emits buy when Comprar is clicked', async () => {
const fixture = await createComponent();
const buySpy = vi.fn();
fixture.componentInstance.buy.subscribe(buySpy);
const button = fixture.nativeElement.querySelector(
'.product-vertical-with-cart-card__actions .btn-primary',
) as HTMLButtonElement;
button.click();
expect(buySpy).toHaveBeenCalledOnce();
});
it('emits addToCart with the current quantity', async () => {
const fixture = await createComponent();
const addToCartSpy = vi.fn();
fixture.componentInstance.addToCart.subscribe(addToCartSpy);
fixture.componentInstance.quantity.set(3);
fixture.detectChanges();
const button = fixture.nativeElement.querySelector(
'.product-vertical-with-cart-card__actions .btn-outline-primary',
) as HTMLButtonElement;
button.click();
expect(addToCartSpy).toHaveBeenCalledWith({ quantity: 3 });
});
it('uses the shared primary and secondary buttons', async () => {
const fixture = await createComponent();
const element = fixture.nativeElement as HTMLElement;
expect(
element.querySelectorAll('.product-vertical-with-cart-card__actions app-button'),
).toHaveLength(2);
expect(
element.querySelector('.product-vertical-with-cart-card__actions .btn-primary'),
).not.toBeNull();
expect(
element.querySelector('.product-vertical-with-cart-card__actions .btn-outline-primary'),
).not.toBeNull();
});
});

View File

@@ -0,0 +1,36 @@
import { ChangeDetectionStrategy, Component, computed, input, model, output } from '@angular/core';
import { ButtonComponent } from '../button/button.component';
import { QuantitySelectorComponent } from '../quantity-selector/quantity-selector.component';
@Component({
selector: 'app-product-vertical-with-cart-card',
imports: [ButtonComponent, QuantitySelectorComponent],
templateUrl: './product-vertical-with-cart-card.component.html',
styleUrl: './product-vertical-with-cart-card.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ProductVerticalWithCartCardComponent {
readonly title = input<string>('');
readonly description = input<string>('');
readonly price = input<number>(0);
readonly quantity = model<number>(1);
readonly buy = output<void>();
readonly addToCart = output<{ quantity: number }>();
protected readonly formattedPrice = computed(() => this.formatCurrency(this.price()));
protected onAddToCart(): void {
this.addToCart.emit({ quantity: this.quantity() });
}
private formatCurrency(value: number): string {
const rounded = Math.round(value);
const parts = rounded.toString().split('.');
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, '.');
return `$ ${parts.join(',')}`;
}
}

View File

@@ -0,0 +1,16 @@
<article class="qr-modal">
<header class="qr-modal__header">
<p class="qr-modal__id">ID: {{ data.id }}</p>
@if (data.date) {
<time class="qr-modal__date">{{ data.date }}</time>
}
</header>
<qrcode
class="qr-modal__code"
[qrdata]="data.ticket"
[width]="320"
errorCorrectionLevel="M"
[ariaLabel]="'Código QR del ticket ' + data.id"
/>
</article>

View File

@@ -0,0 +1,44 @@
.qr-modal {
display: grid;
justify-items: center;
gap: 0.65rem;
width: 100%;
text-align: center;
}
.qr-modal__header {
display: grid;
justify-items: center;
gap: 0.25rem;
}
.qr-modal__id {
margin: 0;
}
.qr-modal__id {
color: #4f4f4f;
font-size: 0.68rem;
line-height: 1.2;
}
.qr-modal__date {
margin-top: 0.35rem;
color: #8a8a8a;
font-size: 0.62rem;
line-height: 1.2;
}
.qr-modal__code {
display: block;
width: 100%;
max-width: 11rem;
overflow: hidden;
background: #ffffff;
}
:host ::ng-deep .qr-modal__code canvas {
display: block;
width: 100% !important;
height: auto !important;
}

View File

@@ -0,0 +1,15 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { MODAL_DATA, QrModalData } from '../../../core/services/modal.service';
import { QRCodeComponent } from '../qrcode/qrcode.component';
@Component({
selector: 'app-qr-modal',
imports: [QRCodeComponent],
templateUrl: './qr-modal.component.html',
styleUrl: './qr-modal.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class QrModalComponent {
protected readonly data = inject<QrModalData>(MODAL_DATA);
}

View File

@@ -7,7 +7,7 @@
width: 78px;
height: 40px;
min-height: 40px;
background: #f6f6f6;
background: inherit;
box-sizing: border-box;
&__button {
@@ -15,7 +15,7 @@
height: 100%;
font-size: 20px;
color: #6f6f6f;
background: #f6f6f6;
background: inherit;
transition: background-color 0.2s ease, color 0.2s ease;
cursor: pointer;
@@ -41,12 +41,12 @@
height: 21px;
min-height: 21px;
border: 1px solid #cfcfcf;
background: #ffffff;
background: inherit;
.quantity-selector__button {
width: 15px;
font-size: 10px;
background: #ffffff;
background: inherit;
color: #666666;
&:not(:disabled):hover {