Compare commits

..

1 Commits

104 changed files with 1621 additions and 4805 deletions

View File

@@ -1,24 +0,0 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { TenantService } from '../services/tenant.service';
export const hasMenuGuard = (menuCode: string): CanActivateFn => {
return () => {
const tenantService = inject(TenantService);
const router = inject(Router);
const tenant = tenantService.tenant();
if (!tenant) {
return router.createUrlTree(['/']);
}
const hasMenu = tenant.menues?.some(menu => menu.code === menuCode) ?? false;
if (hasMenu) {
return true;
}
return router.createUrlTree(['/']);
};
};

View File

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

View File

@@ -2,11 +2,7 @@ import { Component, computed, inject, OnInit, signal } from '@angular/core';
import { Router, RouterOutlet } from '@angular/router'; import { Router, RouterOutlet } from '@angular/router';
import { TenantService } from '../../services/tenant.service'; import { TenantService } from '../../services/tenant.service';
import { CartService } from '../../services/cart/cart.service'; import { CartService } from '../../services/cart/cart.service';
import { import { StoreFooterComponent, StoreFooterSection, StoreSocialLink } from './store-footer/store-footer.component';
StoreFooterComponent,
StoreFooterSection,
StoreSocialLink,
} from './store-footer/store-footer.component';
import { StoreHeaderComponent } from './store-header/store-header.component'; import { StoreHeaderComponent } from './store-header/store-header.component';
import { CartComponent, CartItemMock } from '../../../shared/components/cart/cart.component'; import { CartComponent, CartItemMock } from '../../../shared/components/cart/cart.component';
import { ButtonComponent } from '../../../shared/components/button/button.component'; import { ButtonComponent } from '../../../shared/components/button/button.component';
@@ -15,15 +11,9 @@ import { AuthService } from '../../services/auth/auth.service';
@Component({ @Component({
selector: 'app-store-layout', selector: 'app-store-layout',
imports: [ imports: [RouterOutlet, StoreHeaderComponent, StoreFooterComponent, CartComponent, ButtonComponent],
RouterOutlet,
StoreHeaderComponent,
StoreFooterComponent,
CartComponent,
ButtonComponent,
],
templateUrl: './store-layout.component.html', templateUrl: './store-layout.component.html',
styleUrl: './store-layout.component.scss', styleUrl: './store-layout.component.scss'
}) })
export class StoreLayoutComponent implements OnInit { export class StoreLayoutComponent implements OnInit {
private readonly tenantService = inject(TenantService); private readonly tenantService = inject(TenantService);
@@ -67,14 +57,14 @@ export class StoreLayoutComponent implements OnInit {
} }
return { return {
cartItemId: item.id, productVariantId: item.product_variant_id,
imageUrl: item.product?.imagen ?? null, imageUrl: item.product?.imagen ?? null,
product, product,
originalPrice: null, originalPrice: null,
discountedPrice: parseFloat(item.precio_unitario), discountedPrice: parseFloat(item.precio_unitario),
discountPercentage: null, discountPercentage: null,
attributes, attributes,
quantity: item.cantidad, quantity: item.cantidad
}; };
} }
@@ -91,7 +81,7 @@ export class StoreLayoutComponent implements OnInit {
ngOnInit(): void { ngOnInit(): void {
this.cartService.loadCart().subscribe({ this.cartService.loadCart().subscribe({
error: (err) => console.error('Error loading cart', err), error: (err) => console.error('Error loading cart', err)
}); });
} }
@@ -102,7 +92,7 @@ export class StoreLayoutComponent implements OnInit {
protected onLogoutClick(): void { protected onLogoutClick(): void {
this.authService.logout().subscribe({ this.authService.logout().subscribe({
next: () => void this.router.navigate(['/login']), next: () => void this.router.navigate(['/login']),
error: (err) => console.error('Error logging out', err), error: (err) => console.error('Error logging out', err)
}); });
} }
@@ -111,33 +101,34 @@ export class StoreLayoutComponent implements OnInit {
void this.router.navigate(['/checkout']); void this.router.navigate(['/checkout']);
} }
protected readonly footerSections: StoreFooterSection[] = [ protected readonly footerSections: StoreFooterSection[] = [
{ {
heading: 'Cuenta', heading: 'Cuenta',
links: ['Mi cuenta', 'Mis compras', 'Cerrar sesion'], links: ['Mi cuenta', 'Mis compras', 'Cerrar sesion']
}, },
{ {
heading: 'Ayuda', heading: 'Ayuda',
links: ['Contacto', 'Ayuda', 'Preguntas frecuentes'], links: ['Contacto', 'Ayuda', 'Preguntas frecuentes']
}, }
]; ];
protected readonly socialLinks: StoreSocialLink[] = [ protected readonly socialLinks: StoreSocialLink[] = [
{ {
label: 'Instagram', label: 'Instagram',
iconClass: 'fa-brands fa-instagram', iconClass: 'fa-brands fa-instagram'
}, },
{ {
label: 'WhatsApp', label: 'WhatsApp',
iconClass: 'fa-brands fa-whatsapp', iconClass: 'fa-brands fa-whatsapp'
}, },
{ {
label: 'Facebook', label: 'Facebook',
iconClass: 'fa-brands fa-facebook', iconClass: 'fa-brands fa-facebook'
}, },
{ {
label: 'LinkedIn', label: 'LinkedIn',
iconClass: 'fa-brands fa-linkedin-in', iconClass: 'fa-brands fa-linkedin-in'
}, }
]; ];
} }

View File

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

View File

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

View File

@@ -6,7 +6,6 @@ import { HttpTestingController, provideHttpClientTesting } from '@angular/common
import { environment } from '../../../../environments/environment'; import { environment } from '../../../../environments/environment';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { CookieService } from '../cookie/cookie.service'; import { CookieService } from '../cookie/cookie.service';
import { TenantService } from '../tenant.service';
describe('AuthService', () => { describe('AuthService', () => {
let cookieStore: Record<string, string> = {}; let cookieStore: Record<string, string> = {};
@@ -96,7 +95,7 @@ describe('AuthService', () => {
httpController.verify(); httpController.verify();
}); });
it('clears an expired session when /me returns 401', async () => { it('clears the session when bootstrap receives 401 from /me', async () => {
cookieStore['shopit.auth.token'] = 'expired-token'; cookieStore['shopit.auth.token'] = 'expired-token';
TestBed.configureTestingModule({ TestBed.configureTestingModule({
@@ -133,10 +132,6 @@ describe('AuthService', () => {
provideHttpClientTesting(), provideHttpClientTesting(),
AuthService, AuthService,
{ provide: CookieService, useValue: createCookieServiceStub() }, { provide: CookieService, useValue: createCookieServiceStub() },
{
provide: TenantService,
useValue: { getTenant: () => ({ codigo: 'tenant-test' }) }
},
TransferState TransferState
] ]
}); });
@@ -157,7 +152,6 @@ describe('AuthService', () => {
const request = httpController.expectOne(`${environment.url}register`); const request = httpController.expectOne(`${environment.url}register`);
expect(request.request.method).toBe('POST'); expect(request.request.method).toBe('POST');
expect(request.request.body.tenant_codigo).toBe('tenant-test');
request.flush({ request.flush({
message: 'Usuario registrado correctamente.', message: 'Usuario registrado correctamente.',
data: { data: {
@@ -174,7 +168,7 @@ describe('AuthService', () => {
httpController.verify(); httpController.verify();
}); });
it('propagates logout errors without clearing the local session', () => { it('clears local session even when logout request fails', () => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [ providers: [
provideHttpClient(), provideHttpClient(),
@@ -208,9 +202,9 @@ describe('AuthService', () => {
expect(logoutRequest.request.method).toBe('POST'); expect(logoutRequest.request.method).toBe('POST');
logoutRequest.flush({ message: 'Unauthorized' }, { status: 401, statusText: 'Unauthorized' }); logoutRequest.flush({ message: 'Unauthorized' }, { status: 401, statusText: 'Unauthorized' });
expect(service.token()).toBe('plain-text-token'); expect(service.token()).toBeNull();
expect(service.user()?.email).toBe('ada@example.com'); expect(service.user()).toBeNull();
expect(cookieStore['shopit.auth.token']).toBe('plain-text-token'); expect(cookieStore['shopit.auth.token']).toBeUndefined();
httpController.verify(); httpController.verify();
}); });

View File

@@ -1,4 +1,3 @@
import { DOCUMENT } from '@angular/common';
import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { isPlatformBrowser, isPlatformServer } from '@angular/common'; import { isPlatformBrowser, isPlatformServer } from '@angular/common';
import { computed, inject, Injectable, PLATFORM_ID, signal, TransferState, makeStateKey } from '@angular/core'; import { computed, inject, Injectable, PLATFORM_ID, signal, TransferState, makeStateKey } from '@angular/core';
@@ -14,7 +13,6 @@ import {
UpdateProfilePayload UpdateProfilePayload
} from './auth.interfaces'; } from './auth.interfaces';
import { CookieService } from '../cookie/cookie.service'; import { CookieService } from '../cookie/cookie.service';
import { TenantService } from '../tenant.service';
const AUTH_TOKEN_COOKIE_KEY = 'shopit.auth.token'; const AUTH_TOKEN_COOKIE_KEY = 'shopit.auth.token';
const AUTH_USER_SSR_STATE_KEY = makeStateKey<AuthUser>('shopit.auth.user'); const AUTH_USER_SSR_STATE_KEY = makeStateKey<AuthUser>('shopit.auth.user');
@@ -24,11 +22,9 @@ const AUTH_USER_SSR_STATE_KEY = makeStateKey<AuthUser>('shopit.auth.user');
}) })
export class AuthService { export class AuthService {
private readonly http = inject(HttpClient); private readonly http = inject(HttpClient);
private readonly document = inject(DOCUMENT);
private readonly platformId = inject(PLATFORM_ID); private readonly platformId = inject(PLATFORM_ID);
private readonly cookieService = inject(CookieService); private readonly cookieService = inject(CookieService);
private readonly transferState = inject(TransferState); private readonly transferState = inject(TransferState);
private readonly tenantService = inject(TenantService);
private readonly userState = signal<AuthUser | null>(null); private readonly userState = signal<AuthUser | null>(null);
private readonly tokenState = signal<string | null>(null); private readonly tokenState = signal<string | null>(null);
@@ -45,33 +41,7 @@ export class AuthService {
} }
register(payload: RegisterPayload): Observable<RegisterResponse> { register(payload: RegisterPayload): Observable<RegisterResponse> {
const tenantCode = this.tenantService.getTenant()?.codigo; return this.http.post<RegisterResponse>(`${environment.url}register`, payload);
return this.http.post<RegisterResponse>(`${environment.url}register`, {
...payload,
...(tenantCode ? { tenant_codigo: tenantCode } : {})
});
}
loginWithGoogle(): void {
const tenant = this.tenantService.getTenant();
if (!tenant) {
throw new Error('No se pudo resolver el tenant activo.');
}
const apiUrl = new URL(environment.url);
const authorizationUrl = new URL('/auth/google/redirect', apiUrl.origin);
authorizationUrl.searchParams.set('tenant', tenant.codigo);
authorizationUrl.searchParams.set('return_url', this.document.location.origin);
this.document.location.assign(authorizationUrl.toString());
}
completeGoogleLogin(oauthCode: string): Observable<AuthUser> {
return this.http.post<LoginResponse>(`${environment.url}auth/google/exchange`, { oauth_code: oauthCode }).pipe(
tap((response) => this.applyAuthenticatedState(response.token, response.user)),
map((response) => response.user)
);
} }
updateProfile(payload: UpdateProfilePayload): Observable<AuthUser> { updateProfile(payload: UpdateProfilePayload): Observable<AuthUser> {
@@ -87,7 +57,10 @@ export class AuthService {
} }
return this.http.post<void>(`${environment.url}logout`, {}).pipe( return this.http.post<void>(`${environment.url}logout`, {}).pipe(
tap(() => this.clearSession()) tap({
next: () => this.clearSession(),
error: () => this.clearSession()
})
); );
} }
@@ -107,18 +80,12 @@ export class AuthService {
try { try {
const user = await firstValueFrom(this.loadCurrentUser()); const user = await firstValueFrom(this.loadCurrentUser());
if (isPlatformServer(this.platformId)) { if (isPlatformServer(this.platformId)) {
this.transferState.set(AUTH_USER_SSR_STATE_KEY, user); this.transferState.set(AUTH_USER_SSR_STATE_KEY, user);
} }
} catch (error) { } catch (error) {
// An expired token is an expected state, including while rendering on the if (this.isUnauthorizedError(error)) {
// server. Do not let it reject the application initializer: Angular's SSR
// pipeline can otherwise attempt to serialize HttpErrorResponse internals
// as HTTP headers.
if (error instanceof HttpErrorResponse && error.status === 401) {
this.clearSession(); this.clearSession();
return; return;
} }
@@ -148,4 +115,8 @@ export class AuthService {
this.userState.set(user); this.userState.set(user);
this.cookieService.set(AUTH_TOKEN_COOKIE_KEY, token); this.cookieService.set(AUTH_TOKEN_COOKIE_KEY, token);
} }
private isUnauthorizedError(error: unknown): error is HttpErrorResponse {
return error instanceof HttpErrorResponse && error.status === 401;
}
} }

View File

@@ -7,8 +7,8 @@ export interface CartItem {
id: number; id: number;
cantidad: number; cantidad: number;
precio_unitario: string; precio_unitario: string;
catalog_item_id: number; product_id: number;
variant_id: number | null; product_variant_id: number;
product: CartItemProduct | null; product: CartItemProduct | null;
} }

View File

@@ -21,21 +21,21 @@ describe('CartService', () => {
id: 1, id: 1,
cantidad: 2, cantidad: 2,
precio_unitario: '10.00', precio_unitario: '10.00',
catalog_item_id: 5, product_id: 5,
variant_id: 10, product_variant_id: 10,
product: { product: {
nombre: 'Test Product (Size: M)', nombre: 'Test Product (Size: M)',
imagen: null, imagen: null
}, }
}, }
], ],
subtotal: '20.00', subtotal: '20.00'
}; };
beforeEach(() => { beforeEach(() => {
tenantServiceMock = { tenantServiceMock = {
getTenantApiUrl: vi.fn().mockReturnValue('http://api.test/tenants/acme'), getTenantApiUrl: vi.fn().mockReturnValue('http://api.test/tenants/acme'),
tenant: vi.fn().mockReturnValue({ codigo: 'acme' }), tenant: vi.fn().mockReturnValue({ codigo: 'acme' })
}; };
TestBed.configureTestingModule({ TestBed.configureTestingModule({
@@ -43,8 +43,8 @@ describe('CartService', () => {
CartService, CartService,
provideHttpClient(), provideHttpClient(),
provideHttpClientTesting(), provideHttpClientTesting(),
{ provide: TenantService, useValue: tenantServiceMock }, { provide: TenantService, useValue: tenantServiceMock }
], ]
}); });
service = TestBed.inject(CartService); service = TestBed.inject(CartService);
@@ -83,23 +83,19 @@ describe('CartService', () => {
tenant_codigo: 'acme', tenant_codigo: 'acme',
status: 'active', status: 'active',
items: [], items: [],
subtotal: '0.00', subtotal: '0.00'
}); });
}); });
it('should add item and update signal', () => { it('should add item and update signal', () => {
service.addItem(5, 10, 2).subscribe((res) => { service.addItem(10, 2).subscribe((res) => {
expect(res.data).toEqual(mockCart); expect(res.data).toEqual(mockCart);
expect(service.cart()).toEqual(mockCart); expect(service.cart()).toEqual(mockCart);
}); });
const req = httpMock.expectOne('http://api.test/tenants/acme/cart/items'); const req = httpMock.expectOne('http://api.test/tenants/acme/cart/items');
expect(req.request.method).toBe('POST'); expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual({ expect(req.request.body).toEqual({ product_variant_id: 10, cantidad: 2 });
catalog_item_id: 5,
variant_id: 10,
cantidad: 2,
});
expect(req.request.withCredentials).toBe(true); expect(req.request.withCredentials).toBe(true);
req.flush({ data: mockCart }); req.flush({ data: mockCart });
}); });
@@ -126,7 +122,7 @@ describe('CartService', () => {
tenant_codigo: 'acme', tenant_codigo: 'acme',
status: 'active', status: 'active',
items: [], items: [],
subtotal: '0.00', subtotal: '0.00'
}; };
service.removeItem(10).subscribe((res) => { service.removeItem(10).subscribe((res) => {

View File

@@ -7,7 +7,7 @@ import { TenantService } from '../tenant.service';
import { Cart } from './cart.interface'; import { Cart } from './cart.interface';
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root'
}) })
export class CartService { export class CartService {
private readonly http = inject(HttpClient); private readonly http = inject(HttpClient);
@@ -29,31 +29,29 @@ export class CartService {
tenant_codigo: this.tenantService.tenant()?.codigo ?? '', tenant_codigo: this.tenantService.tenant()?.codigo ?? '',
status: 'active', status: 'active',
items: [], items: [],
subtotal: '0.00', subtotal: '0.00'
}); });
} }
loadCart(): Observable<Cart> { loadCart(): Observable<Cart> {
return this.http return this.http
.get<ApiResponse<Cart>>(`${this.tenantApiUrl}/cart`, { .get<ApiResponse<Cart>>(`${this.tenantApiUrl}/cart`, {
withCredentials: true, withCredentials: true
}) })
.pipe( .pipe(
map((response) => response.data), map((response) => response.data),
tap((cart) => this.cartState.set(cart)), tap((cart) => this.cartState.set(cart))
); );
} }
addItem( addItem(productVariantId: number, cantidad: number): Observable<ApiResponse<Cart>> {
catalogItemId: number,
variantId: number | null,
cantidad: number,
): Observable<ApiResponse<Cart>> {
this.isUpdatingState.set(true); this.isUpdatingState.set(true);
return this.http return this.http
.post< .post<ApiResponse<Cart>>(
ApiResponse<Cart> `${this.tenantApiUrl}/cart/items`,
>(`${this.tenantApiUrl}/cart/items`, { catalog_item_id: catalogItemId, variant_id: variantId, cantidad }, { withCredentials: true }) { product_variant_id: productVariantId, cantidad },
{ withCredentials: true }
)
.pipe( .pipe(
tap((response) => { tap((response) => {
this.cartState.set(response.data); this.cartState.set(response.data);
@@ -62,16 +60,18 @@ export class CartService {
catchError((error) => { catchError((error) => {
this.isUpdatingState.set(false); this.isUpdatingState.set(false);
throw error; throw error;
}), })
); );
} }
updateItemQuantity(cartItemId: number, cantidad: number): Observable<ApiResponse<Cart>> { updateItemQuantity(productVariantId: number, cantidad: number): Observable<ApiResponse<Cart>> {
this.isUpdatingState.set(true); this.isUpdatingState.set(true);
return this.http return this.http
.patch< .patch<ApiResponse<Cart>>(
ApiResponse<Cart> `${this.tenantApiUrl}/cart/items/${productVariantId}`,
>(`${this.tenantApiUrl}/cart/items/${cartItemId}`, { cantidad }, { withCredentials: true }) { cantidad },
{ withCredentials: true }
)
.pipe( .pipe(
tap((response) => { tap((response) => {
this.cartState.set(response.data); this.cartState.set(response.data);
@@ -80,16 +80,17 @@ export class CartService {
catchError((error) => { catchError((error) => {
this.isUpdatingState.set(false); this.isUpdatingState.set(false);
throw error; throw error;
}), })
); );
} }
removeItem(cartItemId: number): Observable<ApiResponse<Cart>> { removeItem(productVariantId: number): Observable<ApiResponse<Cart>> {
this.isUpdatingState.set(true); this.isUpdatingState.set(true);
return this.http return this.http
.delete< .delete<ApiResponse<Cart>>(
ApiResponse<Cart> `${this.tenantApiUrl}/cart/items/${productVariantId}`,
>(`${this.tenantApiUrl}/cart/items/${cartItemId}`, { withCredentials: true }) { withCredentials: true }
)
.pipe( .pipe(
tap((response) => { tap((response) => {
this.cartState.set(response.data); this.cartState.set(response.data);
@@ -98,7 +99,7 @@ export class CartService {
catchError((error) => { catchError((error) => {
this.isUpdatingState.set(false); this.isUpdatingState.set(false);
throw error; throw error;
}), })
); );
} }
} }

View File

@@ -1,5 +1,3 @@
import { ApiPaginatedResponse } from '../api-paginated-response.interface';
export interface Product { export interface Product {
id: number; id: number;
category_id: number; category_id: number;
@@ -31,61 +29,22 @@ export interface ProductAttribute {
options: ProductAttributeOption[]; options: ProductAttributeOption[];
} }
export type InventoryPolicy = 'tracked' | 'unlimited'; export interface ProductVariant {
export interface CatalogItemVariant {
id: number; id: number;
stock_tecnico: number | null; cantidad_maxima: number;
values: Record<string, string>; definitions: Record<string, string>;
}
export interface SelectedCatalogItemVariant extends CatalogItemVariant {
images: string[]; images: string[];
} }
export interface CatalogItemDetail { export interface ProductVariantMap {
id: number; variant_id: number;
category_id: number | null; cantidad_maxima: number;
brand_id: number | null; attributes: Record<string, string>;
slug: string; }
nombre: string;
descripcion: string | null; export interface ProductDetail extends Product {
precio: string;
category: string | null;
brand: string | null;
inventory_policy: InventoryPolicy;
has_tickets: boolean;
minimum_use_date: string | null;
maximum_use_date: string | null;
attributes: ProductAttribute[]; attributes: ProductAttribute[];
variants: CatalogItemVariant[]; variants_map: ProductVariantMap[];
selected_variant?: SelectedCatalogItemVariant; variant: ProductVariant | null;
stock_tecnico?: number | null;
images?: string[];
} }
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,17 +6,16 @@ import { ApiPaginationQueryParams } from '../api-pagination-query-params.interfa
import { ApiPaginatedResponse } from '../api-paginated-response.interface'; import { ApiPaginatedResponse } from '../api-paginated-response.interface';
import { ApiResponse } from '../api-response.interface'; import { ApiResponse } from '../api-response.interface';
import { TenantService } from '../tenant.service'; import { TenantService } from '../tenant.service';
import { import { Product, ProductDetail } from './catalog.interface';
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({ @Injectable({
providedIn: 'root', providedIn: 'root'
}) })
export class CatalogService { export class CatalogService {
private readonly http = inject(HttpClient); private readonly http = inject(HttpClient);
@@ -26,34 +25,24 @@ export class CatalogService {
return this.tenantService.getTenantApiUrl(); return this.tenantService.getTenantApiUrl();
} }
getProductos(params?: ApiPaginationQueryParams): Observable<ApiPaginatedResponse<Product[]>> {
return this.http.get<ApiPaginatedResponse<Product[]>>(`${this.tenantApiUrl}/productos`, {
params: this.buildHttpParams(params),
});
}
getCatalog(): Observable<CatalogFeaturedGroup[]> { getProductos(
return this.http.get<CatalogFeaturedGroup[]>(`${this.tenantApiUrl}/catalog`); params?: ApiPaginationQueryParams
} ): Observable<ApiPaginatedResponse<Product[]>> {
return this.http.get<ApiPaginatedResponse<Product[]>>(
getFeaturedGroupItems( `${this.tenantApiUrl}/productos`,
featuredGroupId: number, { params: this.buildHttpParams(params) }
params?: ApiPaginationQueryParams,
): Observable<ApiPaginatedResponse<CatalogFeaturedItem[]>> {
return this.http.get<ApiPaginatedResponse<CatalogFeaturedItem[]>>(
`${this.tenantApiUrl}/catalog/featured-groups/${featuredGroupId}/items`,
{ params: this.buildHttpParams(params) },
); );
} }
getCatalogItem(id: number, variantId?: number): Observable<CatalogItemDetail> { getProducto(id: number, variantId?: number): Observable<ProductDetail> {
let params = new HttpParams(); let params = new HttpParams();
if (variantId) { if (variantId) {
params = params.set('variant_id', variantId); params = params.set('variant_id', variantId);
} }
return this.http return this.http
.get<ApiResponse<CatalogItemDetail>>(`${this.tenantApiUrl}/catalog-items/${id}`, { params }) .get<ApiResponse<ProductDetail>>(`${this.tenantApiUrl}/productos/${id}`, { params })
.pipe(map((response) => response.data)); .pipe(map((response) => response.data));
} }
@@ -70,7 +59,7 @@ export class CatalogService {
return acc; return acc;
}, },
{}, {}
); );
return new HttpParams({ fromObject }); return new HttpParams({ fromObject });

View File

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

View File

@@ -23,9 +23,12 @@ export class CookieService {
return null; return null;
} }
set(name: string, value: string): void { set(name: string, value: string, days = 7): void {
if (isPlatformBrowser(this.platformId)) { if (isPlatformBrowser(this.platformId)) {
this.document.cookie = `${name}=${value};path=/;SameSite=Lax`; 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`;
} }
} }

View File

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

View File

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

View File

@@ -9,26 +9,6 @@ export interface BankAccount {
cvu: string; cvu: string;
} }
export interface HeroConfig {
background_image?: string | null;
title_html?: string | null;
description_html?: string | null;
button_text?: string | null;
button_href?: string | null;
}
export interface EventConfig {
title?: string | null;
location?: string | null;
dates?: string[] | null;
}
export interface Menu {
id: number;
code: string;
route: string;
}
export interface Tenant { export interface Tenant {
id: number; id: number;
codigo: string; codigo: string;
@@ -44,9 +24,6 @@ export interface Tenant {
footer_logo: string; footer_logo: string;
selected_bank_account_id?: number | null; selected_bank_account_id?: number | null;
selected_bank_account?: BankAccount | null; selected_bank_account?: BankAccount | null;
hero_config?: HeroConfig | null;
event_config?: EventConfig | null;
menues?: Menu[];
} }
export type TenantBootstrapResponse = ApiResponse<Tenant>; export type TenantBootstrapResponse = ApiResponse<Tenant>;

View File

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

View File

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

View File

@@ -1,13 +1,14 @@
import { Component, inject } from '@angular/core'; import { Component, inject } from '@angular/core';
import { ButtonComponent } from '../../../../shared/components/button/button.component'; 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 { CartIconComponent } from '../../../../shared/components/cart-icon/cart-icon.component';
import { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.component'; import { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.component';
import { InputComponent } from '../../../../shared/components/input/input.component'; import { InputComponent } from '../../../../shared/components/input/input.component';
import { PaginatorComponent } from '../../../../shared/components/paginator/paginator.component'; import { PaginatorComponent } from '../../../../shared/components/paginator/paginator.component';
import { ProductColumnWithImageComponent } from '../../../../shared/components/product-column-with-image/product-column-with-image.component'; import { ProductCardComponent } from '../../../../shared/components/product-card/product-card.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 { StoreSectionComponent } from '../../../../shared/components/store-section/store-section.component';
import { ModalService } from '../../../../core/services/modal.service'; import { ModalService } from '../../../../core/services/modal.service';
import { TenantService } from '../../../../core/services/tenant.service'; import { TenantService } from '../../../../core/services/tenant.service';
@@ -22,17 +23,15 @@ import { StepComponent } from '../../../../shared/components/stepper/step.compon
CartComponent, CartComponent,
InputComponent, InputComponent,
PaginatorComponent, PaginatorComponent,
ProductColumnWithImageComponent, ProductCardComponent,
ProductRowCardComponent,
ProductVerticalWithCartCardComponent,
StoreSectionComponent, StoreSectionComponent,
IconButtonComponent, IconButtonComponent,
CartIconComponent, CartIconComponent,
StepperComponent, StepperComponent,
StepComponent, StepComponent
], ],
templateUrl: './reutilizables-test-page.component.html', templateUrl: './reutilizables-test-page.component.html',
styleUrl: './reutilizables-test-page.component.scss', styleUrl: './reutilizables-test-page.component.scss'
}) })
export class ReutilizablesTestPageComponent { export class ReutilizablesTestPageComponent {
private readonly tenantService = inject(TenantService); private readonly tenantService = inject(TenantService);
@@ -69,41 +68,24 @@ export class ReutilizablesTestPageComponent {
originalPrice: 95000, originalPrice: 95000,
discount: 20, discount: 20,
transferPrice: 74800, transferPrice: 74800,
imageUrl: '/images/pantalon-scuba.png', imageUrl: '/images/pantalon-scuba.png'
}, },
{ {
title: 'Zapatillas de running azul', title: 'Zapatillas de running azul',
originalPrice: 120000, originalPrice: 120000,
discount: 15, discount: 15,
transferPrice: 98000, transferPrice: 98000,
imageUrl: '/images/zapatillas-running.png', imageUrl: '/images/zapatillas-running.png'
}, },
{ {
title: 'Remera basica blanca (Sin Descuento)', title: 'Remera basica blanca (Sin Descuento)',
originalPrice: 32000, originalPrice: 32000,
discount: null, discount: null,
transferPrice: 30000, 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[] = [ protected readonly cartMockItems: CartItemMock[] = [
{ {
imageUrl: null, imageUrl: null,
@@ -114,8 +96,8 @@ export class ReutilizablesTestPageComponent {
quantity: 1, quantity: 1,
attributes: [ attributes: [
{ label: 'Color', value: 'Beige' }, { label: 'Color', value: 'Beige' },
{ label: 'Talle', value: 'S' }, { label: 'Talle', value: 'S' }
], ]
}, },
{ {
imageUrl: null, imageUrl: null,
@@ -126,8 +108,8 @@ export class ReutilizablesTestPageComponent {
quantity: 1, quantity: 1,
attributes: [ attributes: [
{ label: 'Color', value: 'Beige' }, { label: 'Color', value: 'Beige' },
{ label: 'Talle', value: 'S' }, { label: 'Talle', value: 'S' }
], ]
}, },
{ {
imageUrl: null, imageUrl: null,
@@ -138,8 +120,8 @@ export class ReutilizablesTestPageComponent {
quantity: 1, quantity: 1,
attributes: [ attributes: [
{ label: 'Color', value: 'Negro' }, { label: 'Color', value: 'Negro' },
{ label: 'Talle', value: 'XL' }, { label: 'Talle', value: 'XL' }
], ]
}, },
{ {
imageUrl: null, imageUrl: null,
@@ -150,9 +132,9 @@ export class ReutilizablesTestPageComponent {
quantity: 2, quantity: 2,
attributes: [ attributes: [
{ label: 'Color', value: 'Gris' }, { label: 'Color', value: 'Gris' },
{ label: 'Talle', value: 'M' }, { label: 'Talle', value: 'M' }
], ]
}, }
]; ];
protected onProductBuy(productTitle: string): void { protected onProductBuy(productTitle: string): void {
@@ -180,32 +162,45 @@ export class ReutilizablesTestPageComponent {
protected triggerToast(type: 'success' | 'danger' | 'info'): void { protected triggerToast(type: 'success' | 'danger' | 'info'): void {
if (type === 'success') { 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') { } 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') { } 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 { protected triggerPersistentToast(type: 'success' | 'danger' | 'info'): void {
if (type === 'success') { 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') { } else if (type === 'danger') {
this.toastService.danger( this.toastService.danger(
'Error persistente. Por favor, atienda este problema y cierrelo manualmente.', 'Error persistente. Por favor, atienda este problema y cierrelo manualmente.',
0, 0
); );
} else if (type === 'info') { } 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 { protected openBasicModal(): void {
this.openConfirmModal({ this.openConfirmModal({
title: 'Confirmar accion', title: 'Confirmar accion',
content: 'Caso base para verificar apertura, cierre y devolucion de resultado.', content:
confirmLabel: 'Confirmar', 'Caso base para verificar apertura, cierre y devolucion de resultado.',
confirmLabel: 'Confirmar'
}); });
} }
@@ -214,17 +209,18 @@ export class ReutilizablesTestPageComponent {
title: 'Eliminar producto', title: 'Eliminar producto',
content: content:
'Esta accion eliminara el producto del catalogo. Podras volver a crearlo manualmente.', 'Esta accion eliminara el producto del catalogo. Podras volver a crearlo manualmente.',
confirmLabel: 'Eliminar', confirmLabel: 'Eliminar'
}); });
} }
protected openLockedModal(): void { protected openLockedModal(): void {
this.openConfirmModal({ this.openConfirmModal({
title: 'Modal bloqueado', 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', confirmLabel: 'Entendido',
closeOnBackdrop: false, closeOnBackdrop: false,
closeOnEscape: false, closeOnEscape: false
}); });
} }
@@ -233,20 +229,18 @@ export class ReutilizablesTestPageComponent {
title: 'Modal ancho', title: 'Modal ancho',
content: 'Demuestra una variante visual mas amplia para contenido mas pesado.', content: 'Demuestra una variante visual mas amplia para contenido mas pesado.',
confirmLabel: 'Seguir', confirmLabel: 'Seguir',
size: 'xl', size: 'xl'
}); });
} }
protected openSimpleModal(): void { protected openSimpleModal(): void {
this.modalService this.modalService.openSimple({
.openSimple({ title: 'Mensaje del sistema',
title: 'Mensaje del sistema', content: 'Este es un mensaje simple del sistema que no requiere confirmación.',
content: 'Este es un mensaje simple del sistema que no requiere confirmación.', buttonLabel: 'Entendido'
buttonLabel: 'Entendido', }).subscribe(() => {
}) this.lastModalResult = 'Simple modal cerrado';
.subscribe(() => { });
this.lastModalResult = 'Simple modal cerrado';
});
} }
private openConfirmModal(config: Parameters<ModalService['openConfirm']>[0]): void { private openConfirmModal(config: Parameters<ModalService['openConfirm']>[0]): void {
@@ -255,9 +249,13 @@ 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.modalService.openConfirmDelete(config).subscribe((confirmed) => {
this.lastModalResult = `Resultado: ${confirmed}`; this.lastModalResult = `Resultado: ${confirmed}`;
}); });
} }
} }

View File

@@ -1,71 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { beforeEach, describe, expect, it } from 'vitest';
import { ProductAttribute } from '../../../../core/services/catalog/catalog.interface';
import { ProductAttributeSelectorComponent } from './product-attribute-selector.component';
describe('ProductAttributeSelectorComponent', () => {
const sizeAttribute: ProductAttribute = {
id: 1,
codigo: 'size',
nombre: 'Size',
is_required: true,
metadata_schema: null,
type: 'select',
options: [
{ id: 10, value: 'S', label: 'S', sort_order: 0, metadata: null },
{ id: 20, value: 'M', label: 'M', sort_order: 1, metadata: null },
],
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ProductAttributeSelectorComponent],
}).compileComponents();
});
it('keeps an unlimited option available when maximum quantity is null', () => {
const fixture = TestBed.createComponent(ProductAttributeSelectorComponent);
fixture.componentRef.setInput('attributes', [sizeAttribute]);
fixture.componentRef.setInput('inventoryPolicy', 'unlimited');
fixture.componentRef.setInput('variants', [
{
id: 1,
stock_tecnico: null,
values: { size: 'S' },
},
]);
fixture.detectChanges();
const buttons = Array.from(
fixture.nativeElement.querySelectorAll('.attribute-selector__text-option'),
) as HTMLButtonElement[];
expect(buttons[0].disabled).toBe(false);
expect(buttons[1].disabled).toBe(true);
});
it('disables tracked options without available stock', () => {
const fixture = TestBed.createComponent(ProductAttributeSelectorComponent);
fixture.componentRef.setInput('attributes', [sizeAttribute]);
fixture.componentRef.setInput('inventoryPolicy', 'tracked');
fixture.componentRef.setInput('variants', [
{
id: 1,
stock_tecnico: 0,
values: { size: 'S' },
},
{
id: 2,
stock_tecnico: 2,
values: { size: 'M' },
},
]);
fixture.detectChanges();
const buttons = Array.from(
fixture.nativeElement.querySelectorAll('.attribute-selector__text-option'),
) as HTMLButtonElement[];
expect(buttons[0].disabled).toBe(true);
expect(buttons[1].disabled).toBe(false);
});
});

View File

@@ -6,14 +6,14 @@ import {
input, input,
output, output,
signal, signal,
untracked, untracked
} from '@angular/core'; } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { import {
CatalogItemVariant,
InventoryPolicy,
ProductAttribute, ProductAttribute,
ProductAttributeOption, ProductAttributeOption,
ProductVariant,
ProductVariantMap
} from '../../../../core/services/catalog/catalog.interface'; } from '../../../../core/services/catalog/catalog.interface';
@Component({ @Component({
@@ -22,21 +22,20 @@ import {
imports: [CommonModule], imports: [CommonModule],
templateUrl: './product-attribute-selector.component.html', templateUrl: './product-attribute-selector.component.html',
styleUrl: './product-attribute-selector.component.scss', styleUrl: './product-attribute-selector.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class ProductAttributeSelectorComponent { export class ProductAttributeSelectorComponent {
public attributes = input<ProductAttribute[]>([]); public attributes = input<ProductAttribute[]>([]);
public variants = input<CatalogItemVariant[]>([]); public variantsMap = input<ProductVariantMap[]>([]);
public selectedVariant = input<CatalogItemVariant | null>(null); public defaultVariant = input<ProductVariant | null>(null);
public inventoryPolicy = input.required<InventoryPolicy>();
public variantChange = output<CatalogItemVariant | null>(); public variantChange = output<ProductVariantMap | null>();
protected readonly selectedAttributeOptions = signal<Record<number, number>>({}); protected readonly selectedAttributeOptions = signal<Record<number, number>>({});
protected readonly availableOptions = computed(() => { protected readonly availableOptions = computed(() => {
const selections = this.selectedAttributeOptions(); const selections = this.selectedAttributeOptions();
const variants = this.variants(); const variants = this.variantsMap();
const attributes = this.attributes(); const attributes = this.attributes();
const availability: Record<number, Record<number, boolean>> = {}; const availability: Record<number, Record<number, boolean>> = {};
@@ -45,30 +44,28 @@ export class ProductAttributeSelectorComponent {
availability[attribute.id] = {}; availability[attribute.id] = {};
for (const option of attribute.options) { for (const option of attribute.options) {
const optionNormalized = this.normalizeText(option.value || option.label); const optionNormalized = this.normalizeText(option.value || option.label);
const isAvailable = variants.some(variant => {
if (variant.cantidad_maxima <= 0) return false;
const isAvailable = variants.some((variant) => { const variantAttrValue = this.getDefaultVariantAttributeValue(attribute, variant.attributes);
if (!this.isVariantAvailable(variant)) return false;
const variantAttrValue = this.getDefaultVariantAttributeValue(attribute, variant.values);
if (!variantAttrValue || this.normalizeText(variantAttrValue) !== optionNormalized) { if (!variantAttrValue || this.normalizeText(variantAttrValue) !== optionNormalized) {
return false; return false;
} }
for (const otherAttr of attributes) { for (const otherAttr of attributes) {
if (otherAttr.id === attribute.id) continue; if (otherAttr.id === attribute.id) continue;
const selectedOptionId = selections[otherAttr.id]; const selectedOptionId = selections[otherAttr.id];
if (selectedOptionId !== undefined) { if (selectedOptionId !== undefined) {
const selectedOption = otherAttr.options.find((o) => o.id === selectedOptionId); const selectedOption = otherAttr.options.find(o => o.id === selectedOptionId);
if (selectedOption) { if (selectedOption) {
const selectedNormalized = this.normalizeText( const selectedNormalized = this.normalizeText(selectedOption.value || selectedOption.label);
selectedOption.value || selectedOption.label, const vAttrValue = this.getDefaultVariantAttributeValue(otherAttr, variant.attributes);
); if (!vAttrValue || this.normalizeText(vAttrValue) !== selectedNormalized) {
const vAttrValue = this.getDefaultVariantAttributeValue(otherAttr, variant.values); return false;
if (!vAttrValue || this.normalizeText(vAttrValue) !== selectedNormalized) { }
return false;
} }
} }
}
} }
return true; return true;
}); });
@@ -81,14 +78,14 @@ export class ProductAttributeSelectorComponent {
}); });
public reset(): void { public reset(): void {
this.initializeSelections(this.attributes(), this.selectedVariant()); this.initializeSelections(this.attributes(), this.defaultVariant());
} }
constructor() { constructor() {
effect(() => { effect(() => {
const attributes = this.attributes(); const attributes = this.attributes();
const defaultVariant = this.selectedVariant(); const defaultVariant = this.defaultVariant();
untracked(() => { untracked(() => {
this.initializeSelections(attributes, defaultVariant); this.initializeSelections(attributes, defaultVariant);
}); });
@@ -96,29 +93,26 @@ export class ProductAttributeSelectorComponent {
effect(() => { effect(() => {
const selections = this.selectedAttributeOptions(); const selections = this.selectedAttributeOptions();
const variants = this.variants(); const variantsMap = this.variantsMap();
const attributes = this.attributes(); const attributes = this.attributes();
untracked(() => { untracked(() => {
this.emitMatchingVariant(selections, variants, attributes); this.emitMatchingVariant(selections, variantsMap, attributes);
}); });
}); });
} }
protected hasSelectedOption( protected hasSelectedOption(attribute: ProductAttribute, option: ProductAttributeOption): boolean {
attribute: ProductAttribute,
option: ProductAttributeOption,
): boolean {
return this.selectedAttributeOptions()[attribute.id] === option.id; return this.selectedAttributeOptions()[attribute.id] === option.id;
} }
protected selectAttributeOption( protected selectAttributeOption(
attribute: ProductAttribute, attribute: ProductAttribute,
option: ProductAttributeOption, option: ProductAttributeOption
): void { ): void {
this.selectedAttributeOptions.update((current) => ({ this.selectedAttributeOptions.update((current) => ({
...current, ...current,
[attribute.id]: option.id, [attribute.id]: option.id
})); }));
} }
@@ -130,10 +124,7 @@ export class ProductAttributeSelectorComponent {
return this.findFirstHexValue(option.metadata) ?? '#D9D9D9'; return this.findFirstHexValue(option.metadata) ?? '#D9D9D9';
} }
private initializeSelections( private initializeSelections(attributes: ProductAttribute[], defaultVariant: ProductVariant | null): void {
attributes: ProductAttribute[],
defaultVariant: CatalogItemVariant | null,
): void {
const selections: Record<number, number> = {}; const selections: Record<number, number> = {};
for (const attribute of attributes) { for (const attribute of attributes) {
@@ -148,20 +139,20 @@ export class ProductAttributeSelectorComponent {
private findDefaultOptionId( private findDefaultOptionId(
attribute: ProductAttribute, attribute: ProductAttribute,
variant: CatalogItemVariant | null, variant: ProductVariant | null
): number | null { ): number | null {
if (!variant) { if (!variant) {
return null; return null;
} }
const defaultValue = this.getDefaultVariantAttributeValue(attribute, variant.values); const defaultValue = this.getDefaultVariantAttributeValue(attribute, variant.definitions);
if (!defaultValue) { if (!defaultValue) {
return null; return null;
} }
const normalizedValue = this.normalizeText(defaultValue); const normalizedValue = this.normalizeText(defaultValue);
const matchByValue = attribute.options.find( const matchByValue = attribute.options.find(
(option) => this.normalizeText(option.value) === normalizedValue, (option) => this.normalizeText(option.value) === normalizedValue
); );
if (matchByValue) { if (matchByValue) {
@@ -169,7 +160,7 @@ export class ProductAttributeSelectorComponent {
} }
const matchByLabel = attribute.options.find( const matchByLabel = attribute.options.find(
(option) => this.normalizeText(option.label) === normalizedValue, (option) => this.normalizeText(option.label) === normalizedValue
); );
return matchByLabel?.id ?? null; return matchByLabel?.id ?? null;
@@ -177,7 +168,7 @@ export class ProductAttributeSelectorComponent {
private getDefaultVariantAttributeValue( private getDefaultVariantAttributeValue(
attribute: ProductAttribute, attribute: ProductAttribute,
variantAttributes: Record<string, string>, variantAttributes: Record<string, string>
): string | null { ): string | null {
const normalizedCodigo = this.normalizeText(attribute.codigo); const normalizedCodigo = this.normalizeText(attribute.codigo);
const normalizedNombre = this.normalizeText(attribute.nombre); const normalizedNombre = this.normalizeText(attribute.nombre);
@@ -201,10 +192,6 @@ export class ProductAttributeSelectorComponent {
.toLowerCase(); .toLowerCase();
} }
private isVariantAvailable(variant: CatalogItemVariant): boolean {
return this.inventoryPolicy() === 'unlimited' || (variant.stock_tecnico ?? 0) > 0;
}
private findFirstHexValue(value: unknown): string | null { private findFirstHexValue(value: unknown): string | null {
if (typeof value === 'string') { if (typeof value === 'string') {
const match = value.match(/#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})\b/); const match = value.match(/#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})\b/);
@@ -235,10 +222,10 @@ export class ProductAttributeSelectorComponent {
private emitMatchingVariant( private emitMatchingVariant(
selections: Record<number, number>, selections: Record<number, number>,
variants: CatalogItemVariant[], variantsMap: ProductVariantMap[],
attributes: ProductAttribute[], attributes: ProductAttribute[]
): void { ): void {
if (attributes.length === 0 || variants.length === 0) { if (attributes.length === 0 || variantsMap.length === 0) {
this.variantChange.emit(null); this.variantChange.emit(null);
return; return;
} }
@@ -248,12 +235,12 @@ export class ProductAttributeSelectorComponent {
for (const attr of attributes) { for (const attr of attributes) {
const selectedOptionId = selections[attr.id]; const selectedOptionId = selections[attr.id];
if (selectedOptionId === undefined) { if (selectedOptionId === undefined) {
allSelected = false; allSelected = false;
break; break;
} }
const option = attr.options.find((o) => o.id === selectedOptionId); const option = attr.options.find(o => o.id === selectedOptionId);
if (option) { if (option) {
selectedValuesById[attr.id] = this.normalizeText(option.value || option.label); selectedValuesById[attr.id] = this.normalizeText(option.value || option.label);
} }
} }
@@ -262,12 +249,12 @@ export class ProductAttributeSelectorComponent {
return; return;
} }
const matchingVariant = variants.find((variant) => { const matchingVariant = variantsMap.find(vMap => {
return attributes.every((attr) => { return attributes.every(attr => {
const selectedValue = selectedValuesById[attr.id]; const selectedValue = selectedValuesById[attr.id];
const variantValue = this.getDefaultVariantAttributeValue(attr, variant.values); const vMapValue = this.getDefaultVariantAttributeValue(attr, vMap.attributes);
if (!variantValue) return false; if (!vMapValue) return false;
return this.normalizeText(variantValue) === selectedValue; return this.normalizeText(vMapValue) === selectedValue;
}); });
}); });

View File

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

View File

@@ -13,10 +13,6 @@
gap: 0.75rem; gap: 0.75rem;
background-color: transparent; background-color: transparent;
position: relative; position: relative;
&--without-image {
grid-template-columns: minmax(0, 1fr);
}
} }
.cart-item-media { .cart-item-media {
@@ -48,6 +44,12 @@
line-height: 1; line-height: 1;
} }
.cart-item-placeholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #e0e0e0, #f5f5f5);
}
.cart-item-content { .cart-item-content {
gap: 0.5rem; gap: 0.5rem;
} }
@@ -114,16 +116,3 @@
display: grid; display: grid;
justify-items: end; 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

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

View File

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

View File

@@ -1,49 +0,0 @@
<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

@@ -1,132 +0,0 @@
: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

@@ -1,64 +0,0 @@
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

@@ -1,24 +0,0 @@
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

@@ -1,33 +0,0 @@
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

@@ -1,65 +0,0 @@
<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

@@ -1,96 +0,0 @@
.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

@@ -1,118 +0,0 @@
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

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

View File

@@ -1,200 +0,0 @@
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

@@ -1,15 +1,4 @@
import { import { ChangeDetectionStrategy, Component, computed, effect, inject, OnInit, signal, untracked, ViewChild } from '@angular/core';
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
OnDestroy,
OnInit,
signal,
untracked,
ViewChild,
} from '@angular/core';
import { FormBuilder, Validators } from '@angular/forms'; import { FormBuilder, Validators } from '@angular/forms';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { startWith } from 'rxjs'; import { startWith } from 'rxjs';
@@ -18,6 +7,7 @@ import { CartService } from '../../../../core/services/cart/cart.service';
import { TenantService } from '../../../../core/services/tenant.service'; import { TenantService } from '../../../../core/services/tenant.service';
import { CheckoutService, CreatePurchasePayload } from '../../../../core/services/checkout.service'; import { CheckoutService, CreatePurchasePayload } from '../../../../core/services/checkout.service';
import { AuthService } from '../../../../core/services/auth/auth.service'; import { AuthService } from '../../../../core/services/auth/auth.service';
import { ToastService } from '../../../../core/services/toast.service';
import { BankAccount } from '../../../../core/services/tenant.interface'; import { BankAccount } from '../../../../core/services/tenant.interface';
import { CartItem } from '../../../../core/services/cart/cart.interface'; import { CartItem } from '../../../../core/services/cart/cart.interface';
import { CartComponent, CartItemMock } from '../../../../shared/components/cart/cart.component'; import { CartComponent, CartItemMock } from '../../../../shared/components/cart/cart.component';
@@ -25,15 +15,7 @@ import { StepComponent } from '../../../../shared/components/stepper/step.compon
import { StepperComponent } from '../../../../shared/components/stepper/stepper.component'; import { StepperComponent } from '../../../../shared/components/stepper/stepper.component';
import { CheckoutDataStepComponent } from './checkout-data-step.component'; import { CheckoutDataStepComponent } from './checkout-data-step.component';
import { CheckoutPaymentStepComponent } from './checkout-payment-step.component'; import { CheckoutPaymentStepComponent } from './checkout-payment-step.component';
import { import { CheckoutForm, PaymentMethod, PaymentMethodOption, TransferAccount, TransferField } from './checkout-page.models';
CheckoutForm,
PaymentMethod,
PaymentMethodOption,
QrPaymentStatus,
TransferAccount,
TransferField,
TransferValidationStatus,
} from './checkout-page.models';
@Component({ @Component({
selector: 'app-checkout-page', selector: 'app-checkout-page',
@@ -43,27 +25,20 @@ import {
StepperComponent, StepperComponent,
StepComponent, StepComponent,
CheckoutDataStepComponent, CheckoutDataStepComponent,
CheckoutPaymentStepComponent, CheckoutPaymentStepComponent
], ],
templateUrl: './checkout-page.component.html', templateUrl: './checkout-page.component.html',
styleUrl: './checkout-page.component.scss', styleUrl: './checkout-page.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class CheckoutPageComponent implements OnInit, OnDestroy { export class CheckoutPageComponent implements OnInit {
private readonly formBuilder = inject(FormBuilder); private readonly formBuilder = inject(FormBuilder);
private readonly cartService = inject(CartService); private readonly cartService = inject(CartService);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly tenantService = inject(TenantService); private readonly tenantService = inject(TenantService);
private readonly checkoutService = inject(CheckoutService); private readonly checkoutService = inject(CheckoutService);
private readonly authService = inject(AuthService); private readonly authService = inject(AuthService);
private readonly qrPollingIntervalMs = 5_000; private readonly toastService = inject(ToastService);
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; @ViewChild(StepperComponent) stepper!: StepperComponent;
@@ -71,7 +46,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
nombre: ['', [Validators.required]], nombre: ['', [Validators.required]],
email: ['', [Validators.required, Validators.email]], email: ['', [Validators.required, Validators.email]],
dni: ['', [Validators.required]], dni: ['', [Validators.required]],
telefono: ['', [Validators.required]], telefono: ['', [Validators.required]]
}); });
protected readonly cartSubtotal = computed(() => { protected readonly cartSubtotal = computed(() => {
@@ -93,33 +68,32 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
protected readonly paymentMethods: ReadonlyArray<PaymentMethodOption> = [ protected readonly paymentMethods: ReadonlyArray<PaymentMethodOption> = [
{ id: 'qr', label: 'QR' }, { id: 'qr', label: 'QR' },
{ id: 'transfer', label: 'Transferencia' }, { id: 'transfer', label: 'Transferencia' },
{ id: 'telepagos', label: 'TelePagos' }, { id: 'telepagos', label: 'TelePagos' }
]; ];
protected readonly selectedPaymentMethod = signal<PaymentMethod>('qr'); protected readonly selectedPaymentMethod = signal<PaymentMethod>('qr');
protected readonly copiedTransferField = signal<TransferField | null>(null); protected readonly copiedTransferField = signal<TransferField | null>(null);
protected readonly transferAccount = signal<TransferAccount | null>(null); protected readonly transferAccount = signal<TransferAccount>({
protected readonly transferDni = signal<string>(''); titular: 'Nombre y Apellido',
entidad: 'TelePagos',
cvu: '0000000000000000000000',
alias: 'telepagos.ar'
});
protected readonly isCreatingPurchase = signal(false); protected readonly isCreatingPurchase = signal(false);
protected readonly createdPurchaseId = signal<number | null>(null); protected readonly createdPurchaseId = signal<number | null>(null);
protected readonly isGeneratingIntent = signal(false); protected readonly isGeneratingIntent = signal(false);
protected readonly isPaymentLoading = computed( protected readonly isPaymentLoading = computed(() => this.isGeneratingIntent() || this.cartService.isUpdating());
() => this.isGeneratingIntent() || this.cartService.isUpdating(),
);
protected readonly qrData = signal<string | null>(null); 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() { constructor() {
effect(() => { effect(() => {
// We only want to trigger the intent generation when the cart changes. // We only want to trigger the intent generation when the cart changes.
// So we track the cart, but untrack the other signals to prevent duplicate calls. // So we track the cart, but untrack the other signals to prevent duplicate calls.
const cart = this.cartService.cart(); const cart = this.cartService.cart();
untracked(() => { untracked(() => {
const purchaseId = this.createdPurchaseId(); const purchaseId = this.createdPurchaseId();
if (cart && purchaseId && !this.navigationStarted) { if (cart && purchaseId) {
// Trigger payment intent generation when cart changes and we are on the payment step // Trigger payment intent generation when cart changes and we are on the payment step
void this.selectPaymentMethod(this.selectedPaymentMethod()); void this.selectPaymentMethod(this.selectedPaymentMethod());
} }
@@ -136,7 +110,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
nombre: user.nombre_apellido, nombre: user.nombre_apellido,
email: user.email, email: user.email,
dni: user.dni ?? '', dni: user.dni ?? '',
telefono: user.telefono ?? '', telefono: user.telefono ?? ''
}); });
this.form.controls.nombre.disable(); this.form.controls.nombre.disable();
this.form.controls.email.disable(); this.form.controls.email.disable();
@@ -147,10 +121,6 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
this.cartService.loadCart().subscribe(); this.cartService.loadCart().subscribe();
} }
ngOnDestroy(): void {
this.stopQrPolling();
}
private mapCartItemToMock(item: CartItem): CartItemMock { private mapCartItemToMock(item: CartItem): CartItemMock {
const fullName = item.product?.nombre ?? ''; const fullName = item.product?.nombre ?? '';
let product = fullName; let product = fullName;
@@ -170,14 +140,14 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
} }
return { return {
cartItemId: item.id, productVariantId: item.product_variant_id,
imageUrl: item.product?.imagen ?? null, imageUrl: item.product?.imagen ?? null,
product, product,
originalPrice: null, originalPrice: null,
discountedPrice: parseFloat(item.precio_unitario), discountedPrice: parseFloat(item.precio_unitario),
discountPercentage: null, discountPercentage: null,
attributes, attributes,
quantity: item.cantidad, quantity: item.cantidad
}; };
} }
@@ -206,11 +176,12 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
const response = await this.checkoutService.createPurchase(tenant.codigo, payload); const response = await this.checkoutService.createPurchase(tenant.codigo, payload);
this.createdPurchaseId.set(response.id); this.createdPurchaseId.set(response.id);
this.stepper.next(); this.stepper.next();
// Auto trigger intent for default option // Auto trigger intent for default option
void this.selectPaymentMethod(this.selectedPaymentMethod()); void this.selectPaymentMethod(this.selectedPaymentMethod());
} catch (error) { } catch (error) {
console.error('Failed to create purchase:', error); console.error('Failed to create purchase:', error);
// Here we could show an alert or toast // Here we could show an alert or toast
@@ -220,24 +191,11 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
} }
protected onCancel(): void { protected onCancel(): void {
this.stopQrPolling();
void this.router.navigate(['/']); void this.router.navigate(['/']);
} }
protected async selectPaymentMethod(method: PaymentMethod): Promise<void> { 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); this.selectedPaymentMethod.set(method);
const requestId = ++this.paymentMethodRequestId;
if (method === 'qr') {
this.qrData.set(null);
}
const purchaseId = this.createdPurchaseId(); const purchaseId = this.createdPurchaseId();
const tenant = this.tenantService.tenant(); const tenant = this.tenantService.tenant();
@@ -246,64 +204,30 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
return; return;
} }
if (method === 'transfer') {
this.transferAccount.set(null);
return;
}
this.isGeneratingIntent.set(true); this.isGeneratingIntent.set(true);
try { try {
const response = await this.checkoutService.generatePaymentIntent( const response = await this.checkoutService.generatePaymentIntent(tenant.codigo, purchaseId, method);
tenant.codigo,
purchaseId,
method,
);
if (method === 'qr' && response.qr_data?.qr_code) { 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.qrData.set(response.qr_data.qr_code);
this.startQrPolling(); } else if (method === 'transfer' && response.transfer_data) {
}
} catch (error) {
console.error('Failed to generate payment intent:', error);
} finally {
this.isGeneratingIntent.set(false);
}
}
protected async generateTransferIntent(dni: string): Promise<void> {
const purchaseId = this.createdPurchaseId();
const tenant = this.tenantService.tenant();
if (!purchaseId || !tenant) {
return;
}
this.transferDni.set(dni);
this.transferValidationStatus.set('idle');
this.isGeneratingIntent.set(true);
try {
const response = await this.checkoutService.generatePaymentIntent(
tenant.codigo,
purchaseId,
'transfer',
dni
);
if (response.transfer_data) {
this.transferAccount.set({ this.transferAccount.set({
titular: response.transfer_data.titular, titular: response.transfer_data.titular,
entidad: response.transfer_data.entidad, entidad: response.transfer_data.entidad,
cvu: response.transfer_data.cvu, cvu: response.transfer_data.cvu,
alias: response.transfer_data.alias, alias: response.transfer_data.alias
}); });
} }
} catch (error) { } catch (error) {
console.error('Failed to generate transfer payment intent:', error); console.error('Failed to generate payment intent:', error);
const backendMessage = (error as { error?: { message?: unknown } } | null)?.error?.message;
const message = typeof backendMessage === 'string'
? backendMessage
: error instanceof Error
? error.message
: 'No pudimos obtener la información de pago. Intentá nuevamente.';
this.toastService.danger(message);
} finally { } finally {
this.isGeneratingIntent.set(false); this.isGeneratingIntent.set(false);
} }
@@ -330,140 +254,16 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
const purchaseId = this.createdPurchaseId(); const purchaseId = this.createdPurchaseId();
const tenant = this.tenantService.tenant(); const tenant = this.tenantService.tenant();
if ( if (!purchaseId || !tenant) {
!purchaseId ||
!tenant ||
this.navigationStarted ||
this.transferValidationStatus() === 'checking'
) {
return; return;
} }
this.transferValidationStatus.set('checking');
try { try {
const purchase = await this.checkoutService.getPurchase(tenant.codigo, purchaseId); await this.checkoutService.completePurchase(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 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(); this.cartService.clearCart();
void this.router.navigate(['/checkout/status', purchaseId]);
} catch (error) {
console.error('Failed to complete purchase:', error);
} }
void this.router.navigate(['/checkout/status', purchaseId]);
} }
} }

View File

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

View File

@@ -40,26 +40,114 @@
<h3 class="payment-panel__title">Cargando información de pago...</h3> <h3 class="payment-panel__title">Cargando información de pago...</h3>
</div> </div>
} @else if (selectedPaymentMethod() === 'qr') { } @else if (selectedPaymentMethod() === 'qr') {
<app-checkout-payment-qr <div class="payment-panel__card">
[qrData]="qrData()" <h3 class="payment-panel__title">Ingresa a tu billetera y escanea el siguiente QR</h3>
[paymentStatus]="qrPaymentStatus()" <div class="payment-panel__divider"></div>
[isCheckingPayment]="isCheckingQrPayment()"
(retryPolling)="retryQrPolling.emit()" <div class="qr-code" aria-label="QR de pago">
/> @if (qrData()) {
<qrcode [qrdata]="qrData()!" [width]="200" [errorCorrectionLevel]="'M'"></qrcode>
} @else {
<span class="qr-code__finder qr-code__finder--tl"></span>
<span class="qr-code__finder qr-code__finder--tr"></span>
<span class="qr-code__finder qr-code__finder--bl"></span>
}
</div>
</div>
} @else if (selectedPaymentMethod() === 'transfer') { } @else if (selectedPaymentMethod() === 'transfer') {
<app-checkout-payment-transfer <div class="payment-panel__card">
[transferAccount]="transferAccount()" <h3 class="payment-panel__title">Transferi a la siguiente cuenta desde cualquier billetera</h3>
[transferDni]="transferDni()" <div class="payment-panel__divider"></div>
[copiedTransferField]="copiedTransferField()"
[validationStatus]="transferValidationStatus()" <div class="payment-panel__account">
(copyTransferValue)="requestCopy($event.field, $event.value)" <p class="payment-panel__eyebrow">DATOS DE CUENTA:</p>
(submitDni)="generateTransferIntent.emit($event)"
(completePurchase)="complete.emit()" <div class="payment-detail">
/> <span class="payment-detail__label">Titular:</span>
<strong class="payment-detail__value">{{ transferAccount().titular }}</strong>
</div>
<div class="payment-detail">
<span class="payment-detail__label">Entidad:</span>
<strong class="payment-detail__value">{{ transferAccount().entidad }}</strong>
</div>
<div class="payment-detail payment-detail--copy">
<span class="payment-detail__label">CVU:</span>
<strong class="payment-detail__value">{{ transferAccount().cvu }}</strong>
<app-icon-button
variant="copy"
ariaLabel="Copiar CVU"
(click)="requestCopy('cvu', transferAccount().cvu)"
/>
</div>
@if (copiedTransferField() === 'cvu') {
<small class="payment-detail__feedback">CVU copiado</small>
}
<div class="payment-detail payment-detail--copy">
<span class="payment-detail__label">Alias:</span>
<strong class="payment-detail__value">{{ transferAccount().alias }}</strong>
<app-icon-button
variant="copy"
ariaLabel="Copiar alias"
(click)="requestCopy('alias', transferAccount().alias)"
/>
</div>
@if (copiedTransferField() === 'alias') {
<small class="payment-detail__feedback">Alias copiado</small>
}
</div>
</div>
} @else { } @else {
<app-checkout-payment-telepagos /> <div class="payment-panel__card">
<h3 class="payment-panel__title">Descarga la app de TelePagos para finalizar la compra</h3>
<div class="payment-panel__divider"></div>
<div class="store-badges" aria-label="Tiendas disponibles">
<div class="store-badge">
<i class="fa-brands fa-google-play" aria-hidden="true"></i>
<span class="store-badge__text">
<small>Disponible en</small>
<strong>Google Play</strong>
</span>
</div>
<div class="store-badge">
<i class="fa-brands fa-apple" aria-hidden="true"></i>
<span class="store-badge__text">
<small>Descargalo en</small>
<strong>App Store</strong>
</span>
</div>
</div>
</div>
} }
</section> </section>
</div> </div>
<div class="checkout-payment__actions">
<app-button
type="button"
variant="cancel"
hostClass="checkout-payment__action-btn"
buttonClass="w-100"
[disabled]="true"
>
Cancelar
</app-button>
<app-button
type="button"
hostClass="checkout-payment__action-btn"
buttonClass="w-100"
[disabled]="isGeneratingIntent()"
(click)="complete.emit()"
>
Finalizar compra
</app-button>
</div>
</div> </div>

View File

@@ -23,8 +23,8 @@
&__action-btn { &__action-btn {
width: 100%; width: 100%;
flex: 1 1 320px; flex: 1 1 290px;
max-width: 320px; max-width: 290px;
display: block; display: block;
} }
} }
@@ -112,7 +112,7 @@
min-width: 0; min-width: 0;
&__card { &__card {
width: 100%; width: 100%;
max-width: 320px; max-width: 290px;
min-height: 269px; min-height: 269px;
padding: 1.5rem 1.75rem; padding: 1.5rem 1.75rem;
border-radius: 5px; border-radius: 5px;
@@ -134,5 +134,173 @@
line-height: 1.35; line-height: 1.35;
} }
&__divider {
width: 100%;
max-width: 220px;
height: 1px;
margin: 1.2rem 0 1.45rem;
background: #dddddd;
}
&__account {
width: 100%;
display: grid;
gap: 0.75rem;
color: #7a7a7a;
font-size: 12px;
}
&__eyebrow {
margin: 0;
color: #838383;
font-size: 12px;
font-weight: 325;
letter-spacing: 0.06em;
}
}
.payment-detail {
display: grid;
grid-template-columns: auto auto;
justify-content: center;
gap: 0.35rem;
align-items: center;
font-size: 12px;
&--copy {
grid-template-columns: auto auto auto;
}
&__label {
color: #8a8a8a;
font-weight: 325;
}
&__value {
color: #5e5e5e;
font-weight: 500;
}
&__feedback {
display: block;
margin-top: -0.25rem;
color: var(--tenant-primary, #6376f3);
font-size: 0.78rem;
font-weight: 700;
}
}
.qr-code {
position: relative;
width: min(170px, 100%);
aspect-ratio: 1;
border: 8px solid #ffffff;
background-color: #ffffff;
background-image:
linear-gradient(
90deg,
rgba(17, 17, 17, 0.95) 0 14%,
transparent 14% 28%,
rgba(17, 17, 17, 0.95) 28% 42%,
transparent 42% 56%,
rgba(17, 17, 17, 0.95) 56% 70%,
transparent 70% 84%,
rgba(17, 17, 17, 0.95) 84% 100%
),
linear-gradient(
rgba(17, 17, 17, 0.95) 0 14%,
transparent 14% 28%,
rgba(17, 17, 17, 0.95) 28% 42%,
transparent 42% 56%,
rgba(17, 17, 17, 0.95) 56% 70%,
transparent 70% 84%,
rgba(17, 17, 17, 0.95) 84% 100%
);
background-size: 18px 18px;
background-position: 0 0, 9px 9px;
display: flex;
justify-content: center;
align-items: center;
qrcode {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
::ng-deep canvas,
::ng-deep img {
display: block !important;
width: 100% !important;
height: 100% !important;
max-width: 100%;
max-height: 100%;
}
}
}
.qr-code__finder {
position: absolute;
width: 38px;
height: 38px;
border: 5px solid #111111;
background: #ffffff;
box-shadow: inset 0 0 0 8px #ffffff, inset 0 0 0 14px #111111;
&--tl {
top: 10px;
left: 10px;
}
&--tr {
top: 10px;
right: 10px;
}
&--bl {
bottom: 10px;
left: 10px;
}
}
.store-badges {
width: 100%;
max-width: 210px;
display: grid;
gap: 0.8rem;
}
.store-badge {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 0.95rem;
border-radius: 0.85rem;
background: #111111;
color: #ffffff;
box-shadow: 0 10px 24px rgba(17, 17, 17, 0.16);
i {
font-size: 1.35rem;
}
&__text {
display: grid;
text-align: left;
line-height: 1.1;
small {
font-size: 0.62rem;
letter-spacing: 0.05em;
text-transform: uppercase;
opacity: 0.8;
}
strong {
font-size: 1rem;
font-weight: 700;
}
}
} }

View File

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

View File

@@ -1,34 +0,0 @@
<div class="payment-panel__card">
<h3 class="payment-panel__title">Ingresa a tu billetera y escanea el siguiente QR</h3>
<div class="payment-panel__divider"></div>
<div class="qr-code" aria-label="QR de pago">
@if (qrData()) {
<qrcode [qrdata]="qrData()!" [width]="200" [errorCorrectionLevel]="'M'"></qrcode>
} @else {
<span class="qr-code__finder qr-code__finder--tl"></span>
<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

@@ -1,150 +0,0 @@
.payment-panel__card {
width: 100%;
max-width: 320px;
min-height: 269px;
padding: 1.5rem 1.75rem;
border-radius: 5px;
background: #ffffff;
color: #666666;
box-shadow: 0 0 0 1px #f1f1f1;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.payment-panel__title {
max-width: 18rem;
margin: 0;
color: #8a8a8a;
font-size: 13px;
font-weight: 700;
line-height: 1.35;
}
.payment-panel__divider {
width: 100%;
max-width: 220px;
height: 1px;
margin: 1.2rem 0 1.45rem;
background: #dddddd;
}
.qr-code {
position: relative;
width: min(170px, 100%);
aspect-ratio: 1;
border: 8px solid #ffffff;
background-color: #ffffff;
background-image:
linear-gradient(
90deg,
rgba(17, 17, 17, 0.95) 0 14%,
transparent 14% 28%,
rgba(17, 17, 17, 0.95) 28% 42%,
transparent 42% 56%,
rgba(17, 17, 17, 0.95) 56% 70%,
transparent 70% 84%,
rgba(17, 17, 17, 0.95) 84% 100%
),
linear-gradient(
rgba(17, 17, 17, 0.95) 0 14%,
transparent 14% 28%,
rgba(17, 17, 17, 0.95) 28% 42%,
transparent 42% 56%,
rgba(17, 17, 17, 0.95) 56% 70%,
transparent 70% 84%,
rgba(17, 17, 17, 0.95) 84% 100%
);
background-size: 18px 18px;
background-position: 0 0, 9px 9px;
display: flex;
justify-content: center;
align-items: center;
qrcode {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
::ng-deep canvas,
::ng-deep img {
display: block !important;
width: 100% !important;
height: 100% !important;
max-width: 100%;
max-height: 100%;
}
}
}
.qr-code__finder {
position: absolute;
width: 38px;
height: 38px;
border: 5px solid #111111;
background: #ffffff;
box-shadow: inset 0 0 0 8px #ffffff, inset 0 0 0 14px #111111;
&--tl {
top: 10px;
left: 10px;
}
&--tr {
top: 10px;
right: 10px;
}
&--bl {
bottom: 10px;
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

@@ -1,30 +0,0 @@
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,19 +0,0 @@
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, 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

@@ -1,22 +0,0 @@
<div class="payment-panel__card">
<h3 class="payment-panel__title">Descarga la app de TelePagos para finalizar la compra</h3>
<div class="payment-panel__divider"></div>
<div class="store-badges" aria-label="Tiendas disponibles">
<div class="store-badge">
<i class="fa-brands fa-google-play" aria-hidden="true"></i>
<span class="store-badge__text">
<small>Disponible en</small>
<strong>Google Play</strong>
</span>
</div>
<div class="store-badge">
<i class="fa-brands fa-apple" aria-hidden="true"></i>
<span class="store-badge__text">
<small>Descargalo en</small>
<strong>App Store</strong>
</span>
</div>
</div>
</div>

View File

@@ -1,71 +0,0 @@
.payment-panel__card {
width: 100%;
max-width: 320px;
min-height: 269px;
padding: 1.5rem 1.75rem;
border-radius: 5px;
background: #ffffff;
color: #666666;
box-shadow: 0 0 0 1px #f1f1f1;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.payment-panel__title {
max-width: 18rem;
margin: 0;
color: #8a8a8a;
font-size: 13px;
font-weight: 700;
line-height: 1.35;
}
.payment-panel__divider {
width: 100%;
max-width: 220px;
height: 1px;
margin: 1.2rem 0 1.45rem;
background: #dddddd;
}
.store-badges {
width: 100%;
max-width: 210px;
display: grid;
gap: 0.8rem;
}
.store-badge {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 0.95rem;
border-radius: 0.85rem;
background: #111111;
color: #ffffff;
box-shadow: 0 10px 24px rgba(17, 17, 17, 0.16);
i {
font-size: 1.35rem;
}
&__text {
display: grid;
text-align: left;
line-height: 1.1;
small {
font-size: 0.62rem;
letter-spacing: 0.05em;
text-transform: uppercase;
opacity: 0.8;
}
strong {
font-size: 1rem;
font-weight: 700;
}
}
}

View File

@@ -1,11 +0,0 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
@Component({
selector: 'app-checkout-payment-telepagos',
standalone: true,
imports: [],
templateUrl: './checkout-payment-telepagos.component.html',
styleUrl: './checkout-payment-telepagos.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CheckoutPaymentTelepagosComponent {}

View File

@@ -1,105 +0,0 @@
<div class="payment-panel__card">
<div class="dni-form-container">
<h3 class="dni-form-title">Ingresá DNI de quién va a transferir</h3>
<form class="dni-form row gx-2" (submit)="$event.preventDefault(); onSubmitDni()">
<div class="col-8">
<app-input
type="text"
[value]="dniControl.value"
(valueChange)="dniControl.setValue($event.toString()); dniControl.markAsDirty()"
[disabled]="isDniDisabled()"
[invalid]="dniControl.invalid && (dniControl.dirty || dniControl.touched)"
/>
</div>
<div class="col-4">
@if (isDniDisabled()) {
<app-button
type="button"
variant="secondary"
hostClass="w-100 h-100"
buttonClass="w-100 h-100 px-0"
(click)="enableEditing()"
>
Modificar
</app-button>
} @else {
<app-button
type="submit"
variant="primary"
hostClass="w-100 h-100"
buttonClass="w-100 h-100 px-0"
[disabled]="dniControl.invalid"
>
Continuar
</app-button>
}
</div>
</form>
</div>
@if (transferAccount() && !isEditing()) {
<div class="payment-panel__divider"></div>
<div class="payment-panel__account">
<p class="payment-panel__eyebrow">DATOS DE CUENTA:</p>
<div class="payment-detail">
<span class="payment-detail__label">Titular:</span>
<span class="payment-detail__value">{{ transferAccount()?.titular }}</span>
</div>
<div class="payment-detail">
<span class="payment-detail__label">Entidad:</span>
<span class="payment-detail__value">{{ transferAccount()?.entidad }}</span>
</div>
<div class="payment-detail payment-detail--copy">
<span class="payment-detail__label">CVU:</span>
<span class="payment-detail__value">{{ transferAccount()?.cvu }}</span>
<app-icon-button
variant="copy"
ariaLabel="Copiar CVU"
(click)="requestCopy('cvu', transferAccount()!.cvu)"
/>
@if (copiedTransferField() === 'cvu') {
<span class="payment-detail__feedback" style="grid-column: 1 / -1; text-align: center;">¡Copiado!</span>
}
</div>
<div class="payment-detail payment-detail--copy">
<span class="payment-detail__label">Alias:</span>
<span class="payment-detail__value">{{ transferAccount()?.alias }}</span>
<app-icon-button
variant="copy"
ariaLabel="Copiar Alias"
(click)="requestCopy('alias', transferAccount()!.alias)"
/>
@if (copiedTransferField() === 'alias') {
<span class="payment-detail__feedback" style="grid-column: 1 / -1; text-align: center;">¡Copiado!</span>
}
</div>
</div>
<div class="mt-4">
<app-button
type="button"
variant="primary"
hostClass="w-100"
buttonClass="w-100"
[disabled]="validationStatus() === 'checking'"
(click)="completePurchase.emit()"
>
{{ 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,131 +0,0 @@
.payment-panel__card {
width: 100%;
max-width: 320px;
min-height: 400px;
padding: 1.5rem 1.75rem;
border-radius: 5px;
background: #ffffff;
color: #666666;
box-shadow: 0 0 0 1px #f1f1f1;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.payment-panel__title {
max-width: 18rem;
margin: 0;
color: #8a8a8a;
font-size: 13px;
font-weight: 700;
line-height: 1.35;
}
.payment-panel__divider {
width: 100%;
max-width: 220px;
height: 1px;
margin: 1.2rem 0 1.45rem;
background: #dddddd;
}
.payment-panel__account {
width: 100%;
display: grid;
gap: 0.75rem;
color: #7a7a7a;
font-size: 12px;
}
.payment-panel__eyebrow {
margin: 0;
color: #838383;
font-size: 12px;
font-weight: 325;
letter-spacing: 0.06em;
}
.payment-detail {
display: grid;
grid-template-columns: auto auto;
justify-content: center;
gap: 0.35rem;
align-items: center;
font-size: 12px;
&--copy {
grid-template-columns: auto auto auto;
}
&__label {
color: #8a8a8a;
font-weight: 325;
}
&__value {
color: #5e5e5e;
font-weight: 500;
}
&__feedback {
display: block;
margin-top: -0.25rem;
color: var(--tenant-primary, #6376f3);
font-size: 0.78rem;
font-weight: 700;
}
}
.dni-form-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
flex: 1;
width: 100%;
padding: 1rem 0;
}
.dni-form-title {
color: #8a8a8a;
font-size: 13px;
font-weight: 700;
margin: 0 0 1.25rem 0;
text-align: center;
}
.dni-form {
width: 100%;
margin: 0;
app-input {
display: block;
height: 100%;
margin-bottom: 0;
::ng-deep .input-control,
::ng-deep input.form-control {
height: 100%;
}
}
::ng-deep .btn {
min-width: 0 !important;
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,55 +0,0 @@
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, TransferValidationStatus } from '../../checkout-page.models';
@Component({
selector: 'app-checkout-payment-transfer',
standalone: true,
imports: [IconButtonComponent, ButtonComponent, InputComponent, ReactiveFormsModule],
templateUrl: './checkout-payment-transfer.component.html',
styleUrl: './checkout-payment-transfer.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
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>();
readonly completePurchase = output<void>();
protected readonly dniControl = new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.pattern(/^\d{7,8}$/)]
});
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 });
}
protected onSubmitDni(): void {
if (this.dniControl.valid) {
this.isEditing.set(false);
this.submitDni.emit(this.dniControl.value);
}
}
protected enableEditing(): void {
this.isEditing.set(true);
}
}

View File

@@ -81,8 +81,7 @@
variant="neutral-outline" variant="neutral-outline"
hostClass="w-100 d-block" hostClass="w-100 d-block"
buttonClass="w-100 d-inline-flex align-items-center justify-content-center gap-2 py-2 fw-semibold" buttonClass="w-100 d-inline-flex align-items-center justify-content-center gap-2 py-2 fw-semibold"
[disabled]="isSubmitting()" [disabled]="true"
(click)="loginWithGoogle()"
> >
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"

View File

@@ -1,7 +1,7 @@
import { DOCUMENT, isPlatformBrowser } from '@angular/common'; import { DOCUMENT } from '@angular/common';
import { ChangeDetectionStrategy, Component, inject, PLATFORM_ID, signal } from '@angular/core'; import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router'; import { Router } from '@angular/router';
import { AuthService } from '../../../../core/services/auth/auth.service'; import { AuthService } from '../../../../core/services/auth/auth.service';
import { ButtonComponent } from '../../../../shared/components/button/button.component'; import { ButtonComponent } from '../../../../shared/components/button/button.component';
@@ -20,10 +20,8 @@ const EMAIL_MAX_LENGTH = 255;
export class LoginPageComponent { export class LoginPageComponent {
private readonly formBuilder = inject(FormBuilder); private readonly formBuilder = inject(FormBuilder);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
private readonly authService = inject(AuthService); private readonly authService = inject(AuthService);
private readonly document = inject(DOCUMENT); private readonly document = inject(DOCUMENT);
private readonly platformId = inject(PLATFORM_ID);
private readonly submittedState = signal(false); private readonly submittedState = signal(false);
private readonly serverErrorState = signal<string | null>(null); private readonly serverErrorState = signal<string | null>(null);
@@ -37,18 +35,6 @@ export class LoginPageComponent {
protected readonly serverError = this.serverErrorState.asReadonly(); protected readonly serverError = this.serverErrorState.asReadonly();
protected readonly isSubmitting = this.isSubmittingState.asReadonly(); protected readonly isSubmitting = this.isSubmittingState.asReadonly();
ngOnInit(): void {
if (!isPlatformBrowser(this.platformId)) {
return;
}
const oauthCode = this.route.snapshot.queryParamMap.get('oauth_code');
if (oauthCode) {
this.completeGoogleLogin(oauthCode);
}
}
goToRegister(): void { goToRegister(): void {
void this.router.navigate(['/register']); void this.router.navigate(['/register']);
} }
@@ -76,16 +62,6 @@ export class LoginPageComponent {
}); });
} }
loginWithGoogle(): void {
this.serverErrorState.set(null);
try {
this.authService.loginWithGoogle();
} catch (error: unknown) {
this.serverErrorState.set(this.resolveErrorMessage(error));
}
}
protected updateEmail(value: string | number): void { protected updateEmail(value: string | number): void {
this.form.controls.email.setValue(String(value)); this.form.controls.email.setValue(String(value));
} }
@@ -131,22 +107,6 @@ export class LoginPageComponent {
this.document.location.assign(homeUrl); this.document.location.assign(homeUrl);
} }
private completeGoogleLogin(oauthCode: string): void {
this.isSubmittingState.set(true);
this.serverErrorState.set(null);
this.authService.completeGoogleLogin(oauthCode).subscribe({
next: () => {
this.isSubmittingState.set(false);
this.redirectToHome();
},
error: (error: unknown) => {
this.isSubmittingState.set(false);
this.serverErrorState.set(this.resolveErrorMessage(error));
}
});
}
private resolveErrorMessage(error: unknown): string { private resolveErrorMessage(error: unknown): string {
const errorPayload = const errorPayload =
typeof error === 'object' && error !== null && 'error' in error typeof error === 'object' && error !== null && 'error' in error

View File

@@ -1,120 +1,118 @@
<section class="product-detail"> <section class="product-detail">
@if (loading()) { @if (loading()) {
<div class="text-center py-5"> <div class="text-center py-5">
<div class="spinner-border text-primary" role="status"> <div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Cargando...</span> <span class="visually-hidden">Cargando...</span>
</div>
</div>
} @else if (error()) {
<div class="alert alert-danger text-center mb-0" role="alert">
{{ error() }}
</div>
} @else if (product(); as prod) {
<div class="row g-4 g-xl-5 align-items-start">
<div class="col-12 col-lg-5">
<div #carouselHost class="product-detail__carousel">
<app-product-carousel [images]="carouselImages()" />
</div> </div>
</div> </div>
} @else if (error()) {
<div class="alert alert-danger text-center mb-0" role="alert">
{{ error() }}
</div>
} @else if (product(); as prod) {
<div class="row g-4 g-xl-5 align-items-start">
<div class="col-12 col-lg-5">
<div #carouselHost class="product-detail__carousel">
<app-product-carousel [images]="carouselImages()" />
</div>
</div>
<div class="col-12 col-lg-7"> <div class="col-12 col-lg-7">
<div class="product-detail__panel"> <div class="product-detail__panel">
<section class="product-detail__section"> <section class="product-detail__section">
<h1 class="product-detail__title mb-0">{{ prod.nombre }}</h1> <h1 class="product-detail__title mb-0">{{ prod.nombre }}</h1>
<div class="product-detail__title-price-divider"></div> <div class="product-detail__title-price-divider"></div>
<div class="product-detail__price-group"> <div class="product-detail__price-group">
<span class="product-detail__price-current"> <span class="product-detail__price-current">
{{ getFormattedPrice(prod.precio) }} {{ getFormattedPrice(prod.precio) }}
</span> </span>
@if (oldPrice(); as oldPrice) { @if (oldPrice(); as oldPrice) {
<span class="product-detail__price-previous">{{ oldPrice }}</span> <span class="product-detail__price-previous">{{ oldPrice }}</span>
} }
</div> </div>
</section> </section>
@if (hasRenderableAttributes()) {
<div class="product-detail__divider"></div>
<section class="product-detail__section product-detail__section--attributes">
<app-product-attribute-selector
[attributes]="renderableAttributes()"
[variantsMap]="prod.variants_map"
[defaultVariant]="prod.variant"
(variantChange)="onVariantChange($event)"
/>
</section>
}
@if (hasRenderableAttributes()) {
<div class="product-detail__divider"></div> <div class="product-detail__divider"></div>
<section class="product-detail__section product-detail__section--attributes"> <section class="product-detail__section">
<app-product-attribute-selector <div class="product-detail__purchase">
[attributes]="renderableAttributes()" <app-quantity-selector
[variants]="prod.variants" [(quantity)]="quantity"
[selectedVariant]="prod.selected_variant ?? null" [max]="selectedVariant()?.cantidad_maxima ?? 1"
[inventoryPolicy]="prod.inventory_policy" />
(variantChange)="onVariantChange($event)"
/>
</section>
}
<div class="product-detail__divider"></div> <div class="product-detail__actions">
<app-button
class="product-detail__cta"
variant="secondary"
type="button"
[disabled]="!selectedVariant() || variantLoading() || addingToCart()"
(click)="addToCart()"
>
@if (variantLoading() || addingToCart()) {
<div class="spinner-border spinner-border-sm" role="status"></div>
} @else {
Agregar al carrito
}
</app-button>
<section class="product-detail__section"> <app-button class="product-detail__cta" type="button" [disabled]="!selectedVariant() || variantLoading()">
<div class="product-detail__purchase"> @if (variantLoading()) {
<app-quantity-selector [(quantity)]="quantity" [max]="selectedVariantMax()" /> <div class="spinner-border spinner-border-sm" role="status"></div>
} @else {
<div class="product-detail__actions"> Comprar
<app-button }
class="product-detail__cta" </app-button>
variant="secondary" </div>
type="button"
[disabled]="!selectedVariantAvailable() || variantLoading() || addingToCart()"
(click)="addToCart()"
>
@if (variantLoading() || addingToCart()) {
<div class="spinner-border spinner-border-sm" role="status"></div>
} @else {
Agregar al carrito
}
</app-button>
<app-button
class="product-detail__cta"
type="button"
[disabled]="!selectedVariantAvailable() || variantLoading()"
>
@if (variantLoading()) {
<div class="spinner-border spinner-border-sm" role="status"></div>
} @else {
Comprar
}
</app-button>
</div> </div>
</div> </section>
</section>
<div class="product-detail__divider"></div> <div class="product-detail__divider"></div>
<section class="product-detail__section product-detail__section--description"> <section class="product-detail__section product-detail__section--description">
<h2 class="product-detail__description-heading">DESCRIPCIÓN</h2> <h2 class="product-detail__description-heading">DESCRIPCIÓN</h2>
<div <div
#descriptionBody #descriptionBody
class="product-detail__description-body" class="product-detail__description-body"
[class.product-detail__description-body--expanded]="descriptionExpanded()" [class.product-detail__description-body--expanded]="descriptionExpanded()"
[style.max-height.px]="descriptionExpanded() ? descriptionMaxHeight() || null : null" [style.max-height.px]="descriptionExpanded() ? descriptionMaxHeight() || null : null"
>
{{ prod.descripcion }}
</div>
@if (showDescriptionToggle()) {
<button
type="button"
class="product-detail__description-toggle"
(click)="toggleDescription()"
> >
{{ descriptionExpanded() ? 'Mostrar menos' : 'Mostrar más' }} {{ prod.descripcion }}
</button> </div>
}
</section> @if (showDescriptionToggle()) {
<button
type="button"
class="product-detail__description-toggle"
(click)="toggleDescription()"
>
{{ descriptionExpanded() ? 'Mostrar menos' : 'Mostrar más' }}
</button>
}
</section>
</div>
</div> </div>
</div> </div>
</div> } @else {
} @else { <div class="alert alert-warning text-center mb-0" role="alert">
<div class="alert alert-warning text-center mb-0" role="alert"> No se encontró el producto especificado.
No se encontró el producto especificado. </div>
</div> }
}
</section> </section>

View File

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

View File

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

View File

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

View File

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

View File

@@ -50,8 +50,8 @@ describe('RegisterPageComponent', () => {
component.form.setValue({ component.form.setValue({
nombre_apellido: 'Ada Lovelace', nombre_apellido: 'Ada Lovelace',
email: 'ada@example.com', email: 'ada@example.com',
password: 'Secret!123', password: 'secret123',
password_confirmation: 'Secret!123' password_confirmation: 'secret123'
}); });
component.onSubmit(); component.onSubmit();
@@ -59,8 +59,8 @@ describe('RegisterPageComponent', () => {
expect(authService.register).toHaveBeenCalledWith({ expect(authService.register).toHaveBeenCalledWith({
nombre_apellido: 'Ada Lovelace', nombre_apellido: 'Ada Lovelace',
email: 'ada@example.com', email: 'ada@example.com',
password: 'Secret!123', password: 'secret123',
password_confirmation: 'Secret!123' password_confirmation: 'secret123'
}); });
expect(modalService.openSimple).toHaveBeenCalledWith({ expect(modalService.openSimple).toHaveBeenCalledWith({
content: 'Tu cuenta fue creada correctamente', content: 'Tu cuenta fue creada correctamente',
@@ -178,8 +178,8 @@ describe('RegisterPageComponent', () => {
component.form.setValue({ component.form.setValue({
nombre_apellido: 'Ada Lovelace', nombre_apellido: 'Ada Lovelace',
email: 'ada@example.com', email: 'ada@example.com',
password: 'Secret!123', password: 'secret123',
password_confirmation: 'Secret!123' password_confirmation: 'secret123'
}); });
component.onSubmit(); component.onSubmit();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,11 +4,10 @@ import { SimpleLayoutComponent } from '../../core/layout/simple-layout/simple-la
import { StoreLayoutComponent } from '../../core/layout/store-layout/store-layout.component'; import { StoreLayoutComponent } from '../../core/layout/store-layout/store-layout.component';
import { authGuard, guestOnlyGuard } from '../../core/services/auth/auth.guards'; import { authGuard, guestOnlyGuard } from '../../core/services/auth/auth.guards';
import { LoginPageComponent } from './pages/login-page/login-page.component'; import { LoginPageComponent } from './pages/login-page/login-page.component';
import { hasMenuGuard } from '../../core/guards/menu.guard';
import { productDetailResolver } from './pages/product-detail-page/product-detail-page.resolver'; import { productDetailResolver } from './pages/product-detail-page/product-detail-page.resolver';
import { RegisterPageComponent } from './pages/register-page/register-page.component'; import { RegisterPageComponent } from './pages/register-page/register-page.component';
import { StoreHomePageComponent } from './pages/store-home-page/store-home-page.component'; import { StoreHomePageComponent } from './pages/store-home-page/store-home-page.component';
import { storeHomeCatalogResolver } from './pages/store-home-page/store-home-page.resolver'; import { storeHomeProductsResolver } from './pages/store-home-page/store-home-page.resolver';
export const routes: Routes = [ export const routes: Routes = [
{ {
@@ -18,9 +17,8 @@ export const routes: Routes = [
{ {
path: '', path: '',
component: StoreHomePageComponent, component: StoreHomePageComponent,
canActivate: [hasMenuGuard('index')],
resolve: { resolve: {
catalogData: storeHomeCatalogResolver, productsData: storeHomeProductsResolver,
}, },
}, },
{ {
@@ -47,7 +45,6 @@ export const routes: Routes = [
}, },
{ {
path: 'producto/:id', path: 'producto/:id',
canActivate: [hasMenuGuard('product.detail')],
resolve: { resolve: {
productDetailData: productDetailResolver, productDetailData: productDetailResolver,
}, },
@@ -58,7 +55,7 @@ export const routes: Routes = [
}, },
{ {
path: 'checkout', path: 'checkout',
canActivate: [authGuard, hasMenuGuard('checkout')], canActivate: [authGuard],
loadComponent: () => loadComponent: () =>
import('./pages/checkout-page/checkout-page.component').then( import('./pages/checkout-page/checkout-page.component').then(
(m) => m.CheckoutPageComponent, (m) => m.CheckoutPageComponent,
@@ -85,7 +82,6 @@ export const routes: Routes = [
children: [ children: [
{ {
path: 'datos-personales', path: 'datos-personales',
canActivate: [hasMenuGuard('profile')],
loadComponent: () => loadComponent: () =>
import('./pages/account-page/pages/profile-page/profile-page').then( import('./pages/account-page/pages/profile-page/profile-page').then(
(m) => m.ProfilePage, (m) => m.ProfilePage,
@@ -93,7 +89,6 @@ export const routes: Routes = [
}, },
{ {
path: 'compras', path: 'compras',
canActivate: [hasMenuGuard('purchases')],
loadComponent: () => loadComponent: () =>
import('./pages/account-page/pages/purchases-page/purchases-page').then( import('./pages/account-page/pages/purchases-page/purchases-page').then(
(m) => m.PurchasesPage, (m) => m.PurchasesPage,
@@ -106,14 +101,6 @@ export const routes: Routes = [
(m) => m.PurchaseDetailPage, (m) => m.PurchaseDetailPage,
), ),
}, },
{
path: 'tickets',
canActivate: [hasMenuGuard('tickets')],
loadComponent: () =>
import('./pages/account-page/pages/tickets-page/tickets-page').then(
(m) => m.TicketsPage,
),
},
{ {
path: '', path: '',
redirectTo: 'datos-personales', redirectTo: 'datos-personales',

View File

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

View File

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

View File

@@ -1,28 +1,16 @@
<section <section class="d-flex flex-column h-100 overflow-hidden text-secondary" [style.background-color]="backgroundColor()">
class="d-flex flex-column h-100 overflow-hidden text-secondary"
[style.background-color]="backgroundColor()"
>
<header class="d-flex align-items-center p-3 justify-content-between bg-transparent cart-header"> <header class="d-flex align-items-center p-3 justify-content-between bg-transparent cart-header">
<h2 class="m-0 text-uppercase fw-bold cart-title">{{ title() }}</h2> <h2 class="m-0 text-uppercase fw-bold cart-title">{{ title() }}</h2>
@if (showClose()) { @if (showClose()) {
<button <button class="btn btn-link p-0 border-0 d-inline-flex align-items-center justify-content-center cart-close-btn" type="button" aria-label="Cerrar carrito" (click)="closed.emit()">
class="btn btn-link p-0 border-0 d-inline-flex align-items-center justify-content-center cart-close-btn"
type="button"
aria-label="Cerrar carrito"
(click)="closed.emit()"
>
<i class="fa-solid fa-xmark"></i> <i class="fa-solid fa-xmark"></i>
</button> </button>
} }
</header> </header>
<div class="d-grid w-100 flex-grow-1 overflow-y-auto cart-items-container"> <div class="d-grid w-100 flex-grow-1 overflow-y-auto cart-items-container">
@for ( @for (item of items(); track item.productVariantId || item.product + item.discountedPrice; let idx = $index) {
item of items();
track item.cartItemId || item.product + item.discountedPrice;
let idx = $index
) {
<app-cart-item <app-cart-item
[imageUrl]="item.imageUrl" [imageUrl]="item.imageUrl"
[product]="item.product" [product]="item.product"
@@ -34,31 +22,30 @@
(quantityChange)="onItemQuantityChange(idx, $event)" (quantityChange)="onItemQuantityChange(idx, $event)"
(remove)="onItemRemove(idx)" (remove)="onItemRemove(idx)"
/> />
} @empty {
<p class="cart-empty-message" role="status">El carrito está vacío</p>
} }
</div> </div>
@if (items().length > 0) { <footer class="d-grid gap-1 bg-transparent cart-footer">
<footer class="d-grid gap-1 bg-transparent cart-footer"> <div class="d-flex align-items-baseline justify-content-between gap-3">
<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">Subtotal:</span> <span class="cart-summary-text-muted-bold">{{ formattedSubtotal() }}</span>
<span class="cart-summary-text-muted-bold">{{ formattedSubtotal() }}</span> </div>
</div>
<div class="d-flex align-items-baseline justify-content-between gap-3"> <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">Descuento:</span>
<span class="cart-summary-text-muted-bold">{{ formattedDiscount() }}</span> <span class="cart-summary-text-muted-bold">{{ formattedDiscount() }}</span>
</div> </div>
<div class="d-flex align-items-baseline justify-content-between gap-3 mt-1"> <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">TOTAL:</span>
<span class="cart-total-text">{{ formattedTotal() }}</span> <span class="cart-total-text">{{ formattedTotal() }}</span>
</div> </div>
<div class="cart-actions mt-3"> <div class="cart-actions mt-3">
<ng-content /> <ng-content />
</div> </div>
</footer> </footer>
}
</section> </section>

View File

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

View File

@@ -2,7 +2,10 @@ import '@angular/compiler';
import { signal } from '@angular/core'; import { signal } from '@angular/core';
import { TestBed, getTestBed } from '@angular/core/testing'; import { TestBed, getTestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser'; import { By } from '@angular/platform-browser';
import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing'; import {
BrowserTestingModule,
platformBrowserTesting
} from '@angular/platform-browser/testing';
import { of, throwError } from 'rxjs'; import { of, throwError } from 'rxjs';
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
@@ -14,7 +17,10 @@ import { CartComponent } from './cart.component';
describe('CartComponent', () => { describe('CartComponent', () => {
beforeAll(() => { beforeAll(() => {
try { try {
getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting()); getTestBed().initTestEnvironment(
BrowserTestingModule,
platformBrowserTesting()
);
} catch { } catch {
// Test environment may already be initialized by another setup entrypoint. // Test environment may already be initialized by another setup entrypoint.
} }
@@ -24,44 +30,6 @@ describe('CartComponent', () => {
TestBed.resetTestingModule(); 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 () => { it('opens a confirm delete modal before removing an item', async () => {
const removeItem = vi.fn().mockReturnValue(of({ message: 'Producto eliminado.' })); const removeItem = vi.fn().mockReturnValue(of({ message: 'Producto eliminado.' }));
const openConfirmDelete = vi.fn().mockReturnValue(of(true)); const openConfirmDelete = vi.fn().mockReturnValue(of(true));
@@ -74,50 +42,49 @@ describe('CartComponent', () => {
useValue: { useValue: {
cart: signal(null).asReadonly(), cart: signal(null).asReadonly(),
updateItemQuantity: vi.fn(), updateItemQuantity: vi.fn(),
removeItem, removeItem
}, }
}, },
{ {
provide: ModalService, provide: ModalService,
useValue: { useValue: {
openConfirmDelete, openConfirmDelete
}, }
}, },
{ {
provide: ToastService, provide: ToastService,
useValue: { useValue: {
success: vi.fn(), success: vi.fn(),
info: vi.fn(), info: vi.fn(),
danger: vi.fn(), danger: vi.fn()
}, }
}, }
], ]
}).compileComponents(); }).compileComponents();
const fixture = TestBed.createComponent(CartComponent); const fixture = TestBed.createComponent(CartComponent);
fixture.componentRef.setInput('items', [ fixture.componentRef.setInput('items', [
{ {
cartItemId: 10, productVariantId: 10,
imageUrl: null, imageUrl: null,
product: 'Producto de prueba', product: 'Producto de prueba',
originalPrice: null, originalPrice: null,
discountedPrice: 1000, discountedPrice: 1000,
discountPercentage: null, discountPercentage: null,
attributes: [], attributes: [],
quantity: 1, quantity: 1
}, }
]); ]);
fixture.detectChanges(); fixture.detectChanges();
expect(fixture.debugElement.query(By.css('.cart-item-media'))).toBeNull();
fixture.debugElement.query(By.css('app-cart-item')).triggerEventHandler('remove'); fixture.debugElement.query(By.css('app-cart-item')).triggerEventHandler('remove');
expect(openConfirmDelete).toHaveBeenCalledWith({ expect(openConfirmDelete).toHaveBeenCalledWith({
title: 'Eliminar producto', title: 'Eliminar producto',
content: 'Se eliminara "Producto de prueba" del carrito. Esta accion no se puede deshacer.', content:
'Se eliminara "Producto de prueba" del carrito. Esta accion no se puede deshacer.',
confirmLabel: 'Eliminar', confirmLabel: 'Eliminar',
cancelLabel: 'Cancelar', cancelLabel: 'Cancelar'
}); });
expect(removeItem).toHaveBeenCalledWith(10); expect(removeItem).toHaveBeenCalledWith(10);
}); });
@@ -134,38 +101,38 @@ describe('CartComponent', () => {
useValue: { useValue: {
cart: signal(null).asReadonly(), cart: signal(null).asReadonly(),
updateItemQuantity: vi.fn(), updateItemQuantity: vi.fn(),
removeItem, removeItem
}, }
}, },
{ {
provide: ModalService, provide: ModalService,
useValue: { useValue: {
openConfirmDelete, openConfirmDelete
}, }
}, },
{ {
provide: ToastService, provide: ToastService,
useValue: { useValue: {
success: vi.fn(), success: vi.fn(),
info: vi.fn(), info: vi.fn(),
danger: vi.fn(), danger: vi.fn()
}, }
}, }
], ]
}).compileComponents(); }).compileComponents();
const fixture = TestBed.createComponent(CartComponent); const fixture = TestBed.createComponent(CartComponent);
fixture.componentRef.setInput('items', [ fixture.componentRef.setInput('items', [
{ {
cartItemId: 10, productVariantId: 10,
imageUrl: null, imageUrl: null,
product: 'Producto de prueba', product: 'Producto de prueba',
originalPrice: null, originalPrice: null,
discountedPrice: 1000, discountedPrice: 1000,
discountPercentage: null, discountPercentage: null,
attributes: [], attributes: [],
quantity: 1, quantity: 1
}, }
]); ]);
fixture.detectChanges(); fixture.detectChanges();
@@ -188,36 +155,36 @@ describe('CartComponent', () => {
useValue: { useValue: {
cart: signal(null).asReadonly(), cart: signal(null).asReadonly(),
updateItemQuantity, updateItemQuantity,
removeItem: vi.fn(), removeItem: vi.fn()
}, }
}, },
{ {
provide: ModalService, provide: ModalService,
useValue: {}, useValue: {}
}, },
{ {
provide: ToastService, provide: ToastService,
useValue: { useValue: {
success: vi.fn(), success: vi.fn(),
info: vi.fn(), info: vi.fn(),
danger, danger
}, }
}, }
], ]
}).compileComponents(); }).compileComponents();
const fixture = TestBed.createComponent(CartComponent); const fixture = TestBed.createComponent(CartComponent);
const component = fixture.componentInstance; const component = fixture.componentInstance;
const item = { const item = {
cartItemId: 10, productVariantId: 10,
imageUrl: null, imageUrl: null,
product: 'Producto de prueba', product: 'Producto de prueba',
originalPrice: null, originalPrice: null,
discountedPrice: 1000, discountedPrice: 1000,
discountPercentage: null, discountPercentage: null,
attributes: [], attributes: [],
quantity: 1, quantity: 1
}; };
fixture.componentRef.setInput('items', [item]); fixture.componentRef.setInput('items', [item]);
@@ -254,36 +221,36 @@ describe('CartComponent', () => {
useValue: { useValue: {
cart: signal(null).asReadonly(), cart: signal(null).asReadonly(),
updateItemQuantity, updateItemQuantity,
removeItem: vi.fn(), removeItem: vi.fn()
}, }
}, },
{ {
provide: ModalService, provide: ModalService,
useValue: {}, useValue: {}
}, },
{ {
provide: ToastService, provide: ToastService,
useValue: { useValue: {
success, success,
info: vi.fn(), info: vi.fn(),
danger: vi.fn(), danger: vi.fn()
}, }
}, }
], ]
}).compileComponents(); }).compileComponents();
const fixture = TestBed.createComponent(CartComponent); const fixture = TestBed.createComponent(CartComponent);
const component = fixture.componentInstance; const component = fixture.componentInstance;
const item = { const item = {
cartItemId: 10, productVariantId: 10,
imageUrl: null, imageUrl: null,
product: 'Producto de prueba', product: 'Producto de prueba',
originalPrice: null, originalPrice: null,
discountedPrice: 1000, discountedPrice: 1000,
discountPercentage: null, discountPercentage: null,
attributes: [], attributes: [],
quantity: 1, quantity: 1
}; };
fixture.componentRef.setInput('items', [item]); fixture.componentRef.setInput('items', [item]);

View File

@@ -1,12 +1,4 @@
import { import { ChangeDetectionStrategy, Component, computed, inject, input, output, signal } from '@angular/core';
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
output,
signal,
} from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http'; import { HttpErrorResponse } from '@angular/common/http';
import { Subject, EMPTY } from 'rxjs'; import { Subject, EMPTY } from 'rxjs';
import { catchError, debounceTime, groupBy, mergeMap, switchMap, tap } from 'rxjs'; import { catchError, debounceTime, groupBy, mergeMap, switchMap, tap } from 'rxjs';
@@ -17,7 +9,7 @@ import { CartService } from '../../../core/services/cart/cart.service';
import { ToastService } from '../../../core/services/toast.service'; import { ToastService } from '../../../core/services/toast.service';
export interface CartItemMock { export interface CartItemMock {
cartItemId?: number; productVariantId?: number;
imageUrl: string | null; imageUrl: string | null;
product: string; product: string;
originalPrice: number | null; originalPrice: number | null;
@@ -33,13 +25,13 @@ export interface CartItemMock {
imports: [CartItemComponent], imports: [CartItemComponent],
templateUrl: './cart.component.html', templateUrl: './cart.component.html',
styleUrl: './cart.component.scss', styleUrl: './cart.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class CartComponent { export class CartComponent {
private readonly cartService = inject(CartService); private readonly cartService = inject(CartService);
private readonly modalService = inject(ModalService); private readonly modalService = inject(ModalService);
private readonly toastService = inject(ToastService); private readonly toastService = inject(ToastService);
private readonly quantityUpdates$ = new Subject<{ cartItemId: number; quantity: number }>(); private readonly quantityUpdates$ = new Subject<{ productVariantId: number; quantity: number }>();
readonly title = input<string>('CARRITO'); readonly title = input<string>('CARRITO');
readonly showClose = input<boolean>(false); readonly showClose = input<boolean>(false);
@@ -54,75 +46,68 @@ export class CartComponent {
protected readonly quantityOverrides = signal<Record<number, number>>({}); protected readonly quantityOverrides = signal<Record<number, number>>({});
constructor() { constructor() {
this.quantityUpdates$ this.quantityUpdates$.pipe(
.pipe( groupBy(update => update.productVariantId),
groupBy((update) => update.cartItemId), mergeMap(group$ => group$.pipe(
mergeMap((group$) => debounceTime(1000),
group$.pipe( switchMap(update => this.cartService.updateItemQuantity(update.productVariantId, update.quantity).pipe(
debounceTime(1000), tap({
switchMap((update) => next: (res) => {
this.cartService.updateItemQuantity(update.cartItemId, update.quantity).pipe( const msg = res.message || 'Cantidad de producto actualizada.';
tap({ this.toastService.success(msg);
next: (res) => { this.clearOverride(update.productVariantId);
const msg = res.message || 'Cantidad de producto actualizada.'; },
this.toastService.success(msg); error: (err: HttpErrorResponse) => {
this.clearOverride(update.cartItemId); console.error('Error updating cart quantity', err);
}, const msg = err.error?.message || 'Error al actualizar la cantidad del producto.';
error: (err: HttpErrorResponse) => { this.toastService.danger(msg);
console.error('Error updating cart quantity', err); this.clearOverride(update.productVariantId);
const msg = }
err.error?.message || 'Error al actualizar la cantidad del producto.'; }),
this.toastService.danger(msg); catchError(() => EMPTY)
this.clearOverride(update.cartItemId); ))
}, )),
}), takeUntilDestroyed()
catchError(() => EMPTY), ).subscribe();
),
),
),
),
takeUntilDestroyed(),
)
.subscribe();
} }
protected getItemQuantity(item: CartItemMock): number { protected getItemQuantity(item: CartItemMock): number {
if (item.cartItemId !== undefined && this.quantityOverrides()[item.cartItemId] !== undefined) { if (item.productVariantId !== undefined && this.quantityOverrides()[item.productVariantId] !== undefined) {
return this.quantityOverrides()[item.cartItemId]; return this.quantityOverrides()[item.productVariantId];
} }
return item.quantity; return item.quantity;
} }
private clearOverride(cartItemId: number): void { private clearOverride(productVariantId: number): void {
this.quantityOverrides.update((overrides) => { this.quantityOverrides.update((overrides) => {
const copy = { ...overrides }; const copy = { ...overrides };
delete copy[cartItemId]; delete copy[productVariantId];
return copy; return copy;
}); });
} }
protected onItemQuantityChange(index: number, newQuantity: number): void { protected onItemQuantityChange(index: number, newQuantity: number): void {
const mockItem = this.items()[index]; const mockItem = this.items()[index];
const cartItemId = mockItem?.cartItemId; const productVariantId = mockItem?.productVariantId;
if (cartItemId) { if (productVariantId) {
this.quantityOverrides.update((overrides) => ({ this.quantityOverrides.update((overrides) => ({
...overrides, ...overrides,
[cartItemId]: newQuantity, [productVariantId]: newQuantity
})); }));
this.quantityUpdates$.next({ this.quantityUpdates$.next({
cartItemId, productVariantId,
quantity: newQuantity, quantity: newQuantity
}); });
} else { } else {
const item = this.cartService.cart()?.items[index]; const item = this.cartService.cart()?.items[index];
if (item) { if (item) {
this.quantityOverrides.update((overrides) => ({ this.quantityOverrides.update((overrides) => ({
...overrides, ...overrides,
[item.id]: newQuantity, [item.product_variant_id]: newQuantity
})); }));
this.quantityUpdates$.next({ this.quantityUpdates$.next({
cartItemId: item.id, productVariantId: item.product_variant_id,
quantity: newQuantity, quantity: newQuantity
}); });
} }
} }
@@ -135,18 +120,16 @@ export class CartComponent {
return; return;
} }
this.modalService this.modalService.openConfirmDelete({
.openConfirmDelete({ title: 'Eliminar producto',
title: 'Eliminar producto', content: `Se eliminara "${target.productName}" del carrito. Esta accion no se puede deshacer.`,
content: `Se eliminara "${target.productName}" del carrito. Esta accion no se puede deshacer.`, confirmLabel: 'Eliminar',
confirmLabel: 'Eliminar', cancelLabel: 'Cancelar'
cancelLabel: 'Cancelar', }).subscribe((confirmed) => {
}) if (confirmed) {
.subscribe((confirmed) => { this.removeItem(target.productVariantId);
if (confirmed) { }
this.removeItem(target.cartItemId); });
}
});
} }
protected readonly formattedSubtotal = computed(() => this.formatCurrency(this.subtotal())); protected readonly formattedSubtotal = computed(() => this.formatCurrency(this.subtotal()));
@@ -160,14 +143,16 @@ export class CartComponent {
return `$ ${parts.join(',')}`; return `$ ${parts.join(',')}`;
} }
private resolveRemoveTarget(index: number): { cartItemId: number; productName: string } | null { private resolveRemoveTarget(
index: number
): { productVariantId: number; productName: string } | null {
const mockItem = this.items()[index]; const mockItem = this.items()[index];
const cartItemId = mockItem?.cartItemId; const productVariantId = mockItem?.productVariantId;
if (cartItemId) { if (productVariantId) {
return { return {
cartItemId, productVariantId,
productName: mockItem.product, productName: mockItem.product
}; };
} }
@@ -178,13 +163,13 @@ export class CartComponent {
} }
return { return {
cartItemId: item.id, productVariantId: item.product_variant_id,
productName: item.product?.nombre ?? 'este producto', productName: item.product?.nombre ?? 'este producto'
}; };
} }
private removeItem(cartItemId: number): void { private removeItem(productVariantId: number): void {
this.cartService.removeItem(cartItemId).subscribe({ this.cartService.removeItem(productVariantId).subscribe({
next: (res) => { next: (res) => {
const msg = res.message || 'Producto eliminado del carrito.'; const msg = res.message || 'Producto eliminado del carrito.';
this.toastService.info(msg); this.toastService.info(msg);
@@ -193,7 +178,8 @@ export class CartComponent {
console.error('Error removing item from cart', err); console.error('Error removing item from cart', err);
const msg = err.error?.message || 'Error al eliminar el producto del carrito.'; const msg = err.error?.message || 'Error al eliminar el producto del carrito.';
this.toastService.danger(msg); this.toastService.danger(msg);
}, }
}); });
} }
} }

View File

@@ -1,52 +0,0 @@
<div class="hero-banner-container">
<div
class="hero-banner position-relative rounded"
[ngStyle]="{'background-image': heroConfig?.background_image ? 'url(' + heroConfig.background_image + ')' : 'none'}"
>
@if (heroConfig) {
<div class="hero-content d-flex justify-content-end p-4 p-md-5 w-100 h-100 align-items-center">
<div class="hero-text-box">
@if (heroConfig.title_html) {
<div class="hero-title" [innerHTML]="heroConfig.title_html"></div>
}
@if (heroConfig.description_html) {
<div class="hero-description" [innerHTML]="heroConfig.description_html"></div>
}
@if (heroConfig.button_text) {
<a [href]="heroConfig.button_href || '#'" class="text-decoration-none mt-3 d-inline-block">
<app-button variant="primary">
{{ heroConfig.button_text }}
</app-button>
</a>
}
</div>
</div>
}
@if (eventConfig) {
<div class="event-card-container position-absolute w-100 d-flex justify-content-center">
<div class="event-card bg-white shadow rounded p-3 p-md-4 text-center">
@if (eventConfig.title) {
<h3 class="event-title text-primary fw-bold mb-3">{{ eventConfig.title }}</h3>
}
<div class="event-details d-flex flex-column flex-md-row justify-content-center align-items-center gap-3 gap-md-5 text-muted">
@if (eventConfig.dates && eventConfig.dates.length > 0) {
<div class="d-flex align-items-center gap-2">
<i class="fa-regular fa-calendar event-icon"></i>
<span class="event-info-text">{{ formattedDates }}</span>
</div>
}
@if (eventConfig.location) {
<div class="d-flex align-items-center gap-2">
<i class="fa-solid fa-location-dot event-icon"></i>
<span class="event-info-text">{{ eventConfig.location }}</span>
</div>
}
</div>
</div>
</div>
}
</div>
</div>

View File

@@ -1,111 +0,0 @@
.hero-banner-container {
padding-bottom: 4rem; /* Spacing to accommodate the overlapping card */
margin-bottom: 1rem;
}
.hero-banner {
background-color: #e9ecef;
background-size: cover;
background-position: center;
min-height: 400px;
display: flex;
flex-direction: column;
&::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(to right, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.7) 50%, rgba(255, 255, 255, 0.95) 100%);
border-radius: inherit;
z-index: 1;
}
}
.hero-text-box {
max-width: 500px;
padding: 1.5rem;
background: transparent;
}
.hero-content {
position: relative;
z-index: 2;
flex-grow: 1;
}
::ng-deep .hero-title, .hero-title {
color: #666666;
font-size: 1.5rem;
margin-bottom: 1rem;
strong {
font-weight: 800;
}
}
::ng-deep .hero-description, .hero-description {
color: #666666;
font-size: 1.1rem;
line-height: 1.5;
strong {
font-weight: 700;
}
}
.event-card-container {
bottom: -40px; /* Negative margin to pull it down and overlap */
left: 0;
z-index: 10;
}
.event-card {
width: 90%;
max-width: 900px;
}
.event-title {
font-size: 1.4rem;
letter-spacing: 0.5px;
}
.event-details {
font-size: 1.1rem;
}
.event-icon {
font-size: 1.3rem;
color: #8a8a8a;
}
.event-info-text {
color: #8a8a8a;
font-weight: 500;
}
@media (max-width: 768px) {
.hero-banner::before {
background: none;
}
.hero-content {
justify-content: center !important;
background: none;
}
.hero-text-box {
text-align: center;
background: rgba(255, 255, 255, 0.8);
border-radius: 8px;
}
.event-card-container {
bottom: -80px;
}
.hero-banner-container {
padding-bottom: 6rem;
}
}

View File

@@ -1,41 +0,0 @@
import { Component, ChangeDetectionStrategy, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { HeroConfig, EventConfig } from '../../../core/services/tenant.interface';
import { ButtonComponent } from '../button/button.component';
@Component({
selector: 'app-hero-banner',
standalone: true,
imports: [CommonModule, RouterModule, ButtonComponent],
templateUrl: './hero-banner.component.html',
styleUrl: './hero-banner.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class HeroBannerComponent {
@Input() heroConfig?: HeroConfig | null;
@Input() eventConfig?: EventConfig | null;
get formattedDates(): string {
if (!this.eventConfig?.dates || this.eventConfig.dates.length === 0) {
return '';
}
// Si ya viene formateado o es un string libre largo, lo devolvemos
if (this.eventConfig.dates.length === 1 && this.eventConfig.dates[0].length > 10) {
return this.eventConfig.dates[0];
}
// Si viene como array de fechas ISO, intentamos formatearlo bonito
// Pero por simplicidad ahora, los unimos.
// Idealmente el backend manda el texto formateado o se usa un DatePipe avanzado
const hasIsoDates = this.eventConfig.dates.some(d => d.includes('-'));
if (hasIsoDates) {
// Un simple join por si acaso, aunque lo ideal es que el admin ponga el texto tal cual
return '9, 10, 11 y 12 de Octubre 2026'; // Placeholder basado en los requerimientos, si no se envía como string formateado
}
return this.eventConfig.dates.join(', ');
}
}

View File

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

View File

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

View File

@@ -19,36 +19,6 @@
margin: 0; 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 { .modal-shell__dialog.modal-sm {
width: min(100%, 24rem); width: min(100%, 24rem);
} }
@@ -70,7 +40,8 @@
min-height: 180px; min-height: 180px;
max-height: calc(100dvh - 2rem); max-height: calc(100dvh - 2rem);
border-radius: 1.25rem; 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 { .modal-shell__dialog--full .modal-shell__content {
@@ -117,7 +88,6 @@
} }
.modal-shell__dialog, .modal-shell__dialog,
.modal-shell__dialog--qr,
.modal-shell__dialog.modal-sm, .modal-shell__dialog.modal-sm,
.modal-shell__dialog.modal-lg, .modal-shell__dialog.modal-lg,
.modal-shell__dialog.modal-xl, .modal-shell__dialog.modal-xl,

View File

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

View File

@@ -0,0 +1,66 @@
<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

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

View File

@@ -0,0 +1,71 @@
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

@@ -1,61 +0,0 @@
<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

@@ -1,48 +0,0 @@
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

@@ -1,51 +0,0 @@
<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

@@ -1,61 +0,0 @@
: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

@@ -1,131 +0,0 @@
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

@@ -1,104 +0,0 @@
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

@@ -1,47 +0,0 @@
<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

@@ -1,63 +0,0 @@
: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

@@ -1,69 +0,0 @@
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

@@ -1,21 +0,0 @@
<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

@@ -1,103 +0,0 @@
: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

@@ -1,118 +0,0 @@
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

@@ -1,36 +0,0 @@
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

@@ -1,16 +0,0 @@
<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

@@ -1,44 +0,0 @@
.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

@@ -1,15 +0,0 @@
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);
}

Some files were not shown because too many files have changed in this diff Show More