Compare commits

...

38 Commits

Author SHA1 Message Date
20c8b04638 feat(auth): implement Google OAuth login flow and handle session clearing on 401 2026-07-22 17:01:11 -03:00
c662c27fc7 feat(auth): integrate TenantService to include tenant code in registration payload 2026-07-22 11:58:31 -03:00
3cefaf85d9 feat(qr-modal): implement QR modal component and integrate with tickets page 2026-07-21 14:55:46 -03:00
472dead0ff feat(tickets): separate active and inactive tickets, update selection logic and styles 2026-07-21 14:35:38 -03:00
fa8032c557 feat(ticket): enhance ticket component with disabled and expired states, update styles and tests 2026-07-21 14:25:52 -03:00
6c5e10feb2 feat(ticket): update ticket title styles and adjust QR button dimensions 2026-07-21 14:14:35 -03:00
63541a3593 feat(ticket): adjust QR button styles for better responsiveness 2026-07-21 14:12:50 -03:00
bc37a91c7f feat(tickets): implement tickets page with ticket component and service 2026-07-21 14:12:23 -03:00
54826a7f8a feat(ticket): add reusable ticket component with actions and styles 2026-07-21 13:37:31 -03:00
8f57af3cf5 feat(purchase-item): enhance layout for items without images and add description handling 2026-07-21 12:21:26 -03:00
da6614a9f8 feat(auth): update session handling on 401 responses and refactor error propagation 2026-07-21 10:36:48 -03:00
8e44fa5a46 feat(purchase-detail): update item details structure and handle null values in mapping 2026-07-21 10:22:32 -03:00
938d15bf83 feat(checkout): implement QR payment polling and validation logic 2026-07-21 09:52:19 -03:00
dafaa85480 feat(cart): implement add to cart functionality and display empty cart message 2026-07-20 17:11:06 -03:00
98e899c5bf feat(product-list): enhance layout handling with responsive design and computed properties 2026-07-20 16:59:57 -03:00
0c0d2b559d feat(catalog): refactor product and variant interfaces for improved structure and clarity 2026-07-20 16:46:17 -03:00
a233a93d16 feat(store-home-page): add margin styling for adjacent store sections 2026-07-20 16:24:24 -03:00
b0e97fc577 feat(store-home-page): refactor to use catalog structure and update product loading logic 2026-07-20 16:08:18 -03:00
5165ea0716 feat(product-list): implement pagination and update items to use paginated response 2026-07-20 14:39:12 -03:00
c0eb3369c5 feat(product-list): replace ProductCard with ProductColumnWithImage and update related components 2026-07-20 14:27:24 -03:00
625990f594 feat(product-vertical-with-cart-card): add reusable vertical product card component with cart functionality 2026-07-17 14:14:05 -03:00
d4c671dc7a refactor(product-row-card): move variant selection logic into constructor for better clarity 2026-07-17 13:47:40 -03:00
944c12a7f3 feat(product-row-card): update variant selection logic to default to first variant if none selected 2026-07-17 13:45:36 -03:00
9a4e2a03e9 feat(product-card): update title color for better visibility and readability
feat(quantity-selector): change background to inherit for consistent styling
2026-07-17 13:45:16 -03:00
40cc2b840d feat(product-row-card): add reusable product row card component with styling and functionality 2026-07-17 11:54:12 -03:00
45c2ee7a2f feat(store-header): update brand slot dimensions for improved layout 2026-07-16 16:07:41 -03:00
b64c627702 feat(checkout): update payment intent payload to use 'transfer_payer_dni' and remove placeholder from DNI input 2026-07-16 11:38:10 -03:00
fc425543ee feat(checkout): add DNI input handling for transfer payments and update related components 2026-07-16 11:28:33 -03:00
36045656e7 feat(checkout): enhance transfer payment flow with DNI input handling and completion action 2026-07-16 11:18:26 -03:00
ada832cbba feat(checkout): update button width and background color for payment components 2026-07-16 11:02:30 -03:00
524eea38f4 feat(checkout): enhance transfer payment flow with DNI input and validation 2026-07-16 10:55:28 -03:00
f163e8a353 feat(checkout): refactor payment step components for QR, transfer, and TelePagos methods 2026-07-16 10:21:59 -03:00
8b785fd631 feat(cart): refactor cart item structure and update related components for consistency 2026-07-16 10:02:24 -03:00
e4195a8567 feat(product-detail): enhance product variant handling with inventory policies and update UI components 2026-07-15 14:33:39 -03:00
25085f6f85 feat(hero-banner): update event icons and add gradient overlay to hero banner 2026-07-14 14:30:01 -03:00
257e4fbeb8 feat(hero-banner): add HeroBanner component with dynamic hero and event configurations 2026-07-14 14:03:51 -03:00
cc87169c1c feat(menu-guard): implement hasMenuGuard for route access control based on tenant menu permissions 2026-07-14 12:22:26 -03:00
e383a8f159 feat(tenant): add HeroConfig, EventConfig, and Menu interfaces to enhance tenant structure 2026-07-14 12:17:40 -03:00
104 changed files with 4797 additions and 1604 deletions

View File

@@ -0,0 +1,24 @@
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,7 +9,8 @@
} }
.store-layout__brand-slot { .store-layout__brand-slot {
min-width: 10rem; min-width: 150px;
max-width:40px;
} }
.store-layout__brand-logo-shell { .store-layout__brand-logo-shell {

View File

@@ -2,7 +2,11 @@ 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 { StoreFooterComponent, StoreFooterSection, StoreSocialLink } from './store-footer/store-footer.component'; import {
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';
@@ -11,9 +15,15 @@ import { AuthService } from '../../services/auth/auth.service';
@Component({ @Component({
selector: 'app-store-layout', selector: 'app-store-layout',
imports: [RouterOutlet, StoreHeaderComponent, StoreFooterComponent, CartComponent, ButtonComponent], imports: [
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);
@@ -57,14 +67,14 @@ export class StoreLayoutComponent implements OnInit {
} }
return { return {
productVariantId: item.product_variant_id, cartItemId: item.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,
}; };
} }
@@ -81,7 +91,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),
}); });
} }
@@ -92,7 +102,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),
}); });
} }
@@ -101,34 +111,33 @@ 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 } from '@angular/common/http'; import { HttpClient, HttpErrorResponse } 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('clears the local session when an authenticated request returns 401', () => { it('propagates a 401 without clearing the local session', () => {
const authService = createAuthServiceStub('expired-token'); const authService = createAuthServiceStub('expired-token');
TestBed.configureTestingModule({ TestBed.configureTestingModule({
@@ -83,14 +83,21 @@ 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: () => undefined error: (error: HttpErrorResponse) => {
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(authService.clearSession).toHaveBeenCalledTimes(1); expect(receivedError).toBeInstanceOf(HttpErrorResponse);
expect(receivedStatus).toBe(401);
expect(authService.clearSession).not.toHaveBeenCalled();
httpController.verify(); httpController.verify();
}); });
}); });

View File

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

View File

@@ -6,6 +6,7 @@ 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> = {};
@@ -95,7 +96,7 @@ describe('AuthService', () => {
httpController.verify(); httpController.verify();
}); });
it('clears the session when bootstrap receives 401 from /me', async () => { it('clears an expired session when /me returns 401', async () => {
cookieStore['shopit.auth.token'] = 'expired-token'; cookieStore['shopit.auth.token'] = 'expired-token';
TestBed.configureTestingModule({ TestBed.configureTestingModule({
@@ -132,6 +133,10 @@ describe('AuthService', () => {
provideHttpClientTesting(), provideHttpClientTesting(),
AuthService, AuthService,
{ provide: CookieService, useValue: createCookieServiceStub() }, { provide: CookieService, useValue: createCookieServiceStub() },
{
provide: TenantService,
useValue: { getTenant: () => ({ codigo: 'tenant-test' }) }
},
TransferState TransferState
] ]
}); });
@@ -152,6 +157,7 @@ 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: {
@@ -168,7 +174,7 @@ describe('AuthService', () => {
httpController.verify(); httpController.verify();
}); });
it('clears local session even when logout request fails', () => { it('propagates logout errors without clearing the local session', () => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [ providers: [
provideHttpClient(), provideHttpClient(),
@@ -202,9 +208,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()).toBeNull(); expect(service.token()).toBe('plain-text-token');
expect(service.user()).toBeNull(); expect(service.user()?.email).toBe('ada@example.com');
expect(cookieStore['shopit.auth.token']).toBeUndefined(); expect(cookieStore['shopit.auth.token']).toBe('plain-text-token');
httpController.verify(); httpController.verify();
}); });

View File

@@ -1,3 +1,4 @@
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';
@@ -13,6 +14,7 @@ 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');
@@ -22,9 +24,11 @@ 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);
@@ -41,7 +45,33 @@ export class AuthService {
} }
register(payload: RegisterPayload): Observable<RegisterResponse> { register(payload: RegisterPayload): Observable<RegisterResponse> {
return this.http.post<RegisterResponse>(`${environment.url}register`, payload); const tenantCode = this.tenantService.getTenant()?.codigo;
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> {
@@ -57,10 +87,7 @@ export class AuthService {
} }
return this.http.post<void>(`${environment.url}logout`, {}).pipe( return this.http.post<void>(`${environment.url}logout`, {}).pipe(
tap({ tap(() => this.clearSession())
next: () => this.clearSession(),
error: () => this.clearSession()
})
); );
} }
@@ -80,12 +107,18 @@ 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) {
if (this.isUnauthorizedError(error)) { // An expired token is an expected state, including while rendering on the
// 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;
} }
@@ -115,8 +148,4 @@ 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;
product_id: number; catalog_item_id: number;
product_variant_id: number; variant_id: number | null;
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',
product_id: 5, catalog_item_id: 5,
product_variant_id: 10, 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,19 +83,23 @@ 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(10, 2).subscribe((res) => { service.addItem(5, 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({ product_variant_id: 10, cantidad: 2 }); expect(req.request.body).toEqual({
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 });
}); });
@@ -122,7 +126,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,29 +29,31 @@ 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(productVariantId: number, cantidad: number): Observable<ApiResponse<Cart>> { addItem(
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<ApiResponse<Cart>>( .post<
`${this.tenantApiUrl}/cart/items`, ApiResponse<Cart>
{ product_variant_id: productVariantId, cantidad }, >(`${this.tenantApiUrl}/cart/items`, { catalog_item_id: catalogItemId, variant_id: variantId, cantidad }, { withCredentials: true })
{ withCredentials: true }
)
.pipe( .pipe(
tap((response) => { tap((response) => {
this.cartState.set(response.data); this.cartState.set(response.data);
@@ -60,18 +62,16 @@ export class CartService {
catchError((error) => { catchError((error) => {
this.isUpdatingState.set(false); this.isUpdatingState.set(false);
throw error; throw error;
}) }),
); );
} }
updateItemQuantity(productVariantId: number, cantidad: number): Observable<ApiResponse<Cart>> { updateItemQuantity(cartItemId: number, cantidad: number): Observable<ApiResponse<Cart>> {
this.isUpdatingState.set(true); this.isUpdatingState.set(true);
return this.http return this.http
.patch<ApiResponse<Cart>>( .patch<
`${this.tenantApiUrl}/cart/items/${productVariantId}`, ApiResponse<Cart>
{ cantidad }, >(`${this.tenantApiUrl}/cart/items/${cartItemId}`, { cantidad }, { withCredentials: true })
{ withCredentials: true }
)
.pipe( .pipe(
tap((response) => { tap((response) => {
this.cartState.set(response.data); this.cartState.set(response.data);
@@ -80,17 +80,16 @@ export class CartService {
catchError((error) => { catchError((error) => {
this.isUpdatingState.set(false); this.isUpdatingState.set(false);
throw error; throw error;
}) }),
); );
} }
removeItem(productVariantId: number): Observable<ApiResponse<Cart>> { removeItem(cartItemId: number): Observable<ApiResponse<Cart>> {
this.isUpdatingState.set(true); this.isUpdatingState.set(true);
return this.http return this.http
.delete<ApiResponse<Cart>>( .delete<
`${this.tenantApiUrl}/cart/items/${productVariantId}`, ApiResponse<Cart>
{ withCredentials: true } >(`${this.tenantApiUrl}/cart/items/${cartItemId}`, { withCredentials: true })
)
.pipe( .pipe(
tap((response) => { tap((response) => {
this.cartState.set(response.data); this.cartState.set(response.data);
@@ -99,7 +98,7 @@ export class CartService {
catchError((error) => { catchError((error) => {
this.isUpdatingState.set(false); this.isUpdatingState.set(false);
throw error; throw error;
}) }),
); );
} }
} }

View File

@@ -1,3 +1,5 @@
import { ApiPaginatedResponse } from '../api-paginated-response.interface';
export interface Product { export interface Product {
id: number; id: number;
category_id: number; category_id: number;
@@ -29,22 +31,61 @@ export interface ProductAttribute {
options: ProductAttributeOption[]; options: ProductAttributeOption[];
} }
export interface ProductVariant { export type InventoryPolicy = 'tracked' | 'unlimited';
export interface CatalogItemVariant {
id: number; id: number;
cantidad_maxima: number; stock_tecnico: number | null;
definitions: Record<string, string>; values: Record<string, string>;
}
export interface SelectedCatalogItemVariant extends CatalogItemVariant {
images: string[]; images: string[];
} }
export interface ProductVariantMap { export interface CatalogItemDetail {
variant_id: number; id: number;
cantidad_maxima: number; category_id: number | null;
attributes: Record<string, string>; brand_id: number | null;
} slug: string;
nombre: string;
export interface ProductDetail extends Product { descripcion: string | null;
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_map: ProductVariantMap[]; variants: CatalogItemVariant[];
variant: ProductVariant | null; selected_variant?: SelectedCatalogItemVariant;
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,16 +6,17 @@ 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 { Product, ProductDetail } from './catalog.interface'; import {
CatalogFeaturedGroup,
CatalogFeaturedItem,
CatalogItemDetail,
Product,
} from './catalog.interface';
type HttpParamValue = type HttpParamValue = string | number | boolean | readonly (string | number | boolean)[];
| 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);
@@ -25,24 +26,34 @@ 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),
});
}
getProductos( getCatalog(): Observable<CatalogFeaturedGroup[]> {
params?: ApiPaginationQueryParams return this.http.get<CatalogFeaturedGroup[]>(`${this.tenantApiUrl}/catalog`);
): Observable<ApiPaginatedResponse<Product[]>> { }
return this.http.get<ApiPaginatedResponse<Product[]>>(
`${this.tenantApiUrl}/productos`, getFeaturedGroupItems(
{ params: this.buildHttpParams(params) } featuredGroupId: number,
params?: ApiPaginationQueryParams,
): Observable<ApiPaginatedResponse<CatalogFeaturedItem[]>> {
return this.http.get<ApiPaginatedResponse<CatalogFeaturedItem[]>>(
`${this.tenantApiUrl}/catalog/featured-groups/${featuredGroupId}/items`,
{ params: this.buildHttpParams(params) },
); );
} }
getProducto(id: number, variantId?: number): Observable<ProductDetail> { getCatalogItem(id: number, variantId?: number): Observable<CatalogItemDetail> {
let params = new HttpParams(); 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<ProductDetail>>(`${this.tenantApiUrl}/productos/${id}`, { params }) .get<ApiResponse<CatalogItemDetail>>(`${this.tenantApiUrl}/catalog-items/${id}`, { params })
.pipe(map((response) => response.data)); .pipe(map((response) => response.data));
} }
@@ -59,7 +70,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 | null; value: string | number | boolean | null;
} }
export interface PurchaseDetailItemResponse { export interface PurchaseDetailItemResponse {
@@ -32,16 +32,15 @@ export interface PurchaseDetailItemResponse {
quantity: number; quantity: number;
unit_price: string; unit_price: string;
line_total: string; line_total: string;
product: { source_catalog_item_id: number | null;
id: number; source_variant_id: number | null;
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 {
@@ -52,10 +51,11 @@ 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' | 'cart' | null; items_source: 'purchase';
items: PurchaseDetailItemResponse[]; items: PurchaseDetailItemResponse[];
subtotal: string; subtotal: string;
total: string; total: string;
@@ -81,9 +81,13 @@ export class CheckoutService {
return purchase; return purchase;
} }
async generatePaymentIntent(tenantCode: string, purchaseId: number, method: 'qr' | 'transfer' | 'telepagos'): Promise<any> { async generatePaymentIntent(tenantCode: string, purchaseId: number, method: 'qr' | 'transfer' | 'telepagos', payerDni?: string): 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`, { method }) this.http.post<any>(`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/payment-intent`, payload)
); );
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,12 +23,9 @@ export class CookieService {
return null; return null;
} }
set(name: string, value: string, days = 7): void { set(name: string, value: string): void {
if (isPlatformBrowser(this.platformId)) { if (isPlatformBrowser(this.platformId)) {
const date = new Date(); this.document.cookie = `${name}=${value};path=/;SameSite=Lax`;
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,37 +1,23 @@
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 { import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
BrowserTestingModule, import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
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 { import { MODAL_DATA, ModalRef, ModalService } from './modal.service';
MODAL_DATA,
ModalRef,
ModalService
} from './modal.service';
@Component({ @Component({
template: '' template: '',
}) })
class FirstTestModalComponent {} class FirstTestModalComponent {}
@Component({ @Component({
template: '' template: '',
}) })
class SecondTestModalComponent {} class SecondTestModalComponent {}
@@ -40,10 +26,7 @@ describe('ModalService', () => {
beforeAll(() => { beforeAll(() => {
try { try {
getTestBed().initTestEnvironment( getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
BrowserTestingModule,
platformBrowserTesting()
);
} catch { } catch {
// Test environment may already be initialized by another setup entrypoint. // Test environment may already be initialized by another setup entrypoint.
} }
@@ -55,7 +38,7 @@ describe('ModalService', () => {
beforeEach(() => { beforeEach(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [ModalService] providers: [ModalService],
}); });
service = TestBed.inject(ModalService); service = TestBed.inject(ModalService);
@@ -66,7 +49,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();
@@ -80,7 +63,7 @@ describe('ModalService', () => {
size: 'lg', size: 'lg',
closeOnBackdrop: false, closeOnBackdrop: false,
closeOnEscape: true, closeOnEscape: true,
showCloseButton: true showCloseButton: true,
}); });
}); });
@@ -115,7 +98,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);
@@ -126,7 +109,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();
@@ -138,7 +121,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();
@@ -150,19 +133,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$);
@@ -175,7 +158,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$);
@@ -194,7 +177,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();
@@ -205,19 +188,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();
@@ -227,19 +210,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$);
@@ -248,10 +231,35 @@ 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,14 +2,11 @@ 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 = 'sm' | 'md' | 'lg' | 'xl' | 'full'; export type ModalSize = 'qr' | 'sm' | 'md' | 'lg' | 'xl' | 'full';
export type ModalDismissReason = export type ModalDismissReason = 'backdrop' | 'escape' | 'programmatic' | 'replaced';
| 'backdrop'
| 'escape'
| 'programmatic'
| 'replaced';
export interface ModalConfig<TData = unknown> { export interface ModalConfig<TData = unknown> {
title?: string; title?: string;
@@ -20,11 +17,10 @@ export interface ModalConfig<TData = unknown> {
showCloseButton?: boolean; showCloseButton?: boolean;
} }
export interface NormalizedModalConfig<TData = unknown> export interface NormalizedModalConfig<TData = unknown> extends Omit<
extends Omit< ModalConfig<TData>,
ModalConfig<TData>, 'size' | 'closeOnBackdrop' | 'closeOnEscape' | 'showCloseButton'
'size' | 'closeOnBackdrop' | 'closeOnEscape' | 'showCloseButton' > {
> {
size: ModalSize; size: ModalSize;
closeOnBackdrop: boolean; closeOnBackdrop: boolean;
closeOnEscape: boolean; closeOnEscape: boolean;
@@ -37,8 +33,7 @@ export interface ConfirmModalData {
cancelLabel: string; cancelLabel: string;
} }
export interface ConfirmModalConfig export interface ConfirmModalConfig extends Omit<ModalConfig<ConfirmModalData>, 'data'> {
extends Omit<ModalConfig<ConfirmModalData>, 'data'> {
content: string; content: string;
confirmLabel?: string; confirmLabel?: string;
cancelLabel?: string; cancelLabel?: string;
@@ -49,12 +44,24 @@ export interface SimpleModalData {
buttonLabel: string; buttonLabel: string;
} }
export interface SimpleModalConfig export interface SimpleModalConfig extends Omit<ModalConfig<SimpleModalData>, 'data'> {
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>;
@@ -70,25 +77,24 @@ 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> = readonly afterClosed$: Observable<TResult | undefined> = this.afterClosedSubject.asObservable();
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 {
@@ -120,7 +126,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);
@@ -128,35 +134,31 @@ 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( return this.openConfirmRef(config).afterClosed$.pipe(map((result) => result === true));
map((result) => result === true)
);
} }
openConfirmDelete(config: ConfirmModalConfig): Observable<boolean> { openConfirmDelete(config: ConfirmModalConfig): Observable<boolean> {
return this.openConfirmDeleteRef(config).afterClosed$.pipe( return this.openConfirmDeleteRef(config).afterClosed$.pipe(map((result) => result === true));
map((result) => result === true)
);
} }
openConfirmRef(config: ConfirmModalConfig): ModalRef<boolean> { openConfirmRef(config: ConfirmModalConfig): ModalRef<boolean> {
@@ -164,23 +166,30 @@ export class ModalService {
} }
openConfirmDeleteRef(config: ConfirmModalConfig): ModalRef<boolean> { openConfirmDeleteRef(config: ConfirmModalConfig): ModalRef<boolean> {
return this.open( return this.open(ConfirmDeleteModalComponent, this.buildConfirmModalConfig(config));
ConfirmDeleteModalComponent,
this.buildConfirmModalConfig(config)
);
} }
openSimple(config: SimpleModalConfig): Observable<void> { openSimple(config: SimpleModalConfig): Observable<void> {
return this.openSimpleRef(config).afterClosed$.pipe( return this.openSimpleRef(config).afterClosed$.pipe(map(() => undefined));
map(() => undefined)
);
} }
openSimpleRef(config: SimpleModalConfig): ModalRef<void> { openSimpleRef(config: SimpleModalConfig): ModalRef<void> {
return this.open( return this.open(SimpleModalComponent, this.buildSimpleModalConfig(config));
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 {
@@ -194,7 +203,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;
@@ -204,18 +213,14 @@ export class ModalService {
this.activeModalState.set(null); this.activeModalState.set(null);
} }
private normalizeConfig<TData>( private normalizeConfig<TData>(config: ModalConfig<TData>): NormalizedModalConfig<TData> {
config: ModalConfig<TData>
): NormalizedModalConfig<TData> {
return { return {
...DEFAULT_MODAL_CONFIG, ...DEFAULT_MODAL_CONFIG,
...config ...config,
}; };
} }
private buildConfirmModalConfig( private buildConfirmModalConfig(config: ConfirmModalConfig): ModalConfig<ConfirmModalData> {
config: ConfirmModalConfig
): ModalConfig<ConfirmModalData> {
const { const {
content, content,
confirmLabel = DEFAULT_CONFIRM_MODAL_LABELS.confirmLabel, confirmLabel = DEFAULT_CONFIRM_MODAL_LABELS.confirmLabel,
@@ -228,26 +233,20 @@ export class ModalService {
data: { data: {
content, content,
confirmLabel, confirmLabel,
cancelLabel cancelLabel,
} },
}; };
} }
private buildSimpleModalConfig( private buildSimpleModalConfig(config: SimpleModalConfig): ModalConfig<SimpleModalData> {
config: SimpleModalConfig const { content, buttonLabel = 'Entendido', ...modalConfig } = config;
): ModalConfig<SimpleModalData> {
const {
content,
buttonLabel = 'Entendido',
...modalConfig
} = config;
return { return {
...modalConfig, ...modalConfig,
data: { data: {
content, content,
buttonLabel buttonLabel,
} },
}; };
} }
} }

View File

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

View File

@@ -1,14 +1,13 @@
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 { import { CartComponent, CartItemMock } from '../../../../shared/components/cart/cart.component';
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 { ProductCardComponent } from '../../../../shared/components/product-card/product-card.component'; import { ProductColumnWithImageComponent } from '../../../../shared/components/product-column-with-image/product-column-with-image.component';
import { ProductRowCardComponent } from '../../../../shared/components/product-row-card/product-row-card.component';
import { ProductVerticalWithCartCardComponent } from '../../../../shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component';
import { StoreSectionComponent } from '../../../../shared/components/store-section/store-section.component'; import { 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';
@@ -23,15 +22,17 @@ import { StepComponent } from '../../../../shared/components/stepper/step.compon
CartComponent, CartComponent,
InputComponent, InputComponent,
PaginatorComponent, PaginatorComponent,
ProductCardComponent, ProductColumnWithImageComponent,
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);
@@ -68,24 +69,41 @@ 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,
@@ -96,8 +114,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,
@@ -108,8 +126,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,
@@ -120,8 +138,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,
@@ -132,9 +150,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 {
@@ -162,45 +180,32 @@ 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( this.toastService.success('Operacion exitosa. El producto se ha guardado correctamente.');
'Operacion exitosa. El producto se ha guardado correctamente.'
);
} else if (type === 'danger') { } else if (type === 'danger') {
this.toastService.danger( this.toastService.danger('Alerta de error. Hubo un problema al procesar la solicitud.');
'Alerta de error. Hubo un problema al procesar la solicitud.'
);
} else if (type === 'info') { } else if (type === 'info') {
this.toastService.info( this.toastService.info('Informacion del sistema: Hay una nueva actualizacion disponible.');
'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( this.toastService.success('Exito persistente. Esta alerta no se cerrara sola.', 0);
'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( this.toastService.info('Informacion persistente: Mantendremos este aviso en pantalla.', 0);
'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: content: 'Caso base para verificar apertura, cierre y devolucion de resultado.',
'Caso base para verificar apertura, cierre y devolucion de resultado.', confirmLabel: 'Confirmar',
confirmLabel: 'Confirmar'
}); });
} }
@@ -209,18 +214,17 @@ 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: content: 'Este modal no se cierra tocando el backdrop ni con la tecla Escape.',
'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,
}); });
} }
@@ -229,18 +233,20 @@ 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.openSimple({ this.modalService
title: 'Mensaje del sistema', .openSimple({
content: 'Este es un mensaje simple del sistema que no requiere confirmación.', title: 'Mensaje del sistema',
buttonLabel: 'Entendido' content: 'Este es un mensaje simple del sistema que no requiere confirmación.',
}).subscribe(() => { buttonLabel: 'Entendido',
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 {
@@ -249,13 +255,9 @@ export class ReutilizablesTestPageComponent {
}); });
} }
private openConfirmDelete( private openConfirmDelete(config: Parameters<ModalService['openConfirmDelete']>[0]): void {
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

@@ -0,0 +1,71 @@
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,20 +22,21 @@ 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 variantsMap = input<ProductVariantMap[]>([]); public variants = input<CatalogItemVariant[]>([]);
public defaultVariant = input<ProductVariant | null>(null); public selectedVariant = input<CatalogItemVariant | null>(null);
public inventoryPolicy = input.required<InventoryPolicy>();
public variantChange = output<ProductVariantMap | null>(); public variantChange = output<CatalogItemVariant | null>();
protected readonly selectedAttributeOptions = signal<Record<number, number>>({}); protected readonly selectedAttributeOptions = signal<Record<number, number>>({});
protected readonly availableOptions = computed(() => { protected readonly availableOptions = computed(() => {
const selections = this.selectedAttributeOptions(); const selections = this.selectedAttributeOptions();
const variants = this.variantsMap(); const variants = this.variants();
const attributes = this.attributes(); const attributes = this.attributes();
const availability: Record<number, Record<number, boolean>> = {}; const availability: Record<number, Record<number, boolean>> = {};
@@ -44,28 +45,30 @@ 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 variantAttrValue = this.getDefaultVariantAttributeValue(attribute, variant.attributes); const isAvailable = variants.some((variant) => {
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(selectedOption.value || selectedOption.label); const selectedNormalized = this.normalizeText(
const vAttrValue = this.getDefaultVariantAttributeValue(otherAttr, variant.attributes); selectedOption.value || selectedOption.label,
if (!vAttrValue || this.normalizeText(vAttrValue) !== selectedNormalized) { );
return false; const vAttrValue = this.getDefaultVariantAttributeValue(otherAttr, variant.values);
} if (!vAttrValue || this.normalizeText(vAttrValue) !== selectedNormalized) {
return false;
} }
} }
}
} }
return true; return true;
}); });
@@ -78,14 +81,14 @@ export class ProductAttributeSelectorComponent {
}); });
public reset(): void { public reset(): void {
this.initializeSelections(this.attributes(), this.defaultVariant()); this.initializeSelections(this.attributes(), this.selectedVariant());
} }
constructor() { constructor() {
effect(() => { effect(() => {
const attributes = this.attributes(); const attributes = this.attributes();
const defaultVariant = this.defaultVariant(); const defaultVariant = this.selectedVariant();
untracked(() => { untracked(() => {
this.initializeSelections(attributes, defaultVariant); this.initializeSelections(attributes, defaultVariant);
}); });
@@ -93,26 +96,29 @@ export class ProductAttributeSelectorComponent {
effect(() => { effect(() => {
const selections = this.selectedAttributeOptions(); const selections = this.selectedAttributeOptions();
const variantsMap = this.variantsMap(); const variants = this.variants();
const attributes = this.attributes(); const attributes = this.attributes();
untracked(() => { untracked(() => {
this.emitMatchingVariant(selections, variantsMap, attributes); this.emitMatchingVariant(selections, variants, attributes);
}); });
}); });
} }
protected hasSelectedOption(attribute: ProductAttribute, option: ProductAttributeOption): boolean { protected hasSelectedOption(
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,
})); }));
} }
@@ -124,7 +130,10 @@ export class ProductAttributeSelectorComponent {
return this.findFirstHexValue(option.metadata) ?? '#D9D9D9'; return this.findFirstHexValue(option.metadata) ?? '#D9D9D9';
} }
private initializeSelections(attributes: ProductAttribute[], defaultVariant: ProductVariant | null): void { private initializeSelections(
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) {
@@ -139,20 +148,20 @@ export class ProductAttributeSelectorComponent {
private findDefaultOptionId( private findDefaultOptionId(
attribute: ProductAttribute, attribute: ProductAttribute,
variant: ProductVariant | null variant: CatalogItemVariant | null,
): number | null { ): number | null {
if (!variant) { if (!variant) {
return null; return null;
} }
const defaultValue = this.getDefaultVariantAttributeValue(attribute, variant.definitions); const defaultValue = this.getDefaultVariantAttributeValue(attribute, variant.values);
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) {
@@ -160,7 +169,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;
@@ -168,7 +177,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);
@@ -192,6 +201,10 @@ 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/);
@@ -222,10 +235,10 @@ export class ProductAttributeSelectorComponent {
private emitMatchingVariant( private emitMatchingVariant(
selections: Record<number, number>, selections: Record<number, number>,
variantsMap: ProductVariantMap[], variants: CatalogItemVariant[],
attributes: ProductAttribute[] attributes: ProductAttribute[],
): void { ): void {
if (attributes.length === 0 || variantsMap.length === 0) { if (attributes.length === 0 || variants.length === 0) {
this.variantChange.emit(null); this.variantChange.emit(null);
return; return;
} }
@@ -235,12 +248,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);
} }
} }
@@ -249,12 +262,12 @@ export class ProductAttributeSelectorComponent {
return; return;
} }
const matchingVariant = variantsMap.find(vMap => { const matchingVariant = variants.find((variant) => {
return attributes.every(attr => { return attributes.every((attr) => {
const selectedValue = selectedValuesById[attr.id]; const selectedValue = selectedValuesById[attr.id];
const vMapValue = this.getDefaultVariantAttributeValue(attr, vMap.attributes); const variantValue = this.getDefaultVariantAttributeValue(attr, variant.values);
if (!vMapValue) return false; if (!variantValue) return false;
return this.normalizeText(vMapValue) === selectedValue; return this.normalizeText(variantValue) === selectedValue;
}); });
}); });

View File

@@ -1,8 +1,11 @@
<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">Datos Personales</a> <a routerLink="/mi-cuenta/datos-personales" routerLinkActive="active" class="nav-link"
<a routerLink="/mi-cuenta/compras" routerLinkActive="active" class="nav-link">Mis compras</a> >Datos Personales</a
</nav> >
</div> <a routerLink="/mi-cuenta/compras" routerLinkActive="active" class="nav-link">Mis compras</a>
<a routerLink="/mi-cuenta/tickets" routerLinkActive="active" class="nav-link">Mis tickets</a>
</nav>
</div>

View File

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

View File

@@ -13,6 +13,10 @@
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 {
@@ -44,12 +48,6 @@
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;
} }
@@ -116,3 +114,16 @@
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

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

View File

@@ -3,6 +3,7 @@ import { Component, Input } from '@angular/core';
export type PurchaseItemViewModel = { 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,7 +2,10 @@ 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 { CheckoutService, PurchaseDetailResponse } from '../../../../../../core/services/checkout.service'; import {
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';
@@ -79,12 +82,13 @@ 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.product?.nombre ?? 'Producto sin nombre', title: item.item_details.nombre || 'Producto sin nombre',
imageUrl: item.product?.imagen ?? null, description: item.item_details.descripcion,
imageUrl: item.item_details.imagen,
quantity: item.quantity, quantity: item.quantity,
attributes: (item.variant?.attributes ?? []).map((attribute) => ({ attributes: item.item_details.attributes.map((attribute) => ({
label: attribute.name, label: attribute.name,
value: attribute.value, value: attribute.value === null ? null : String(attribute.value),
})), })),
price: item.line_total, price: item.line_total,
originalPrice: null, originalPrice: null,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,12 +16,18 @@
[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

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

View File

@@ -1,4 +1,15 @@
import { ChangeDetectionStrategy, Component, computed, effect, inject, OnInit, signal, untracked, ViewChild } from '@angular/core'; import {
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';
@@ -14,7 +25,15 @@ 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 { CheckoutForm, PaymentMethod, PaymentMethodOption, TransferAccount, TransferField } from './checkout-page.models'; import {
CheckoutForm,
PaymentMethod,
PaymentMethodOption,
QrPaymentStatus,
TransferAccount,
TransferField,
TransferValidationStatus,
} from './checkout-page.models';
@Component({ @Component({
selector: 'app-checkout-page', selector: 'app-checkout-page',
@@ -24,19 +43,27 @@ import { CheckoutForm, PaymentMethod, PaymentMethodOption, TransferAccount, Tran
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 { export class CheckoutPageComponent implements OnInit, OnDestroy {
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 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;
@@ -44,7 +71,7 @@ export class CheckoutPageComponent implements OnInit {
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(() => {
@@ -66,32 +93,33 @@ export class CheckoutPageComponent implements OnInit {
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>({ protected readonly transferAccount = signal<TransferAccount | null>(null);
titular: 'Nombre y Apellido', protected readonly transferDni = signal<string>('');
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(() => this.isGeneratingIntent() || this.cartService.isUpdating()); protected readonly isPaymentLoading = computed(
() => 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) { if (cart && purchaseId && !this.navigationStarted) {
// 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());
} }
@@ -108,7 +136,7 @@ export class CheckoutPageComponent implements OnInit {
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();
@@ -119,6 +147,10 @@ export class CheckoutPageComponent implements OnInit {
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;
@@ -138,14 +170,14 @@ export class CheckoutPageComponent implements OnInit {
} }
return { return {
productVariantId: item.product_variant_id, cartItemId: item.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,
}; };
} }
@@ -174,12 +206,11 @@ export class CheckoutPageComponent implements OnInit {
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
@@ -189,11 +220,24 @@ export class CheckoutPageComponent implements OnInit {
} }
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();
@@ -202,23 +246,64 @@ export class CheckoutPageComponent implements OnInit {
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(tenant.codigo, purchaseId, method); const response = await this.checkoutService.generatePaymentIntent(
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);
} else if (method === 'transfer' && response.transfer_data) { this.startQrPolling();
}
} 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 payment intent:', error); console.error('Failed to generate transfer payment intent:', error);
} finally { } finally {
this.isGeneratingIntent.set(false); this.isGeneratingIntent.set(false);
} }
@@ -245,16 +330,140 @@ export class CheckoutPageComponent implements OnInit {
const purchaseId = this.createdPurchaseId(); const purchaseId = this.createdPurchaseId();
const tenant = this.tenantService.tenant(); const tenant = this.tenantService.tenant();
if (!purchaseId || !tenant) { if (
!purchaseId ||
!tenant ||
this.navigationStarted ||
this.transferValidationStatus() === 'checking'
) {
return; return;
} }
this.transferValidationStatus.set('checking');
try { try {
await this.checkoutService.completePurchase(tenant.codigo, purchaseId); const purchase = await this.checkoutService.getPurchase(tenant.codigo, purchaseId);
this.cartService.clearCart();
void this.router.navigate(['/checkout/status', 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) { } catch (error) {
console.error('Failed to complete purchase:', error); console.error('Failed to validate transfer payment:', error);
this.transferValidationStatus.set('error');
} }
} }
protected retryQrPolling(): void {
if (this.selectedPaymentMethod() === 'qr' && this.qrData()) {
this.startQrPolling();
}
}
private startQrPolling(): void {
this.stopQrPolling();
this.qrPollingAttempts = 0;
this.qrPaymentStatus.set('waiting');
const runId = this.qrPollingRunId;
this.scheduleQrPoll(runId);
}
private scheduleQrPoll(runId: number): void {
this.qrPollingTimeoutId = setTimeout(() => {
this.qrPollingTimeoutId = null;
void this.checkQrPayment(runId);
}, this.qrPollingIntervalMs);
}
private async checkQrPayment(runId: number): Promise<void> {
if (runId !== this.qrPollingRunId) {
return;
}
const purchaseId = this.createdPurchaseId();
const tenant = this.tenantService.tenant();
if (!purchaseId || !tenant || this.selectedPaymentMethod() !== 'qr') {
this.stopQrPolling();
return;
}
this.qrPollingAttempts += 1;
this.isCheckingQrPayment.set(true);
try {
const purchase = await this.checkoutService.getPurchase(tenant.codigo, purchaseId);
if (runId !== this.qrPollingRunId) {
return;
}
if (purchase.status === 'paid') {
this.handleConfirmedPayment(purchaseId);
return;
}
if (purchase.status === 'rejected' || purchase.status === 'cancelled') {
this.stopQrPolling();
this.qrPaymentStatus.set('failed');
return;
}
} catch (error) {
console.error('Failed to validate QR payment:', error);
} finally {
if (runId === this.qrPollingRunId) {
this.isCheckingQrPayment.set(false);
}
}
if (runId !== this.qrPollingRunId) {
return;
}
if (this.qrPollingAttempts >= this.qrPollingMaxAttempts) {
this.stopQrPolling();
this.qrPaymentStatus.set('timed_out');
return;
}
this.scheduleQrPoll(runId);
}
private stopQrPolling(): void {
this.qrPollingRunId += 1;
this.isCheckingQrPayment.set(false);
if (this.qrPollingTimeoutId !== null) {
clearTimeout(this.qrPollingTimeoutId);
this.qrPollingTimeoutId = null;
}
}
private handleConfirmedPayment(purchaseId: number): void {
this.navigateToPurchaseStatus(purchaseId, true);
}
private navigateToPurchaseStatus(purchaseId: number, clearCart = false): void {
if (this.navigationStarted) {
return;
}
this.navigationStarted = true;
this.stopQrPolling();
if (clearCart) {
this.cartService.clearCart();
}
void this.router.navigate(['/checkout/status', purchaseId]);
}
} }

View File

@@ -2,6 +2,8 @@ import { FormControl, FormGroup } from '@angular/forms';
export type PaymentMethod = 'qr' | 'transfer' | 'telepagos'; export type 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,114 +40,26 @@
<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') {
<div class="payment-panel__card"> <app-checkout-payment-qr
<h3 class="payment-panel__title">Ingresa a tu billetera y escanea el siguiente QR</h3> [qrData]="qrData()"
<div class="payment-panel__divider"></div> [paymentStatus]="qrPaymentStatus()"
[isCheckingPayment]="isCheckingQrPayment()"
<div class="qr-code" aria-label="QR de pago"> (retryPolling)="retryQrPolling.emit()"
@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') {
<div class="payment-panel__card"> <app-checkout-payment-transfer
<h3 class="payment-panel__title">Transferi a la siguiente cuenta desde cualquier billetera</h3> [transferAccount]="transferAccount()"
<div class="payment-panel__divider"></div> [transferDni]="transferDni()"
[copiedTransferField]="copiedTransferField()"
<div class="payment-panel__account"> [validationStatus]="transferValidationStatus()"
<p class="payment-panel__eyebrow">DATOS DE CUENTA:</p> (copyTransferValue)="requestCopy($event.field, $event.value)"
(submitDni)="generateTransferIntent.emit($event)"
<div class="payment-detail"> (completePurchase)="complete.emit()"
<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 {
<div class="payment-panel__card"> <app-checkout-payment-telepagos />
<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 290px; flex: 1 1 320px;
max-width: 290px; max-width: 320px;
display: block; display: block;
} }
} }
@@ -112,7 +112,7 @@
min-width: 0; min-width: 0;
&__card { &__card {
width: 100%; width: 100%;
max-width: 290px; max-width: 320px;
min-height: 269px; min-height: 269px;
padding: 1.5rem 1.75rem; padding: 1.5rem 1.75rem;
border-radius: 5px; border-radius: 5px;
@@ -134,173 +134,5 @@
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,14 +1,21 @@
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
import { ButtonComponent } from '../../../../shared/components/button/button.component'; import { CheckoutPaymentQrComponent } from './components/checkout-payment-qr/checkout-payment-qr.component';
import { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.component'; import { CheckoutPaymentTelepagosComponent } from './components/checkout-payment-telepagos/checkout-payment-telepagos.component';
import { QRCodeComponent } from '../../../../shared/components/qrcode/qrcode.component'; import { CheckoutPaymentTransferComponent } from './components/checkout-payment-transfer/checkout-payment-transfer.component';
import { PaymentMethod, PaymentMethodOption, TransferAccount, TransferField } from './checkout-page.models'; import {
PaymentMethod,
PaymentMethodOption,
QrPaymentStatus,
TransferAccount,
TransferField,
TransferValidationStatus,
} from './checkout-page.models';
@Component({ @Component({
selector: 'app-checkout-payment-step', selector: 'app-checkout-payment-step',
standalone: true, standalone: true,
imports: [ButtonComponent, IconButtonComponent, QRCodeComponent], imports: [CheckoutPaymentQrComponent, CheckoutPaymentTelepagosComponent, CheckoutPaymentTransferComponent],
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
@@ -17,14 +24,20 @@ 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.required<TransferAccount>(); readonly transferAccount = input<TransferAccount | null>(null);
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

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

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

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

View File

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

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

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

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

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

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

@@ -0,0 +1,55 @@
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,7 +81,8 @@
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]="true" [disabled]="isSubmitting()"
(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 } from '@angular/common'; import { DOCUMENT, isPlatformBrowser } from '@angular/common';
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; import { ChangeDetectionStrategy, Component, inject, PLATFORM_ID, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { Router } from '@angular/router'; import { ActivatedRoute, 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,8 +20,10 @@ 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);
@@ -35,6 +37,18 @@ 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']);
} }
@@ -62,6 +76,16 @@ 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));
} }
@@ -107,6 +131,22 @@ 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,118 +1,120 @@
<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>
</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>
}
<div class="product-detail__divider"></div>
<section class="product-detail__section">
<div class="product-detail__purchase">
<app-quantity-selector
[(quantity)]="quantity"
[max]="selectedVariant()?.cantidad_maxima ?? 1"
/>
<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>
<app-button class="product-detail__cta" type="button" [disabled]="!selectedVariant() || variantLoading()">
@if (variantLoading()) {
<div class="spinner-border spinner-border-sm" role="status"></div>
} @else {
Comprar
}
</app-button>
</div>
</div>
</section>
<div class="product-detail__divider"></div>
<section class="product-detail__section product-detail__section--description">
<h2 class="product-detail__description-heading">DESCRIPCIÓN</h2>
<div
#descriptionBody
class="product-detail__description-body"
[class.product-detail__description-body--expanded]="descriptionExpanded()"
[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' }}
</button>
} }
</div>
</section>
@if (hasRenderableAttributes()) {
<div class="product-detail__divider"></div>
<section class="product-detail__section product-detail__section--attributes">
<app-product-attribute-selector
[attributes]="renderableAttributes()"
[variants]="prod.variants"
[selectedVariant]="prod.selected_variant ?? null"
[inventoryPolicy]="prod.inventory_policy"
(variantChange)="onVariantChange($event)"
/>
</section> </section>
</div> }
<div class="product-detail__divider"></div>
<section class="product-detail__section">
<div class="product-detail__purchase">
<app-quantity-selector [(quantity)]="quantity" [max]="selectedVariantMax()" />
<div class="product-detail__actions">
<app-button
class="product-detail__cta"
variant="secondary"
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>
</section>
<div class="product-detail__divider"></div>
<section class="product-detail__section product-detail__section--description">
<h2 class="product-detail__description-heading">DESCRIPCIÓN</h2>
<div
#descriptionBody
class="product-detail__description-body"
[class.product-detail__description-body--expanded]="descriptionExpanded()"
[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' }}
</button>
}
</section>
</div> </div>
</div> </div>
} @else { </div>
<div class="alert alert-warning text-center mb-0" role="alert"> } @else {
No se encontró el producto especificado. <div class="alert alert-warning text-center mb-0" role="alert">
</div> No se encontró el producto especificado.
} </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 { ProductDetail } from '../../../../core/services/catalog/catalog.interface'; import { CatalogItemDetail } 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: ProductDetail = { const mockProduct: CatalogItemDetail = {
id: 1, id: 1,
category_id: 10, category_id: 10,
brand_id: null, brand_id: null,
@@ -27,9 +27,13 @@ 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_map: [], variants: [],
variant: null,
}; };
let routeDataSubject: BehaviorSubject<{ productDetailData: ProductDetailResolvedData }>; let routeDataSubject: BehaviorSubject<{ productDetailData: ProductDetailResolvedData }>;
@@ -55,7 +59,7 @@ describe('ProductDetailPageComponent', () => {
}, },
}); });
catalogServiceStub = { catalogServiceStub = {
getProducto: vi.fn(), getCatalogItem: vi.fn(),
}; };
routerStub = { routerStub = {
navigate: vi.fn(), navigate: vi.fn(),
@@ -100,7 +104,7 @@ describe('ProductDetailPageComponent', () => {
}).compileComponents(); }).compileComponents();
} }
function resolveProduct(product: ProductDetail): void { function resolveProduct(product: CatalogItemDetail): void {
routeDataSubject.next({ routeDataSubject.next({
productDetailData: { productDetailData: {
product, product,
@@ -125,7 +129,7 @@ describe('ProductDetailPageComponent', () => {
const element = fixture.nativeElement as HTMLElement; const element = fixture.nativeElement as HTMLElement;
expect(catalogServiceStub.getProducto).not.toHaveBeenCalled(); expect(catalogServiceStub.getCatalogItem).not.toHaveBeenCalled();
expect(element.querySelector('app-product-carousel')).not.toBeNull(); expect(element.querySelector('app-product-carousel')).not.toBeNull();
expect(element.querySelector('.product-detail__title')?.textContent).toContain( expect(element.querySelector('.product-detail__title')?.textContent).toContain(
@@ -150,14 +154,15 @@ describe('ProductDetailPageComponent', () => {
}); });
it('should use default variant images if present', async () => { it('should use default variant images if present', async () => {
const detailProduct: ProductDetail = { const detailProduct: CatalogItemDetail = {
...mockProduct, ...mockProduct,
images: ['https://example.com/product.png'], images: ['https://example.com/product.png'],
variant: { variants: [{ id: 123, stock_tecnico: 10, values: {} }],
selected_variant: {
id: 123, id: 123,
cantidad_maxima: 10, stock_tecnico: 10,
images: ['https://example.com/variant1.png', 'https://example.com/variant2.png'], images: ['https://example.com/variant1.png', 'https://example.com/variant2.png'],
definitions: {}, values: {},
}, },
}; };
resolveProduct(detailProduct); resolveProduct(detailProduct);
@@ -174,10 +179,9 @@ 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: ProductDetail = { const detailProduct: CatalogItemDetail = {
...mockProduct, ...mockProduct,
images: ['https://example.com/product.png'], images: ['https://example.com/product.png'],
variant: null,
}; };
resolveProduct(detailProduct); resolveProduct(detailProduct);
@@ -190,10 +194,9 @@ 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: ProductDetail = { const detailProduct: CatalogItemDetail = {
...mockProduct, ...mockProduct,
images: [], images: [],
variant: null,
}; };
resolveProduct(detailProduct); resolveProduct(detailProduct);
@@ -206,7 +209,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: ProductDetail = { const detailProduct: CatalogItemDetail = {
...mockProduct, ...mockProduct,
attributes: [ attributes: [
{ {
@@ -258,11 +261,12 @@ describe('ProductDetailPageComponent', () => {
], ],
}, },
], ],
variant: { variants: [{ id: 123, stock_tecnico: 10, values: { color: 'beige', material: 'Cuero' } }],
selected_variant: {
id: 123, id: 123,
cantidad_maxima: 10, stock_tecnico: 10,
images: ['https://example.com/variant1.png'], images: ['https://example.com/variant1.png'],
definitions: { values: {
color: 'beige', color: 'beige',
material: 'Cuero', material: 'Cuero',
}, },
@@ -308,9 +312,9 @@ describe('ProductDetailPageComponent', () => {
fixture.detectChanges(); fixture.detectChanges();
fixture.componentInstance['selectedVariant'].set({ fixture.componentInstance['selectedVariant'].set({
variant_id: 1, id: 1,
cantidad_maxima: 10, stock_tecnico: 10,
attributes: {}, values: {},
}); });
fixture.detectChanges(); fixture.detectChanges();
@@ -381,21 +385,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: ProductDetail = { const detailProduct: CatalogItemDetail = {
...mockProduct, ...mockProduct,
variant: { variants: [
id: 123,
cantidad_maxima: 5,
images: [],
definitions: {},
},
variants_map: [
{ {
variant_id: 123, id: 123,
cantidad_maxima: 5, stock_tecnico: 5,
attributes: {}, values: {},
}, },
], ],
selected_variant: {
id: 123,
stock_tecnico: 5,
images: [],
values: {},
},
}; };
resolveProduct(detailProduct); resolveProduct(detailProduct);
@@ -404,7 +408,7 @@ describe('ProductDetailPageComponent', () => {
fixture.detectChanges(); fixture.detectChanges();
// Select the variant // Select the variant
fixture.componentInstance['selectedVariant'].set(detailProduct.variants_map[0]); fixture.componentInstance['selectedVariant'].set(detailProduct.variants[0]);
fixture.componentInstance['quantity'].set(3); fixture.componentInstance['quantity'].set(3);
fixture.detectChanges(); fixture.detectChanges();
@@ -417,26 +421,50 @@ describe('ProductDetailPageComponent', () => {
addToCartButton.click(); addToCartButton.click();
fixture.detectChanges(); fixture.detectChanges();
expect(cartServiceStub.addItem).toHaveBeenCalledWith(123, 3); expect(cartServiceStub.addItem).toHaveBeenCalledWith(1, 123, 3);
expect(toastServiceStub.success).toHaveBeenCalledWith('Producto agregado al carrito'); expect(toastServiceStub.success).toHaveBeenCalledWith('Producto agregado al carrito');
}); });
it('shows error toast when CartService.addItem fails', async () => { it('adds a catalog item without variants to the cart', async () => {
const detailProduct: ProductDetail = { resolveProduct({
...mockProduct, ...mockProduct,
variant: { variants: [],
id: 123, stock_tecnico: 4,
cantidad_maxima: 5, });
images: [],
definitions: {}, await configureTestingModule();
}, const fixture = TestBed.createComponent(ProductDetailPageComponent);
variants_map: [ fixture.detectChanges();
const buttons = Array.from(
fixture.nativeElement.querySelectorAll('app-button button'),
) as HTMLButtonElement[];
const addToCartButton = buttons.find(
(button) => button.textContent?.trim() === 'Agregar al carrito',
) as HTMLButtonElement;
addToCartButton.click();
fixture.detectChanges();
expect(cartServiceStub.addItem).toHaveBeenCalledWith(1, null, 1);
});
it('shows error toast when CartService.addItem fails', async () => {
const detailProduct: CatalogItemDetail = {
...mockProduct,
variants: [
{ {
variant_id: 123, id: 123,
cantidad_maxima: 5, stock_tecnico: 5,
attributes: {}, values: {},
}, },
], ],
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')));
@@ -445,7 +473,7 @@ describe('ProductDetailPageComponent', () => {
const fixture = TestBed.createComponent(ProductDetailPageComponent); const fixture = TestBed.createComponent(ProductDetailPageComponent);
fixture.detectChanges(); fixture.detectChanges();
fixture.componentInstance['selectedVariant'].set(detailProduct.variants_map[0]); fixture.componentInstance['selectedVariant'].set(detailProduct.variants[0]);
fixture.detectChanges(); fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement; const element = fixture.nativeElement as HTMLElement;
@@ -461,4 +489,64 @@ 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 {
ProductDetail, CatalogItemDetail,
ProductVariantMap, CatalogItemVariant,
} 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<ProductDetail | null>(null); protected readonly product = signal<CatalogItemDetail | 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.variant?.images && prod.variant.images.length > 0) { if (prod.selected_variant?.images && prod.selected_variant.images.length > 0) {
return prod.variant.images; return prod.selected_variant.images;
} }
if (prod.images && prod.images.length > 0) { if (prod.images && prod.images.length > 0) {
return prod.images; return prod.images;
@@ -78,8 +78,25 @@ 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<ProductVariantMap | null>(null); protected readonly selectedVariant = signal<CatalogItemVariant | 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);
@@ -131,7 +148,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.getProducto(productId, variantId).subscribe({ this.productSub = this.catalogService.getCatalogItem(productId, variantId).subscribe({
next: (prod) => { next: (prod) => {
this.applyProduct(prod, false); this.applyProduct(prod, false);
this.variantLoading.set(false); this.variantLoading.set(false);
@@ -143,15 +160,9 @@ 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_map: updatedVariantsMap, variants: currentProduct.variants.filter((variant) => variant.id !== variantId),
}; };
}); });
@@ -176,10 +187,10 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
} }
} }
private applyProduct(prod: ProductDetail, resetQuantity: boolean): void { private applyProduct(prod: CatalogItemDetail, resetQuantity: boolean): void {
this.product.set(prod); this.product.set(prod);
const matchingVariant = const matchingVariant =
prod.variants_map.find((v) => v.variant_id === prod.variant?.id) || null; prod.variants.find((variant) => variant.id === prod.selected_variant?.id) || null;
this.selectedVariant.set(matchingVariant); this.selectedVariant.set(matchingVariant);
if (resetQuantity) { if (resetQuantity) {
this.quantity.set(1); this.quantity.set(1);
@@ -203,31 +214,32 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
return `$${parts.join(',')}`; return `$${parts.join(',')}`;
} }
protected onVariantChange(variant: ProductVariantMap | null): void { protected onVariantChange(variant: CatalogItemVariant | null): void {
if (this.selectedVariant()?.variant_id === variant?.variant_id) { if (this.selectedVariant()?.id === variant?.id) {
return; return;
} }
this.selectedVariant.set(variant); this.selectedVariant.set(variant);
if (variant && this.quantity() > variant.cantidad_maxima) { if (variant && variant.stock_tecnico !== null && this.quantity() > variant.stock_tecnico) {
this.quantity.set(Math.max(1, variant.cantidad_maxima)); this.quantity.set(Math.max(1, variant.stock_tecnico));
} }
const currentProduct = this.product(); const currentProduct = this.product();
if (variant && currentProduct && currentProduct.variant?.id !== variant.variant_id) { if (variant && currentProduct && currentProduct.selected_variant?.id !== variant.id) {
this.loadProductVariant(currentProduct.id, variant.variant_id); this.loadProductVariant(currentProduct.id, variant.id);
} }
} }
protected addToCart(): void { protected addToCart(): void {
const currentProduct = this.product();
const variant = this.selectedVariant(); const variant = this.selectedVariant();
if (!variant) { if (!currentProduct || !this.selectedVariantAvailable()) {
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(variant.variant_id, this.quantity()).subscribe({ this.cartService.addItem(currentProduct.id, variant?.id ?? null, 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);
@@ -241,6 +253,10 @@ 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 { ProductDetail } from '../../../../core/services/catalog/catalog.interface'; import { CatalogItemDetail } 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: ProductDetail = { const product: CatalogItemDetail = {
id: 1, id: 1,
category_id: 10, category_id: 10,
brand_id: null, brand_id: null,
@@ -24,16 +24,20 @@ 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_map: [], variants: [],
variant: null,
}; };
let catalogServiceStub: { getProducto: ReturnType<typeof vi.fn> }; let catalogServiceStub: { getCatalogItem: ReturnType<typeof vi.fn> };
beforeEach(() => { beforeEach(() => {
catalogServiceStub = { catalogServiceStub = {
getProducto: vi.fn().mockReturnValue(of(product)), getCatalogItem: vi.fn().mockReturnValue(of(product)),
}; };
TestBed.configureTestingModule({ TestBed.configureTestingModule({
@@ -55,7 +59,7 @@ describe('productDetailResolver', () => {
product, product,
error: null, error: null,
}); });
expect(catalogServiceStub.getProducto).toHaveBeenCalledWith(1); expect(catalogServiceStub.getCatalogItem).toHaveBeenCalledWith(1);
}); });
it('returns an error state for invalid ids', async () => { it('returns an error state for invalid ids', async () => {
@@ -67,11 +71,11 @@ describe('productDetailResolver', () => {
product: null, product: null,
error: PRODUCT_DETAIL_INVALID_ID_MESSAGE, error: PRODUCT_DETAIL_INVALID_ID_MESSAGE,
}); });
expect(catalogServiceStub.getProducto).not.toHaveBeenCalled(); expect(catalogServiceStub.getCatalogItem).not.toHaveBeenCalled();
}); });
it('returns an error state when the request fails', async () => { it('returns an error state when the request fails', async () => {
catalogServiceStub.getProducto.mockReturnValue(throwError(() => new Error('boom'))); catalogServiceStub.getCatalogItem.mockReturnValue(throwError(() => new Error('boom')));
const result = TestBed.runInInjectionContext(() => 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 { ProductDetail } from '../../../../core/services/catalog/catalog.interface'; import { CatalogItemDetail } 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: ProductDetail | null; product: CatalogItemDetail | null;
error: string | null; error: string | null;
} }
@@ -26,7 +26,7 @@ export const productDetailResolver: ResolveFn<ProductDetailResolvedData> = (
} }
return inject(CatalogService) return inject(CatalogService)
.getProducto(productId) .getCatalogItem(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: 'secret123', password: 'Secret!123',
password_confirmation: 'secret123' password_confirmation: 'Secret!123'
}); });
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: 'secret123', password: 'Secret!123',
password_confirmation: 'secret123' password_confirmation: 'Secret!123'
}); });
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: 'secret123', password: 'Secret!123',
password_confirmation: 'secret123' password_confirmation: 'Secret!123'
}); });
component.onSubmit(); component.onSubmit();

View File

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

View File

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

View File

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

View File

@@ -2,23 +2,22 @@ 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 { ApiPaginatedResponse } from '../../../../core/services/api-paginated-response.interface'; import { CatalogFeaturedGroup } from '../../../../core/services/catalog/catalog.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 StoreHomeProductsResolvedData { export interface StoreHomeCatalogResolvedData {
response: ApiPaginatedResponse<Product[]> | null; response: CatalogFeaturedGroup[] | null;
error: string | null; error: string | null;
} }
export const storeHomeProductsResolver: ResolveFn<StoreHomeProductsResolvedData> = () => { export const storeHomeCatalogResolver: ResolveFn<StoreHomeCatalogResolvedData> = () => {
return inject(CatalogService) return inject(CatalogService)
.getProductos({ page: 1 }) .getCatalog()
.pipe( .pipe(
map( map(
(response): StoreHomeProductsResolvedData => ({ (response): StoreHomeCatalogResolvedData => ({
response, response,
error: null, error: null,
}), }),

View File

@@ -4,10 +4,11 @@ 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 { storeHomeProductsResolver } from './pages/store-home-page/store-home-page.resolver'; import { storeHomeCatalogResolver } from './pages/store-home-page/store-home-page.resolver';
export const routes: Routes = [ export const routes: Routes = [
{ {
@@ -17,8 +18,9 @@ export const routes: Routes = [
{ {
path: '', path: '',
component: StoreHomePageComponent, component: StoreHomePageComponent,
canActivate: [hasMenuGuard('index')],
resolve: { resolve: {
productsData: storeHomeProductsResolver, catalogData: storeHomeCatalogResolver,
}, },
}, },
{ {
@@ -45,6 +47,7 @@ export const routes: Routes = [
}, },
{ {
path: 'producto/:id', path: 'producto/:id',
canActivate: [hasMenuGuard('product.detail')],
resolve: { resolve: {
productDetailData: productDetailResolver, productDetailData: productDetailResolver,
}, },
@@ -55,7 +58,7 @@ export const routes: Routes = [
}, },
{ {
path: 'checkout', path: 'checkout',
canActivate: [authGuard], canActivate: [authGuard, hasMenuGuard('checkout')],
loadComponent: () => loadComponent: () =>
import('./pages/checkout-page/checkout-page.component').then( import('./pages/checkout-page/checkout-page.component').then(
(m) => m.CheckoutPageComponent, (m) => m.CheckoutPageComponent,
@@ -82,6 +85,7 @@ 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,
@@ -89,6 +93,7 @@ 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,
@@ -101,6 +106,14 @@ 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,15 +1,16 @@
<article class="w-100 p-3 rounded-0 cart-item"> <article
<div class="position-relative overflow-hidden cart-item-media"> class="w-100 p-3 rounded-0 cart-item"
@if (discountPercentage() && discountPercentage()! > 0) { [class.cart-item-with-media]="imageUrl()"
<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()" />
} @else { </div>
<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,11 +29,15 @@
.cart-item { .cart-item {
display: grid; display: grid;
grid-template-columns: var(--item-media-size) minmax(0, 1fr); grid-template-columns: 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;
@@ -63,12 +67,6 @@
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,16 +1,28 @@
<section class="d-flex flex-column h-100 overflow-hidden text-secondary" [style.background-color]="backgroundColor()"> <section
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 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()"> <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()"
>
<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 (item of items(); track item.productVariantId || item.product + item.discountedPrice; let idx = $index) { @for (
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"
@@ -22,30 +34,31 @@
(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>
<footer class="d-grid gap-1 bg-transparent cart-footer"> @if (items().length > 0) {
<div class="d-flex align-items-baseline justify-content-between gap-3"> <footer class="d-grid gap-1 bg-transparent cart-footer">
<span class="cart-summary-text-muted">Subtotal:</span> <div class="d-flex align-items-baseline justify-content-between gap-3">
<span class="cart-summary-text-muted-bold">{{ formattedSubtotal() }}</span> <span class="cart-summary-text-muted">Subtotal:</span>
</div> <span class="cart-summary-text-muted-bold">{{ formattedSubtotal() }}</span>
</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,8 +31,6 @@
display: block; display: block;
} }
&::-webkit-scrollbar { &::-webkit-scrollbar {
width: 6px; width: 6px;
} }
@@ -43,6 +41,15 @@
} }
} }
.cart-empty-message {
align-self: center;
margin: 0;
padding: 24px;
color: #a0a0a0;
font-size: 14px;
text-align: center;
}
.cart-footer { .cart-footer {
padding: 8px 22px 14px 24px; padding: 8px 22px 14px 24px;
} }

View File

@@ -2,10 +2,7 @@ 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 { import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
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';
@@ -17,10 +14,7 @@ import { CartComponent } from './cart.component';
describe('CartComponent', () => { describe('CartComponent', () => {
beforeAll(() => { beforeAll(() => {
try { try {
getTestBed().initTestEnvironment( getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
BrowserTestingModule,
platformBrowserTesting()
);
} catch { } catch {
// Test environment may already be initialized by another setup entrypoint. // Test environment may already be initialized by another setup entrypoint.
} }
@@ -30,6 +24,44 @@ 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));
@@ -42,49 +74,50 @@ 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', [
{ {
productVariantId: 10, cartItemId: 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: content: 'Se eliminara "Producto de prueba" del carrito. Esta accion no se puede deshacer.',
'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);
}); });
@@ -101,38 +134,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', [
{ {
productVariantId: 10, cartItemId: 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();
@@ -155,36 +188,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 = {
productVariantId: 10, cartItemId: 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]);
@@ -221,36 +254,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 = {
productVariantId: 10, cartItemId: 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,4 +1,12 @@
import { ChangeDetectionStrategy, Component, computed, inject, input, output, signal } from '@angular/core'; import {
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';
@@ -9,7 +17,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 {
productVariantId?: number; cartItemId?: number;
imageUrl: string | null; imageUrl: string | null;
product: string; product: string;
originalPrice: number | null; originalPrice: number | null;
@@ -25,13 +33,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<{ productVariantId: number; quantity: number }>(); private readonly quantityUpdates$ = new Subject<{ cartItemId: number; quantity: number }>();
readonly title = input<string>('CARRITO'); readonly title = input<string>('CARRITO');
readonly showClose = input<boolean>(false); readonly showClose = input<boolean>(false);
@@ -46,68 +54,75 @@ export class CartComponent {
protected readonly quantityOverrides = signal<Record<number, number>>({}); protected readonly quantityOverrides = signal<Record<number, number>>({});
constructor() { constructor() {
this.quantityUpdates$.pipe( this.quantityUpdates$
groupBy(update => update.productVariantId), .pipe(
mergeMap(group$ => group$.pipe( groupBy((update) => update.cartItemId),
debounceTime(1000), mergeMap((group$) =>
switchMap(update => this.cartService.updateItemQuantity(update.productVariantId, update.quantity).pipe( group$.pipe(
tap({ debounceTime(1000),
next: (res) => { switchMap((update) =>
const msg = res.message || 'Cantidad de producto actualizada.'; this.cartService.updateItemQuantity(update.cartItemId, update.quantity).pipe(
this.toastService.success(msg); tap({
this.clearOverride(update.productVariantId); next: (res) => {
}, const msg = res.message || 'Cantidad de producto actualizada.';
error: (err: HttpErrorResponse) => { this.toastService.success(msg);
console.error('Error updating cart quantity', err); this.clearOverride(update.cartItemId);
const msg = err.error?.message || 'Error al actualizar la cantidad del producto.'; },
this.toastService.danger(msg); error: (err: HttpErrorResponse) => {
this.clearOverride(update.productVariantId); console.error('Error updating cart quantity', err);
} const msg =
}), err.error?.message || 'Error al actualizar la cantidad del producto.';
catchError(() => EMPTY) this.toastService.danger(msg);
)) this.clearOverride(update.cartItemId);
)), },
takeUntilDestroyed() }),
).subscribe(); catchError(() => EMPTY),
),
),
),
),
takeUntilDestroyed(),
)
.subscribe();
} }
protected getItemQuantity(item: CartItemMock): number { protected getItemQuantity(item: CartItemMock): number {
if (item.productVariantId !== undefined && this.quantityOverrides()[item.productVariantId] !== undefined) { if (item.cartItemId !== undefined && this.quantityOverrides()[item.cartItemId] !== undefined) {
return this.quantityOverrides()[item.productVariantId]; return this.quantityOverrides()[item.cartItemId];
} }
return item.quantity; return item.quantity;
} }
private clearOverride(productVariantId: number): void { private clearOverride(cartItemId: number): void {
this.quantityOverrides.update((overrides) => { this.quantityOverrides.update((overrides) => {
const copy = { ...overrides }; const copy = { ...overrides };
delete copy[productVariantId]; delete copy[cartItemId];
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 productVariantId = mockItem?.productVariantId; const cartItemId = mockItem?.cartItemId;
if (productVariantId) { if (cartItemId) {
this.quantityOverrides.update((overrides) => ({ this.quantityOverrides.update((overrides) => ({
...overrides, ...overrides,
[productVariantId]: newQuantity [cartItemId]: newQuantity,
})); }));
this.quantityUpdates$.next({ this.quantityUpdates$.next({
productVariantId, cartItemId,
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.product_variant_id]: newQuantity [item.id]: newQuantity,
})); }));
this.quantityUpdates$.next({ this.quantityUpdates$.next({
productVariantId: item.product_variant_id, cartItemId: item.id,
quantity: newQuantity quantity: newQuantity,
}); });
} }
} }
@@ -120,16 +135,18 @@ export class CartComponent {
return; return;
} }
this.modalService.openConfirmDelete({ this.modalService
title: 'Eliminar producto', .openConfirmDelete({
content: `Se eliminara "${target.productName}" del carrito. Esta accion no se puede deshacer.`, title: 'Eliminar producto',
confirmLabel: 'Eliminar', content: `Se eliminara "${target.productName}" del carrito. Esta accion no se puede deshacer.`,
cancelLabel: 'Cancelar' confirmLabel: 'Eliminar',
}).subscribe((confirmed) => { cancelLabel: 'Cancelar',
if (confirmed) { })
this.removeItem(target.productVariantId); .subscribe((confirmed) => {
} if (confirmed) {
}); this.removeItem(target.cartItemId);
}
});
} }
protected readonly formattedSubtotal = computed(() => this.formatCurrency(this.subtotal())); protected readonly formattedSubtotal = computed(() => this.formatCurrency(this.subtotal()));
@@ -143,16 +160,14 @@ export class CartComponent {
return `$ ${parts.join(',')}`; return `$ ${parts.join(',')}`;
} }
private resolveRemoveTarget( private resolveRemoveTarget(index: number): { cartItemId: number; productName: string } | null {
index: number
): { productVariantId: number; productName: string } | null {
const mockItem = this.items()[index]; const mockItem = this.items()[index];
const productVariantId = mockItem?.productVariantId; const cartItemId = mockItem?.cartItemId;
if (productVariantId) { if (cartItemId) {
return { return {
productVariantId, cartItemId,
productName: mockItem.product productName: mockItem.product,
}; };
} }
@@ -163,13 +178,13 @@ export class CartComponent {
} }
return { return {
productVariantId: item.product_variant_id, cartItemId: item.id,
productName: item.product?.nombre ?? 'este producto' productName: item.product?.nombre ?? 'este producto',
}; };
} }
private removeItem(productVariantId: number): void { private removeItem(cartItemId: number): void {
this.cartService.removeItem(productVariantId).subscribe({ this.cartService.removeItem(cartItemId).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);
@@ -178,8 +193,7 @@ 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

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

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

@@ -0,0 +1,41 @@
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,7 +14,9 @@
color: #666666; color: #666666;
cursor: pointer; cursor: pointer;
outline: none; outline: none;
transition: color 0.15s ease-in-out, transform 0.1s ease-in-out; transition:
color 0.15s ease-in-out,
transform 0.1s ease-in-out;
border-radius: 4px; border-radius: 4px;
i { i {
@@ -40,7 +42,7 @@
// Disabled state // Disabled state
&:disabled { &:disabled {
color: #A0A0A0; color: #a0a0a0;
cursor: not-allowed; cursor: not-allowed;
pointer-events: none; pointer-events: none;
} }
@@ -74,3 +76,14 @@
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,14 +8,16 @@ 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>();
@@ -33,7 +35,9 @@ 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,6 +19,36 @@
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);
} }
@@ -40,8 +70,7 @@
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: background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 248, 248, 0.98));
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 {
@@ -88,6 +117,7 @@
} }
.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,10 +32,11 @@ 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',
}; };
}); });
@@ -55,8 +56,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

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

View File

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

View File

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

View File

@@ -4,10 +4,12 @@
height: 100%; height: 100%;
} }
.product-card { .product-column-with-image {
width: 100%; width: 100%;
max-height: 411px; max-height: 411px;
transition: transform 0.2s ease, box-shadow 0.2s ease; transition:
transform 0.2s ease,
box-shadow 0.2s ease;
&:hover { &:hover {
transform: translateY(-2px); transform: translateY(-2px);
@@ -41,11 +43,10 @@
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 {
@@ -73,4 +74,4 @@
} }
} }
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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