62 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
993aa5823a feat(product-detail): implement resolver for product details and update component logic for improved error handling
feat(product-carousel): enhance image loading with optimized attributes and improve layout consistency
style: refactor HTML structure for better readability and maintainability
test(product-detail): add unit tests for product detail resolver and component behavior
2026-07-08 16:12:35 -03:00
6c00a3e2bb feat(store-home): implement resolver for product data and enhance component logic for improved error handling and loading states 2026-07-08 16:04:11 -03:00
efa0d48a40 feat(purchase-item): enhance attributes display for quantity-only items 2026-07-08 15:42:09 -03:00
9fcbf34573 feat(purchase): enhance purchase detail and summary interfaces, update purchase item component for improved data handling 2026-07-08 15:31:37 -03:00
451dc58cd7 style: update cart item layout and typography for improved consistency and readability 2026-07-08 14:27:01 -03:00
26466f57db style: update page title color to #A0A0A0 for consistency across components 2026-07-08 14:16:06 -03:00
a9dc55c274 feat(purchase-detail): implement purchase item component with dynamic attributes and styling 2026-07-08 14:09:51 -03:00
5e9a5917fd feat(purchase-detail): restructure cart item layout for improved readability and alignment 2026-07-08 13:47:23 -03:00
e06edaaa42 feat(purchase-detail): add purchase detail page with routing and mock data 2026-07-08 13:40:29 -03:00
1f2ca09b81 style: update font sizes for labels and page titles across multiple components 2026-07-08 13:16:50 -03:00
56355569f2 fix: correct spelling of "contraseña" in various components and templates 2026-07-08 12:26:46 -03:00
036b2a950f feat(profile): enhance profile form with validation messages and submission logic 2026-07-08 12:12:22 -03:00
7507580c2c feat(profile): add updateProfile method and enhance password validation in register form 2026-07-08 12:06:36 -03:00
b016569b10 feat(purchase-list): implement loading skeleton and enhance purchase list fetching logic 2026-07-08 11:31:52 -03:00
23f48ccec0 style(account-sidebar): update active nav link color to use CSS variable for consistency 2026-07-08 11:20:17 -03:00
6a5683f6b7 feat(purchase-list): refactor purchase list item structure and styles for improved layout 2026-07-08 10:12:20 -03:00
b7ed16966c feat(profile): update profile form layout and functionality, enhance styles for better responsiveness 2026-07-08 09:34:24 -03:00
baf5e2d4e7 feat(layout): enhance layout structure by adding padding and container classes 2026-07-08 09:14:33 -03:00
64ade4d595 feat(profile-form): refactor form-group styles and update error handling in checkout form 2026-07-08 09:05:15 -03:00
b9b8a70ca1 style(profile-form): adjust label styles and remove gap in form group 2026-07-08 08:52:04 -03:00
28ffe3c788 feat(user-dropdown): convert buttons to links for account and purchases navigation 2026-07-08 08:44:07 -03:00
4969026fe9 feat(purchase-list-item): create purchase list item component with styles and template 2026-07-08 08:39:57 -03:00
09d88e6324 feat(account-page): implement account layout with sidebar, profile form, and purchase list components 2026-07-08 08:39:52 -03:00
1a2d303b4e feat(account-page): implement account layout, profile, and purchases pages with corresponding components 2026-07-08 08:36:17 -03:00
142 changed files with 6296 additions and 1719 deletions

View File

@@ -1,20 +1,36 @@
import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http';
import { ApplicationConfig, provideAppInitializer, provideBrowserGlobalErrorListeners } from '@angular/core';
import { HttpRequest, provideHttpClient, withFetch, withInterceptors } from '@angular/common/http';
import {
ApplicationConfig,
provideAppInitializer,
provideBrowserGlobalErrorListeners,
} from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideClientHydration } from '@angular/platform-browser';
import { provideClientHydration, withHttpTransferCacheOptions } from '@angular/platform-browser';
import { routes } from './app.routes';
import { authBootstrap } from './core/services/auth/auth-bootstrap';
import { authInterceptor } from './core/services/auth/auth.interceptor';
import { tenantBootstrap } from './core/services/tenant-bootstrap';
function isStoreCatalogRequest(request: HttpRequest<unknown>): boolean {
return (
request.method === 'GET' &&
/\/api\/tenants\/[^/]+\/productos(?:\/\d+)?(?:\?|$)/.test(request.urlWithParams)
);
}
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
provideClientHydration(),
provideClientHydration(
withHttpTransferCacheOptions({
includeRequestsWithAuthHeaders: true,
filter: isStoreCatalogRequest,
}),
),
provideHttpClient(withFetch(), withInterceptors([authInterceptor])),
provideAppInitializer(authBootstrap),
provideAppInitializer(tenantBootstrap)
]
provideAppInitializer(tenantBootstrap),
],
};

View File

@@ -160,8 +160,8 @@ describe('app routes', () => {
expect(placeholders).toEqual([
'Nombre y Apellido',
'Email',
'Contrasena',
'Repetir Contrasena'
'Contraseña',
'Repetir Contraseña'
]);
});

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 {
min-width: 10rem;
min-width: 150px;
max-width:40px;
}
.store-layout__brand-logo-shell {

View File

@@ -5,12 +5,12 @@
<div class="user-dropdown__divider"></div>
<button type="button" class="user-dropdown__item" role="menuitem" data-testid="user-dropdown-account">
<a routerLink="/mi-cuenta/datos-personales" class="user-dropdown__item" role="menuitem" data-testid="user-dropdown-account">
Mi cuenta
</button>
<button type="button" class="user-dropdown__item" role="menuitem" data-testid="user-dropdown-purchases">
</a>
<a routerLink="/mi-cuenta/compras" class="user-dropdown__item" role="menuitem" data-testid="user-dropdown-purchases">
Mis compras
</button>
</a>
<button
type="button"
class="user-dropdown__item"

View File

@@ -35,6 +35,7 @@
}
.user-dropdown__item {
display: block;
width: 100%;
border: 0;
border-radius: 6px;
@@ -46,6 +47,7 @@
background: transparent;
cursor: pointer;
transition: color 0.15s ease;
text-decoration: none;
}
.user-dropdown__item:active,

View File

@@ -1,8 +1,11 @@
import { Component, computed, input, output } from '@angular/core';
import { RouterLink } from '@angular/router';
import { AuthUser } from '../../../../services/auth/auth.interfaces';
@Component({
selector: 'app-user-dropdown',
standalone: true,
imports: [RouterLink],
templateUrl: './user-dropdown.component.html',
styleUrl: './user-dropdown.component.scss'
})

View File

@@ -28,7 +28,11 @@
}
<main class="flex-grow-1 d-block">
<router-outlet />
<section class="py-5">
<div class="container-xl px-3 px-md-4">
<router-outlet />
</div>
</section>
</main>
<app-store-footer

View File

@@ -2,7 +2,11 @@ import { Component, computed, inject, OnInit, signal } from '@angular/core';
import { Router, RouterOutlet } from '@angular/router';
import { TenantService } from '../../services/tenant.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 { CartComponent, CartItemMock } from '../../../shared/components/cart/cart.component';
import { ButtonComponent } from '../../../shared/components/button/button.component';
@@ -11,9 +15,15 @@ import { AuthService } from '../../services/auth/auth.service';
@Component({
selector: 'app-store-layout',
imports: [RouterOutlet, StoreHeaderComponent, StoreFooterComponent, CartComponent, ButtonComponent],
imports: [
RouterOutlet,
StoreHeaderComponent,
StoreFooterComponent,
CartComponent,
ButtonComponent,
],
templateUrl: './store-layout.component.html',
styleUrl: './store-layout.component.scss'
styleUrl: './store-layout.component.scss',
})
export class StoreLayoutComponent implements OnInit {
private readonly tenantService = inject(TenantService);
@@ -57,14 +67,14 @@ export class StoreLayoutComponent implements OnInit {
}
return {
productVariantId: item.product_variant_id,
cartItemId: item.id,
imageUrl: item.product?.imagen ?? null,
product,
originalPrice: null,
discountedPrice: parseFloat(item.precio_unitario),
discountPercentage: null,
attributes,
quantity: item.cantidad
quantity: item.cantidad,
};
}
@@ -81,7 +91,7 @@ export class StoreLayoutComponent implements OnInit {
ngOnInit(): void {
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 {
this.authService.logout().subscribe({
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']);
}
protected readonly footerSections: StoreFooterSection[] = [
{
heading: 'Cuenta',
links: ['Mi cuenta', 'Mis compras', 'Cerrar sesion']
links: ['Mi cuenta', 'Mis compras', 'Cerrar sesion'],
},
{
heading: 'Ayuda',
links: ['Contacto', 'Ayuda', 'Preguntas frecuentes']
}
links: ['Contacto', 'Ayuda', 'Preguntas frecuentes'],
},
];
protected readonly socialLinks: StoreSocialLink[] = [
{
label: 'Instagram',
iconClass: 'fa-brands fa-instagram'
iconClass: 'fa-brands fa-instagram',
},
{
label: 'WhatsApp',
iconClass: 'fa-brands fa-whatsapp'
iconClass: 'fa-brands fa-whatsapp',
},
{
label: 'Facebook',
iconClass: 'fa-brands fa-facebook'
iconClass: 'fa-brands fa-facebook',
},
{
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 { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { computed, signal } from '@angular/core';
@@ -69,7 +69,7 @@ describe('authInterceptor', () => {
httpController.verify();
});
it('clears the local session when an authenticated request returns 401', () => {
it('propagates a 401 without clearing the local session', () => {
const authService = createAuthServiceStub('expired-token');
TestBed.configureTestingModule({
@@ -83,14 +83,21 @@ describe('authInterceptor', () => {
const client = TestBed.inject(HttpClient);
const httpController = TestBed.inject(HttpTestingController);
let receivedError: unknown;
let receivedStatus: number | null = null;
client.get(`${environment.url}me`).subscribe({
error: () => undefined
error: (error: HttpErrorResponse) => {
receivedError = error;
receivedStatus = error.status;
}
});
const request = httpController.expectOne(`${environment.url}me`);
request.flush({ message: 'Unauthorized' }, { status: 401, statusText: 'Unauthorized' });
expect(authService.clearSession).toHaveBeenCalledTimes(1);
expect(receivedError).toBeInstanceOf(HttpErrorResponse);
expect(receivedStatus).toBe(401);
expect(authService.clearSession).not.toHaveBeenCalled();
httpController.verify();
});
});

View File

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

View File

@@ -18,6 +18,14 @@ export interface RegisterPayload {
password_confirmation: string;
}
export interface UpdateProfilePayload {
nombre_apellido: string;
email: string;
dni?: string;
telefono?: string;
password?: string;
}
export interface LoginResponse {
message: string;
token: string;

View File

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

View File

@@ -1,3 +1,4 @@
import { DOCUMENT } from '@angular/common';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { isPlatformBrowser, isPlatformServer } from '@angular/common';
import { computed, inject, Injectable, PLATFORM_ID, signal, TransferState, makeStateKey } from '@angular/core';
@@ -9,9 +10,11 @@ import {
LoginPayload,
LoginResponse,
RegisterPayload,
RegisterResponse
RegisterResponse,
UpdateProfilePayload
} from './auth.interfaces';
import { CookieService } from '../cookie/cookie.service';
import { TenantService } from '../tenant.service';
const AUTH_TOKEN_COOKIE_KEY = 'shopit.auth.token';
const AUTH_USER_SSR_STATE_KEY = makeStateKey<AuthUser>('shopit.auth.user');
@@ -21,9 +24,11 @@ const AUTH_USER_SSR_STATE_KEY = makeStateKey<AuthUser>('shopit.auth.user');
})
export class AuthService {
private readonly http = inject(HttpClient);
private readonly document = inject(DOCUMENT);
private readonly platformId = inject(PLATFORM_ID);
private readonly cookieService = inject(CookieService);
private readonly transferState = inject(TransferState);
private readonly tenantService = inject(TenantService);
private readonly userState = signal<AuthUser | null>(null);
private readonly tokenState = signal<string | null>(null);
@@ -40,7 +45,39 @@ export class AuthService {
}
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> {
return this.http.put<AuthUser>(`${environment.url}me`, payload).pipe(
tap((user) => this.userState.set(user))
);
}
logout(): Observable<void> {
@@ -50,10 +87,7 @@ export class AuthService {
}
return this.http.post<void>(`${environment.url}logout`, {}).pipe(
tap({
next: () => this.clearSession(),
error: () => this.clearSession()
})
tap(() => this.clearSession())
);
}
@@ -73,12 +107,18 @@ export class AuthService {
try {
const user = await firstValueFrom(this.loadCurrentUser());
if (isPlatformServer(this.platformId)) {
this.transferState.set(AUTH_USER_SSR_STATE_KEY, user);
}
} catch (error) {
if (this.isUnauthorizedError(error)) {
// 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();
return;
}
@@ -108,8 +148,4 @@ export class AuthService {
this.userState.set(user);
this.cookieService.set(AUTH_TOKEN_COOKIE_KEY, token);
}
private isUnauthorizedError(error: unknown): error is HttpErrorResponse {
return error instanceof HttpErrorResponse && error.status === 401;
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,6 +16,51 @@ export interface PurchaseStatusResponse {
status: string | null;
}
export interface PurchaseSummaryResponse extends PurchaseStatusResponse {
id: number;
created_at: string | null;
total: string;
}
export interface PurchaseDetailAttributeResponse {
name: string;
value: string | number | boolean | null;
}
export interface PurchaseDetailItemResponse {
id: number;
quantity: number;
unit_price: string;
line_total: string;
source_catalog_item_id: number | null;
source_variant_id: number | null;
item_details: {
nombre: string;
descripcion: string | null;
slug: string;
imagen: string | null;
attributes: PurchaseDetailAttributeResponse[];
};
}
export interface PurchaseDetailResponse extends PurchaseStatusResponse {
id: number;
cart_id: number | null;
tenant_codigo: string;
user_id: number;
created_at: string | null;
payment_method: string | null;
dni: string | null;
transfer_payer_dni: string | null;
telefono: string | null;
nombre_apellido: string | null;
email: string | null;
items_source: 'purchase';
items: PurchaseDetailItemResponse[];
subtotal: string;
total: string;
}
@Injectable({
providedIn: 'root'
})
@@ -36,9 +81,13 @@ export class CheckoutService {
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(
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) {
throw new Error('Error al generar la intención de pago.');
@@ -62,20 +111,32 @@ export class CheckoutService {
};
}
async getPurchase(tenantCode: string, purchaseId: string | number): Promise<PurchaseStatusResponse> {
async getPurchases(tenantCode: string, status?: string): Promise<{ data: PurchaseSummaryResponse[] }> {
let url = `${environment.url}tenants/${tenantCode}/compras`;
if (status) {
url += `?status=${status}`;
}
const response = await firstValueFrom(
this.http.get<{ data?: PurchaseStatusResponse } | PurchaseStatusResponse>(`${environment.url}tenants/${tenantCode}/compras/${purchaseId}`)
this.http.get<{ data: PurchaseSummaryResponse[] }>(url)
);
if (!response) {
throw new Error('Error al obtener las compras.');
}
return response;
}
async getPurchase(tenantCode: string, purchaseId: string | number): Promise<PurchaseDetailResponse> {
const response = await firstValueFrom(
this.http.get<{ data?: PurchaseDetailResponse } | PurchaseDetailResponse>(`${environment.url}tenants/${tenantCode}/compras/${purchaseId}`)
);
const purchase = this.extractResponseData<PurchaseStatusResponse>(response);
const purchase = this.extractResponseData<PurchaseDetailResponse>(response);
if (!purchase) {
throw new Error('Error al obtener la compra.');
}
return {
status: purchase.status ?? null
};
return purchase;
}
private extractResponseData<T>(response: T | { data?: T } | null | undefined): T | null {

View File

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

View File

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

View File

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

View File

@@ -9,6 +9,26 @@ export interface BankAccount {
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 {
id: number;
codigo: string;
@@ -24,6 +44,9 @@ export interface Tenant {
footer_logo: string;
selected_bank_account_id?: number | null;
selected_bank_account?: BankAccount | null;
hero_config?: HeroConfig | null;
event_config?: EventConfig | null;
menues?: Menu[];
}
export type TenantBootstrapResponse = ApiResponse<Tenant>;

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,51 +1,70 @@
<div class="product-carousel d-flex flex-column gap-4">
<!-- Active/Main Image Area -->
<div class="product-carousel__main position-relative bg-light overflow-hidden">
<div class="product-carousel__main position-relative bg-light overflow-hidden">
<!-- Main Image -->
@if (images().length > 0) {
<img [src]="images()[activeIndex()]" alt="Product active image"
class="product-carousel__main-image w-100 h-100 object-fit-cover" />
<img
[ngSrc]="images()[activeIndex()]"
alt="Product active image"
fill
priority
sizes="(min-width: 992px) 42vw, 100vw"
class="product-carousel__main-image w-100 h-100 object-fit-cover"
/>
} @else {
<!-- Placeholder -->
<div class="product-carousel__placeholder w-100 h-100 d-flex align-items-center justify-content-center text-muted">
<i class="fa-solid fa-image fa-3x"></i>
</div>
<!-- Placeholder -->
<div
class="product-carousel__placeholder w-100 h-100 d-flex align-items-center justify-content-center text-muted"
>
<i class="fa-solid fa-image fa-3x"></i>
</div>
}
<!-- Discount Badge -->
@if (discount() && discount()! > 0) {
<span
class="product-carousel__discount-badge position-absolute top-0 end-0 bg-primary text-white px-3 py-2 fw-semibold">
-{{ discount() }}%
</span>
<span
class="product-carousel__discount-badge position-absolute top-0 end-0 bg-primary text-white px-3 py-2 fw-semibold"
>
-{{ discount() }}%
</span>
}
<!-- Navigation Arrows -->
@if (images().length > 1) {
<button type="button"
class="product-carousel__nav-btn product-carousel__nav-btn--prev position-absolute top-50 translate-middle-y border-0 p-0 d-flex align-items-center justify-content-center"
(click)="prevImage()" aria-label="Previous image">
<i class="fa-solid fa-chevron-left"></i>
</button>
<button
type="button"
class="product-carousel__nav-btn product-carousel__nav-btn--prev position-absolute top-50 translate-middle-y border-0 p-0 d-flex align-items-center justify-content-center"
(click)="prevImage()"
aria-label="Previous image"
>
<i class="fa-solid fa-chevron-left"></i>
</button>
<button type="button"
class="product-carousel__nav-btn product-carousel__nav-btn--next position-absolute top-50 translate-middle-y border-0 p-0 d-flex align-items-center justify-content-center"
(click)="nextImage()" aria-label="Next image">
<i class="fa-solid fa-chevron-right"></i>
</button>
<button
type="button"
class="product-carousel__nav-btn product-carousel__nav-btn--next position-absolute top-50 translate-middle-y border-0 p-0 d-flex align-items-center justify-content-center"
(click)="nextImage()"
aria-label="Next image"
>
<i class="fa-solid fa-chevron-right"></i>
</button>
}
</div>
<!-- Thumbnails Row -->
@if (images().length > 1) {
<div class="product-carousel__thumbnails">
@for (image of images(); track image; let idx = $index) {
<button type="button" class="product-carousel__thumbnail border-0 p-0 overflow-hidden bg-light"
[class.product-carousel__thumbnail--active]="idx === activeIndex()" (click)="selectImage(idx)"
[attr.aria-label]="'Select image ' + (idx + 1)">
<img [src]="image" alt="Product thumbnail" class="w-100 h-100 object-fit-cover" />
</button>
}
</div>
<div class="product-carousel__thumbnails">
@for (image of images(); track image; let idx = $index) {
<button
type="button"
class="product-carousel__thumbnail border-0 p-0 overflow-hidden bg-light"
[class.product-carousel__thumbnail--active]="idx === activeIndex()"
(click)="selectImage(idx)"
[attr.aria-label]="'Select image ' + (idx + 1)"
>
<img [src]="image" alt="Product thumbnail" class="w-100 h-100 object-fit-cover" />
</button>
}
</div>
}
</div>

View File

@@ -1,13 +1,13 @@
import { ChangeDetectionStrategy, Component, input, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { CommonModule, NgOptimizedImage } from '@angular/common';
@Component({
selector: 'app-product-carousel',
standalone: true,
imports: [CommonModule],
imports: [CommonModule, NgOptimizedImage],
templateUrl: './product-carousel.component.html',
styleUrl: './product-carousel.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ProductCarouselComponent {
readonly images = input<string[]>([]);

View File

@@ -0,0 +1,6 @@
<div class="account-layout">
<app-account-sidebar class="account-sidebar"></app-account-sidebar>
<div class="account-content">
<router-outlet></router-outlet>
</div>
</div>

View File

@@ -0,0 +1,17 @@
.account-layout {
display: flex;
margin: 0 auto;
gap: 100px;
min-height: calc(100vh - 100px);
}
.account-sidebar {
width: 250px;
flex-shrink: 0;
}
.account-content {
flex: 1;
}
label {
font-size: 13px !important;
}

View File

@@ -0,0 +1,12 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { AccountSidebar } from '../components/account-sidebar/account-sidebar';
@Component({
selector: 'app-account-layout',
standalone: true,
imports: [RouterOutlet, AccountSidebar],
templateUrl: './account-layout.html',
styleUrl: './account-layout.scss',
})
export class AccountLayout {}

View File

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

View File

@@ -0,0 +1,34 @@
.sidebar-container {
display: flex;
flex-direction: column;
}
.sidebar-title {
font-size: 16px;
font-weight: bold;
color: #1a1a1a;
margin-bottom: 12px;
}
.divider {
height: 1px;
background-color: #e0e0e0;
margin-bottom: 20px;
}
.sidebar-nav {
display: flex;
flex-direction: column;
gap: 16px;
}
.nav-link {
text-decoration: none;
color: #888;
font-size: 14px;
font-weight: 500;
transition: color 0.2s;
}
.nav-link:hover {
color: #5a5a5a;
}
.nav-link.active {
color: var(--color-primary); /* Blue color matching the image */
font-weight: 600;
}

View File

@@ -0,0 +1,11 @@
import { Component } from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router';
@Component({
selector: 'app-account-sidebar',
standalone: true,
imports: [RouterLink, RouterLinkActive],
templateUrl: './account-sidebar.html',
styleUrl: './account-sidebar.scss',
})
export class AccountSidebar {}

View File

@@ -0,0 +1,66 @@
<form [formGroup]="profileForm" class="profile-form" (ngSubmit)="onSubmit()">
<div class="form-group">
<label>Nombre y Apellido:</label>
<app-input
type="text"
[value]="profileForm.controls['fullName'].value"
(valueChange)="profileForm.controls['fullName'].setValue($event)"
></app-input>
@if (getControlError('fullName'); as errorMessage) {
<small class="text-danger">{{ errorMessage }}</small>
}
</div>
<div class="form-group">
<label>Email:</label>
<app-input
type="email"
[value]="profileForm.controls['email'].value"
(valueChange)="profileForm.controls['email'].setValue($event)"
></app-input>
@if (getControlError('email'); as errorMessage) {
<small class="text-danger">{{ errorMessage }}</small>
}
</div>
<div class="form-group">
<label>DNI:</label>
<app-input
type="text"
[value]="profileForm.controls['dni'].value"
(valueChange)="profileForm.controls['dni'].setValue($event)"
></app-input>
@if (getControlError('dni'); as errorMessage) {
<small class="text-danger">{{ errorMessage }}</small>
}
</div>
<div class="form-group">
<label>Teléfono:</label>
<app-input
type="text"
[value]="profileForm.controls['phone'].value"
(valueChange)="profileForm.controls['phone'].setValue($event)"
></app-input>
@if (getControlError('phone'); as errorMessage) {
<small class="text-danger">{{ errorMessage }}</small>
}
</div>
<div class="form-group">
<label>Contraseña (dejar en blanco para no modificar):</label>
<app-input
type="password"
[value]="profileForm.controls['password'].value"
(valueChange)="profileForm.controls['password'].setValue($event)"
></app-input>
@if (getControlError('password'); as errorMessage) {
<small class="text-danger">{{ errorMessage }}</small>
}
</div>
<div class="form-actions mt-3">
<app-button type="button" variant="cancel" hostClass="action-btn" buttonClass="w-100" (click)="onCancel()">Cancelar</app-button>
<app-button type="submit" variant="primary" hostClass="action-btn" buttonClass="w-100" [disabled]="isSubmitting()">Guardar</app-button>
</div>
</form>

View File

@@ -0,0 +1,15 @@
.profile-form {
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
}
.form-actions {
display: flex;
gap: 12px;
margin-top: 16px;
}
.action-btn {
flex: 1;
display: block;
}

View File

@@ -0,0 +1,105 @@
import { Component, inject, effect, signal, OnInit } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { InputComponent } from '../../../../../../shared/components/input/input.component';
import { ButtonComponent } from '../../../../../../shared/components/button/button.component';
import { AuthService } from '../../../../../../core/services/auth/auth.service';
import { ToastService } from '../../../../../../core/services/toast.service';
@Component({
selector: 'app-profile-form',
standalone: true,
imports: [ReactiveFormsModule, InputComponent, ButtonComponent],
templateUrl: './profile-form.html',
styleUrl: './profile-form.scss',
})
export class ProfileForm implements OnInit {
profileForm: FormGroup;
private authService = inject(AuthService);
private toastService = inject(ToastService);
isSubmitting = signal(false);
constructor(private fb: FormBuilder) {
this.profileForm = this.fb.group({
fullName: ['', [Validators.required, Validators.maxLength(255)]],
email: ['', [Validators.required, Validators.email, Validators.maxLength(255)]],
dni: ['', [Validators.pattern(/^[0-9]{7,8}$/)]],
phone: ['', [Validators.pattern(/^\+?[0-9\s\-]{10,20}$/)]],
password: ['', [Validators.pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*[\W_]).{8,}$/)]]
});
effect(() => {
const user = this.authService.user();
if (user) {
this.profileForm.patchValue({
fullName: user.nombre_apellido,
email: user.email,
dni: user.dni || '',
phone: user.telefono || ''
});
}
});
}
ngOnInit() {
// Refresh user data directly from the API to ensure consistency
this.authService.loadCurrentUser().subscribe();
}
getControlError(controlName: string): string | null {
const control = this.profileForm.get(controlName);
if (!control || !control.errors || (!control.touched && !this.isSubmitting())) return null;
if (control.errors['required']) return 'Este campo es obligatorio.';
if (control.errors['email']) return 'Ingresa un email válido.';
if (control.errors['maxlength']) return 'Excede el largo máximo.';
if (controlName === 'dni' && control.errors['pattern']) return 'DNI inválido (7-8 números).';
if (controlName === 'phone' && control.errors['pattern']) return 'Teléfono inválido.';
if (controlName === 'password' && control.errors['pattern']) return 'Debe contener mayúscula, minúscula, carácter especial y mínimo 8 caracteres.';
return 'Valor inválido.';
}
onSubmit() {
if (this.profileForm.invalid) {
this.profileForm.markAllAsTouched();
return;
}
this.isSubmitting.set(true);
const formValue = this.profileForm.value;
const payload: any = {
nombre_apellido: formValue.fullName,
email: formValue.email,
};
if (formValue.dni) payload.dni = formValue.dni;
if (formValue.phone) payload.telefono = formValue.phone;
if (formValue.password) payload.password = formValue.password;
this.authService.updateProfile(payload).subscribe({
next: () => {
this.isSubmitting.set(false);
this.toastService.success('Perfil actualizado correctamente.');
},
error: (error: any) => {
this.isSubmitting.set(false);
const errorMessage = error.error?.message || 'Error al actualizar el perfil.';
this.toastService.danger(errorMessage);
}
});
}
onCancel() {
const user = this.authService.user();
if (user) {
this.profileForm.reset({
fullName: user.nombre_apellido,
email: user.email,
dni: user.dni || '',
phone: user.telefono || '',
password: ''
});
} else {
this.profileForm.reset();
}
}
}

View File

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

View File

@@ -0,0 +1,129 @@
:host {
display: block;
border-bottom: 1px solid #dddddd;
}
:host(:last-child) {
border-bottom: none;
}
.cart-item {
display: grid;
grid-template-columns: 120px minmax(0, 1fr);
gap: 0.75rem;
background-color: transparent;
position: relative;
&--without-image {
grid-template-columns: minmax(0, 1fr);
}
}
.cart-item-media {
width: 120px;
aspect-ratio: 4 / 3;
border-radius: 5px 0 0 5px;
background-color: #ffffff;
border: 1px solid #eaeaea;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.cart-item-discount-badge {
width: 44px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
border-bottom-right-radius: 4px;
background-color: var(--bs-primary, #5b75ff);
color: #ffffff;
font-size: 10px;
font-weight: 500;
line-height: 1;
}
.cart-item-content {
gap: 0.5rem;
}
.cart-item-top-row {
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.75rem;
}
.cart-item-product {
color: #666666;
font-size: 10px;
line-height: 1.25;
font-weight: 325;
}
.cart-item-prices {
display: grid;
justify-items: end;
line-height: 1.1;
}
.cart-item-original-price {
color: #a0a0a0;
font-size: 9px;
font-weight: 325;
}
.cart-item-discounted-price {
color: var(--bs-primary, #5b75ff);
font-size: 15px;
font-weight: bold;
}
.cart-item-price-label {
color: #a0a0a0;
font-size: 9px;
font-weight: 325;
}
.cart-item-attributes {
gap: 0.125rem;
&--quantity-only {
flex: 1;
align-content: center;
}
}
.cart-item-attribute {
color: #666666;
font-size: 9px;
line-height: 1.2;
}
.cart-item-attribute-label {
font-weight: 325;
}
.cart-item-transfer-price {
color: #666666;
font-size: 9px;
line-height: 1.2;
display: grid;
justify-items: end;
}
.cart-item-description {
color: #666666;
font-size: 9px;
font-weight: 325;
line-height: 1.35;
white-space: pre-line;
}
.cart-item-bottom-details {
gap: 0.125rem;
justify-items: end;
}

View File

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

View File

@@ -0,0 +1,28 @@
import { Component, Input } from '@angular/core';
export type PurchaseItemViewModel = {
id: number;
title: string;
description: string | null;
imageUrl: string | null;
quantity: number;
attributes: Array<{
label: string;
value: string | null;
}>;
price: string;
originalPrice?: string | null;
transferPrice?: string | null;
discountPercentage?: string | number | null;
};
@Component({
selector: 'app-purchase-item',
standalone: true,
imports: [],
templateUrl: './purchase-item.html',
styleUrl: './purchase-item.scss',
})
export class PurchaseItem {
@Input() item!: PurchaseItemViewModel;
}

View File

@@ -0,0 +1,11 @@
<div class="purchase-item" [routerLink]="[purchase.id]" style="cursor: pointer;">
<div class="purchase-info">
<span class="purchase-id">Compra {{ purchase.id }}.</span>
<span class="purchase-date">Fecha de compra: {{ purchase.date }}</span>
</div>
<div class="purchase-action">
<svg width="8" height="14" viewBox="0 0 8 14" fill="none" xmlns="http://www.w3.org/2000/svg" class="arrow-icon">
<path d="M1 1L7 7L1 13" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
</div>

View File

@@ -0,0 +1,37 @@
.purchase-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
padding-left:0;
transition: box-shadow 0.2s, border-radius 0.2s;
cursor: pointer;
}
.purchase-item:hover {
box-shadow: 0px 0px 30px 0px rgba(0, 0, 0, 0.07);
border-radius: 8px;
}
.purchase-item:hover .arrow-icon {
color: #5b75ff; /* Blue on hover */
}
.purchase-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.purchase-id {
font-weight: bold;
color: #666666;
font-size: 13px;
}
.purchase-date {
font-weight: 325;
color: #666666;
font-size: 10px;
}
.arrow-icon {
width: 8px;
height: 14px;
color: #888;
transition: color 0.2s;
}

View File

@@ -0,0 +1,14 @@
import { Component, Input } from '@angular/core';
import { RouterLink } from '@angular/router';
@Component({
selector: 'app-purchase-list-item',
standalone: true,
imports: [RouterLink],
templateUrl: './purchase-list-item.html',
styleUrl: './purchase-list-item.scss',
})
export class PurchaseListItem {
@Input() purchase!: { id: number; date: string };
}

View File

@@ -0,0 +1,29 @@
<div class="purchase-list-container">
<ng-container *ngIf="isLoading(); else contentTpl">
<!-- Skeleton -->
<div class="skeleton-item" *ngFor="let i of [1, 2, 3, 4]">
<div class="skeleton-info">
<div class="skeleton-id"></div>
<div class="skeleton-date"></div>
</div>
<div class="skeleton-action"></div>
</div>
</ng-container>
<ng-template #contentTpl>
<ng-container *ngIf="purchases().length > 0; else emptyTpl">
<ng-container *ngFor="let purchase of purchases(); let last = last">
<app-purchase-list-item
[purchase]="purchase">
</app-purchase-list-item>
<div class="purchase-divider" *ngIf="!last"></div>
</ng-container>
</ng-container>
</ng-template>
<ng-template #emptyTpl>
<div class="empty-state">
Aún no hay compras realizadas
</div>
</ng-template>
</div>

View File

@@ -0,0 +1,73 @@
.purchase-list-container {
display: flex;
flex-direction: column;
width: 100%;
}
.purchase-divider {
height: 1px;
background-color: #DDDDDD;
width: 100%;
}
.empty-state {
padding: 2rem 0;
text-align: center;
color: #666;
font-size: 1rem;
}
/* Skeleton */
.skeleton-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 0;
border-bottom: 1px solid #DDDDDD;
}
.skeleton-item:last-child {
border-bottom: none;
}
.skeleton-info {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.skeleton-id {
width: 100px;
height: 1rem;
background-color: #EEEEEE;
border-radius: 4px;
animation: pulse 1.5s infinite ease-in-out;
}
.skeleton-date {
width: 150px;
height: 0.8rem;
background-color: #EEEEEE;
border-radius: 4px;
animation: pulse 1.5s infinite ease-in-out;
}
.skeleton-action {
width: 24px;
height: 24px;
background-color: #EEEEEE;
border-radius: 50%;
animation: pulse 1.5s infinite ease-in-out;
}
@keyframes pulse {
0% {
background-color: #EEEEEE;
}
50% {
background-color: #E0E0E0;
}
100% {
background-color: #EEEEEE;
}
}

View File

@@ -0,0 +1,62 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { PurchaseListItem } from '../purchase-list-item/purchase-list-item';
import { CheckoutService, PurchaseSummaryResponse } from '../../../../../../core/services/checkout.service';
import { ToastService } from '../../../../../../core/services/toast.service';
import { TenantService } from '../../../../../../core/services/tenant.service';
type PurchaseListViewModel = {
id: number;
date: string;
};
@Component({
selector: 'app-purchase-list',
standalone: true,
imports: [CommonModule, PurchaseListItem],
templateUrl: './purchase-list.html',
styleUrl: './purchase-list.scss',
})
export class PurchaseList implements OnInit {
private readonly checkoutService = inject(CheckoutService);
private readonly toastService = inject(ToastService);
private readonly tenantService = inject(TenantService);
purchases = signal<PurchaseListViewModel[]>([]);
isLoading = signal<boolean>(true);
async ngOnInit(): Promise<void> {
try {
const tenantCode = this.tenantService.tenant()?.codigo || '';
const response = await this.checkoutService.getPurchases(tenantCode, 'paid');
const mappedPurchases = response.data.map((purchase: PurchaseSummaryResponse) => ({
id: purchase.id,
date: this.formatDate(purchase.created_at),
}));
this.purchases.set(mappedPurchases);
} catch (error) {
this.toastService.danger('Hubo un error al cargar las compras');
} finally {
this.isLoading.set(false);
}
}
private formatDate(value: string | null): string {
if (!value) {
return '-';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '-';
}
return new Intl.DateTimeFormat('es-AR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
}).format(date);
}
}

View File

@@ -0,0 +1,4 @@
<div class="page-container">
<h2 class="page-title">DATOS PERSONALES</h2>
<app-profile-form></app-profile-form>
</div>

View File

@@ -0,0 +1,13 @@
.page-container {
display: flex;
flex-direction: column;
width: 100%;
max-width: 600px;
}
.page-title {
font-size: 15px;
font-weight: bold;
color: #A0A0A0;
margin-bottom: 24px;
}

View File

@@ -0,0 +1,11 @@
import { Component } from '@angular/core';
import { ProfileForm } from '../../components/profile-form/profile-form';
@Component({
selector: 'app-profile-page',
standalone: true,
imports: [ProfileForm],
templateUrl: './profile-page.html',
styleUrl: './profile-page.scss',
})
export class ProfilePage {}

View File

@@ -0,0 +1,33 @@
<div class="page-container">
<div class="d-flex align-items-center mb-4">
<a routerLink="../" class="text-decoration-none " >
<i class="bi bi-arrow-left fs-5"></i>
</a>
<h2 class="page-title m-0">MIS COMPRAS</h2>
</div>
@if (isLoading()) {
<p class="purchase-loading">Cargando detalle de compra...</p>
} @else if (purchase(); as purchase) {
<div class="d-flex justify-content-between align-items-center mb-4 pb-3" style="border-bottom: 1px solid #dddddd;">
<div class="purchase-info">
<span class="purchase-id">Compra {{ purchase.id }}.</span>
<span class="purchase-date">Fecha de compra: {{ purchase.date }}</span>
</div>
<div class="purchase-total ">
<span class="purchase-total-label">Total:</span>
<span class="purchase-total-value">${{ purchase.total }}</span>
</div>
</div>
<p style="font-size: 12px; color: #666666; margin:0;">Productos:</p>
<div class="d-flex flex-column">
@for (item of purchase.items; track item.id) {
<app-purchase-item [item]="item"></app-purchase-item>
} @empty {
<p class="purchase-empty">No hay productos para mostrar en esta compra.</p>
}
</div>
}
</div>

View File

@@ -0,0 +1,51 @@
.page-container {
display: flex;
flex-direction: column;
width: 100%;
max-width: 600px;
}
.page-title {
font-size: 15px;
font-weight: bold;
color: #A0A0A0;
}
.purchase-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.purchase-id {
font-weight: bold;
color: #666666;
font-size: 13px;
}
.purchase-date {
font-weight: 325;
color: #666666;
font-size: 10px;
}
.purchase-total {
display: flex;
align-items: center;
gap: 0.5rem;
}
.purchase-total-label {
font-size: 17px;
color: #666666;
font-weight: 325;
}
.purchase-total-value {
color: var(--bs-primary, #5b75ff);
font-weight: bold;
font-size: 17px;
}
.purchase-loading,
.purchase-empty {
color: #666666;
font-size: 12px;
margin: 0;
}

View File

@@ -0,0 +1,99 @@
import { CommonModule } from '@angular/common';
import { Component, OnInit, inject, signal } from '@angular/core';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import {
CheckoutService,
PurchaseDetailResponse,
} from '../../../../../../core/services/checkout.service';
import { TenantService } from '../../../../../../core/services/tenant.service';
import { ToastService } from '../../../../../../core/services/toast.service';
import { PurchaseItem, PurchaseItemViewModel } from '../../components/purchase-item/purchase-item';
type PurchaseDetailViewModel = {
id: number;
date: string;
total: string;
items: PurchaseItemViewModel[];
};
@Component({
selector: 'app-purchase-detail-page',
standalone: true,
imports: [CommonModule, RouterLink, PurchaseItem],
templateUrl: './purchase-detail-page.html',
styleUrl: './purchase-detail-page.scss',
})
export class PurchaseDetailPage implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly checkoutService = inject(CheckoutService);
private readonly tenantService = inject(TenantService);
private readonly toastService = inject(ToastService);
readonly isLoading = signal(true);
readonly purchase = signal<PurchaseDetailViewModel | null>(null);
async ngOnInit(): Promise<void> {
const purchaseId = this.route.snapshot.paramMap.get('id');
const tenant = this.tenantService.tenant();
if (!purchaseId || !tenant) {
void this.router.navigate(['/mi-cuenta/compras']);
return;
}
try {
const response = await this.checkoutService.getPurchase(tenant.codigo, purchaseId);
this.purchase.set({
id: response.id,
date: this.formatDate(response.created_at),
total: response.total,
items: this.mapItems(response),
});
} catch (error) {
console.error('Failed to fetch purchase detail:', error);
this.toastService.danger('Hubo un error al cargar el detalle de la compra');
void this.router.navigate(['/mi-cuenta/compras']);
} finally {
this.isLoading.set(false);
}
}
private formatDate(value: string | null): string {
if (!value) {
return '-';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '-';
}
return new Intl.DateTimeFormat('es-AR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
}).format(date);
}
private mapItems(purchase: PurchaseDetailResponse): PurchaseItemViewModel[] {
return purchase.items.map((item) => ({
id: item.id,
title: item.item_details.nombre || 'Producto sin nombre',
description: item.item_details.descripcion,
imageUrl: item.item_details.imagen,
quantity: item.quantity,
attributes: item.item_details.attributes.map((attribute) => ({
label: attribute.name,
value: attribute.value === null ? null : String(attribute.value),
})),
price: item.line_total,
originalPrice: null,
transferPrice: null,
discountPercentage: null,
}));
}
}

View File

@@ -0,0 +1,4 @@
<div class="page-container">
<h2 class="page-title">MIS COMPRAS</h2>
<app-purchase-list></app-purchase-list>
</div>

View File

@@ -0,0 +1,12 @@
.page-container {
display: flex;
flex-direction: column;
width: 100%;
max-width: 600px;
}
.page-title {
font-size: 15px;
font-weight: bold;
color: #A0A0A0;
}

View File

@@ -0,0 +1,11 @@
import { Component } from '@angular/core';
import { PurchaseList } from '../../components/purchase-list/purchase-list';
@Component({
selector: 'app-purchases-page',
standalone: true,
imports: [PurchaseList],
templateUrl: './purchases-page.html',
styleUrl: './purchases-page.scss',
})
export class PurchasesPage {}

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

@@ -5,75 +5,71 @@
(ngSubmit)="submit()"
>
<div class="checkout-form__fields">
<div class="form-field">
<label class="form-field__label" for="checkout-nombre">
Nombre y Apellido <span class="form-field__required" aria-hidden="true">*</span>
<div class="form-group">
<label for="checkout-nombre">
Nombre y Apellido <span class="required" aria-hidden="true">*</span>
</label>
<app-input
id="checkout-nombre"
type="text"
placeholder="Descripcion"
[value]="form().controls.nombre.value"
[invalid]="showControlError('nombre')"
[disabled]="form().controls.nombre.disabled"
(valueChange)="updateField('nombre', $event)"
/>
@if (getControlError('nombre'); as errorMessage) {
<small class="form-field__error">{{ errorMessage }}</small>
<small class="error">{{ errorMessage }}</small>
}
</div>
<div class="form-field">
<label class="form-field__label" for="checkout-email">
Email <span class="form-field__required" aria-hidden="true">*</span>
<div class="form-group">
<label for="checkout-email">
Email <span class="required" aria-hidden="true">*</span>
</label>
<app-input
id="checkout-email"
type="email"
placeholder="Descripcion"
[value]="form().controls.email.value"
[invalid]="showControlError('email')"
[disabled]="form().controls.email.disabled"
(valueChange)="updateField('email', $event)"
/>
@if (getControlError('email'); as errorMessage) {
<small class="form-field__error">{{ errorMessage }}</small>
<small class="error">{{ errorMessage }}</small>
}
</div>
<div class="form-field">
<label class="form-field__label" for="checkout-dni">
DNI <span class="form-field__required" aria-hidden="true">*</span>
<div class="form-group">
<label for="checkout-dni">
DNI <span class="required" aria-hidden="true">*</span>
</label>
<app-input
id="checkout-dni"
type="text"
placeholder="Descripcion"
[value]="form().controls.dni.value"
[invalid]="showControlError('dni')"
[disabled]="form().controls.dni.disabled"
(valueChange)="updateField('dni', $event)"
/>
@if (getControlError('dni'); as errorMessage) {
<small class="form-field__error">{{ errorMessage }}</small>
<small class="error">{{ errorMessage }}</small>
}
</div>
<div class="form-field">
<label class="form-field__label" for="checkout-telefono">
Telefono <span class="form-field__required" aria-hidden="true">*</span>
<div class="form-group">
<label for="checkout-telefono">
Telefono <span class="required" aria-hidden="true">*</span>
</label>
<app-input
id="checkout-telefono"
type="text"
placeholder="Descripcion"
[value]="form().controls.telefono.value"
[invalid]="showControlError('telefono')"
[disabled]="form().controls.telefono.disabled"
(valueChange)="updateField('telefono', $event)"
/>
@if (getControlError('telefono'); as errorMessage) {
<small class="form-field__error">{{ errorMessage }}</small>
<small class="error">{{ errorMessage }}</small>
}
</div>
</div>

View File

@@ -23,26 +23,3 @@
max-width: 290px;
}
}
.form-field {
display: grid;
gap: 0.35rem;
&__label {
font-size: 0.8rem;
font-weight: 600;
color: var(--bs-secondary, #6c757d);
letter-spacing: 0;
}
&__required {
color: var(--bs-danger, #dc3545);
margin-left: 2px;
}
&__error {
font-size: 0.75rem;
color: var(--bs-danger, #dc3545);
}
}

View File

@@ -1,4 +1,4 @@
<div class="container-xl px-3 px-md-4 py-4">
<div class="checkout-page">
<div class="checkout-page__stepper-col ">
<app-stepper #stepper>
@@ -16,12 +16,18 @@
[selectedPaymentMethod]="selectedPaymentMethod()"
[copiedTransferField]="copiedTransferField()"
[transferAccount]="transferAccount()"
[transferDni]="transferDni()"
[isGeneratingIntent]="isPaymentLoading()"
[qrData]="qrData()"
[qrPaymentStatus]="qrPaymentStatus()"
[isCheckingQrPayment]="isCheckingQrPayment()"
[transferValidationStatus]="transferValidationStatus()"
(paymentMethodChange)="selectPaymentMethod($event)"
(copyTransferValue)="copyTransferValue($event.field, $event.value)"
(cancelStep)="onCancel()"
(generateTransferIntent)="generateTransferIntent($event)"
(complete)="onComplete()"
(retryQrPolling)="retryQrPolling()"
/>
</app-step>
</app-stepper>
@@ -37,4 +43,3 @@
/>
</div>
</div>
</div>

View File

@@ -3,7 +3,6 @@
grid-template-columns: minmax(0, 1fr) 420px;
gap: 4rem;
align-items: start;
padding: 2rem 0;
@media (max-width: 900px) {
grid-template-columns: 1fr;

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 { Router } from '@angular/router';
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 { CheckoutDataStepComponent } from './checkout-data-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({
selector: 'app-checkout-page',
@@ -24,19 +43,27 @@ import { CheckoutForm, PaymentMethod, PaymentMethodOption, TransferAccount, Tran
StepperComponent,
StepComponent,
CheckoutDataStepComponent,
CheckoutPaymentStepComponent
CheckoutPaymentStepComponent,
],
templateUrl: './checkout-page.component.html',
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 cartService = inject(CartService);
private readonly router = inject(Router);
private readonly tenantService = inject(TenantService);
private readonly checkoutService = inject(CheckoutService);
private readonly authService = inject(AuthService);
private readonly qrPollingIntervalMs = 5_000;
private readonly qrPollingMaxAttempts = 60;
private qrPollingTimeoutId: ReturnType<typeof setTimeout> | null = null;
private qrPollingAttempts = 0;
private qrPollingRunId = 0;
private paymentMethodRequestId = 0;
private navigationStarted = false;
@ViewChild(StepperComponent) stepper!: StepperComponent;
@@ -44,7 +71,7 @@ export class CheckoutPageComponent implements OnInit {
nombre: ['', [Validators.required]],
email: ['', [Validators.required, Validators.email]],
dni: ['', [Validators.required]],
telefono: ['', [Validators.required]]
telefono: ['', [Validators.required]],
});
protected readonly cartSubtotal = computed(() => {
@@ -66,32 +93,33 @@ export class CheckoutPageComponent implements OnInit {
protected readonly paymentMethods: ReadonlyArray<PaymentMethodOption> = [
{ id: 'qr', label: 'QR' },
{ id: 'transfer', label: 'Transferencia' },
{ id: 'telepagos', label: 'TelePagos' }
{ id: 'telepagos', label: 'TelePagos' },
];
protected readonly selectedPaymentMethod = signal<PaymentMethod>('qr');
protected readonly copiedTransferField = signal<TransferField | null>(null);
protected readonly transferAccount = signal<TransferAccount>({
titular: 'Nombre y Apellido',
entidad: 'TelePagos',
cvu: '0000000000000000000000',
alias: 'telepagos.ar'
});
protected readonly transferAccount = signal<TransferAccount | null>(null);
protected readonly transferDni = signal<string>('');
protected readonly isCreatingPurchase = signal(false);
protected readonly createdPurchaseId = signal<number | null>(null);
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 qrPaymentStatus = signal<QrPaymentStatus>('idle');
protected readonly isCheckingQrPayment = signal(false);
protected readonly transferValidationStatus = signal<TransferValidationStatus>('idle');
constructor() {
effect(() => {
// 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.
const cart = this.cartService.cart();
untracked(() => {
const purchaseId = this.createdPurchaseId();
if (cart && purchaseId) {
if (cart && purchaseId && !this.navigationStarted) {
// Trigger payment intent generation when cart changes and we are on the payment step
void this.selectPaymentMethod(this.selectedPaymentMethod());
}
@@ -108,7 +136,7 @@ export class CheckoutPageComponent implements OnInit {
nombre: user.nombre_apellido,
email: user.email,
dni: user.dni ?? '',
telefono: user.telefono ?? ''
telefono: user.telefono ?? '',
});
this.form.controls.nombre.disable();
this.form.controls.email.disable();
@@ -119,6 +147,10 @@ export class CheckoutPageComponent implements OnInit {
this.cartService.loadCart().subscribe();
}
ngOnDestroy(): void {
this.stopQrPolling();
}
private mapCartItemToMock(item: CartItem): CartItemMock {
const fullName = item.product?.nombre ?? '';
let product = fullName;
@@ -138,14 +170,14 @@ export class CheckoutPageComponent implements OnInit {
}
return {
productVariantId: item.product_variant_id,
cartItemId: item.id,
imageUrl: item.product?.imagen ?? null,
product,
originalPrice: null,
discountedPrice: parseFloat(item.precio_unitario),
discountPercentage: null,
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);
this.createdPurchaseId.set(response.id);
this.stepper.next();
// Auto trigger intent for default option
void this.selectPaymentMethod(this.selectedPaymentMethod());
} catch (error) {
console.error('Failed to create purchase:', error);
// Here we could show an alert or toast
@@ -189,11 +220,24 @@ export class CheckoutPageComponent implements OnInit {
}
protected onCancel(): void {
this.stopQrPolling();
void this.router.navigate(['/']);
}
protected async selectPaymentMethod(method: PaymentMethod): Promise<void> {
if (this.navigationStarted) {
return;
}
this.stopQrPolling();
this.qrPaymentStatus.set('idle');
this.transferValidationStatus.set('idle');
this.selectedPaymentMethod.set(method);
const requestId = ++this.paymentMethodRequestId;
if (method === 'qr') {
this.qrData.set(null);
}
const purchaseId = this.createdPurchaseId();
const tenant = this.tenantService.tenant();
@@ -202,23 +246,64 @@ export class CheckoutPageComponent implements OnInit {
return;
}
if (method === 'transfer') {
this.transferAccount.set(null);
return;
}
this.isGeneratingIntent.set(true);
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 (requestId !== this.paymentMethodRequestId || this.selectedPaymentMethod() !== 'qr') {
return;
}
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({
titular: response.transfer_data.titular,
entidad: response.transfer_data.entidad,
cvu: response.transfer_data.cvu,
alias: response.transfer_data.alias
alias: response.transfer_data.alias,
});
}
} catch (error) {
console.error('Failed to generate payment intent:', error);
console.error('Failed to generate transfer payment intent:', error);
} finally {
this.isGeneratingIntent.set(false);
}
@@ -245,16 +330,140 @@ export class CheckoutPageComponent implements OnInit {
const purchaseId = this.createdPurchaseId();
const tenant = this.tenantService.tenant();
if (!purchaseId || !tenant) {
if (
!purchaseId ||
!tenant ||
this.navigationStarted ||
this.transferValidationStatus() === 'checking'
) {
return;
}
this.transferValidationStatus.set('checking');
try {
await this.checkoutService.completePurchase(tenant.codigo, purchaseId);
this.cartService.clearCart();
void this.router.navigate(['/checkout/status', purchaseId]);
const purchase = await this.checkoutService.getPurchase(tenant.codigo, purchaseId);
if (purchase.status === 'paid') {
this.handleConfirmedPayment(purchaseId);
return;
}
if (purchase.status === 'pending_payment') {
this.transferValidationStatus.set('pending');
this.navigateToPurchaseStatus(purchaseId);
return;
}
this.transferValidationStatus.set('pending');
} catch (error) {
console.error('Failed to complete purchase:', error);
console.error('Failed to validate transfer payment:', error);
this.transferValidationStatus.set('error');
}
}
protected retryQrPolling(): void {
if (this.selectedPaymentMethod() === 'qr' && this.qrData()) {
this.startQrPolling();
}
}
private startQrPolling(): void {
this.stopQrPolling();
this.qrPollingAttempts = 0;
this.qrPaymentStatus.set('waiting');
const runId = this.qrPollingRunId;
this.scheduleQrPoll(runId);
}
private scheduleQrPoll(runId: number): void {
this.qrPollingTimeoutId = setTimeout(() => {
this.qrPollingTimeoutId = null;
void this.checkQrPayment(runId);
}, this.qrPollingIntervalMs);
}
private async checkQrPayment(runId: number): Promise<void> {
if (runId !== this.qrPollingRunId) {
return;
}
const purchaseId = this.createdPurchaseId();
const tenant = this.tenantService.tenant();
if (!purchaseId || !tenant || this.selectedPaymentMethod() !== 'qr') {
this.stopQrPolling();
return;
}
this.qrPollingAttempts += 1;
this.isCheckingQrPayment.set(true);
try {
const purchase = await this.checkoutService.getPurchase(tenant.codigo, purchaseId);
if (runId !== this.qrPollingRunId) {
return;
}
if (purchase.status === 'paid') {
this.handleConfirmedPayment(purchaseId);
return;
}
if (purchase.status === 'rejected' || purchase.status === 'cancelled') {
this.stopQrPolling();
this.qrPaymentStatus.set('failed');
return;
}
} catch (error) {
console.error('Failed to validate QR payment:', error);
} finally {
if (runId === this.qrPollingRunId) {
this.isCheckingQrPayment.set(false);
}
}
if (runId !== this.qrPollingRunId) {
return;
}
if (this.qrPollingAttempts >= this.qrPollingMaxAttempts) {
this.stopQrPolling();
this.qrPaymentStatus.set('timed_out');
return;
}
this.scheduleQrPoll(runId);
}
private stopQrPolling(): void {
this.qrPollingRunId += 1;
this.isCheckingQrPayment.set(false);
if (this.qrPollingTimeoutId !== null) {
clearTimeout(this.qrPollingTimeoutId);
this.qrPollingTimeoutId = null;
}
}
private handleConfirmedPayment(purchaseId: number): void {
this.navigateToPurchaseStatus(purchaseId, true);
}
private navigateToPurchaseStatus(purchaseId: number, clearCart = false): void {
if (this.navigationStarted) {
return;
}
this.navigationStarted = true;
this.stopQrPolling();
if (clearCart) {
this.cartService.clearCart();
}
void this.router.navigate(['/checkout/status', purchaseId]);
}
}

View File

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

View File

@@ -40,114 +40,26 @@
<h3 class="payment-panel__title">Cargando información de pago...</h3>
</div>
} @else if (selectedPaymentMethod() === 'qr') {
<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>
}
</div>
</div>
<app-checkout-payment-qr
[qrData]="qrData()"
[paymentStatus]="qrPaymentStatus()"
[isCheckingPayment]="isCheckingQrPayment()"
(retryPolling)="retryQrPolling.emit()"
/>
} @else if (selectedPaymentMethod() === 'transfer') {
<div class="payment-panel__card">
<h3 class="payment-panel__title">Transferi a la siguiente cuenta desde cualquier billetera</h3>
<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>
<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>
<app-checkout-payment-transfer
[transferAccount]="transferAccount()"
[transferDni]="transferDni()"
[copiedTransferField]="copiedTransferField()"
[validationStatus]="transferValidationStatus()"
(copyTransferValue)="requestCopy($event.field, $event.value)"
(submitDni)="generateTransferIntent.emit($event)"
(completePurchase)="complete.emit()"
/>
} @else {
<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>
<app-checkout-payment-telepagos />
}
</section>
</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>

View File

@@ -23,8 +23,8 @@
&__action-btn {
width: 100%;
flex: 1 1 290px;
max-width: 290px;
flex: 1 1 320px;
max-width: 320px;
display: block;
}
}
@@ -112,7 +112,7 @@
min-width: 0;
&__card {
width: 100%;
max-width: 290px;
max-width: 320px;
min-height: 269px;
padding: 1.5rem 1.75rem;
border-radius: 5px;
@@ -134,173 +134,5 @@
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 { ButtonComponent } from '../../../../shared/components/button/button.component';
import { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.component';
import { QRCodeComponent } from '../../../../shared/components/qrcode/qrcode.component';
import { PaymentMethod, PaymentMethodOption, TransferAccount, TransferField } from './checkout-page.models';
import { CheckoutPaymentQrComponent } from './components/checkout-payment-qr/checkout-payment-qr.component';
import { CheckoutPaymentTelepagosComponent } from './components/checkout-payment-telepagos/checkout-payment-telepagos.component';
import { CheckoutPaymentTransferComponent } from './components/checkout-payment-transfer/checkout-payment-transfer.component';
import {
PaymentMethod,
PaymentMethodOption,
QrPaymentStatus,
TransferAccount,
TransferField,
TransferValidationStatus,
} from './checkout-page.models';
@Component({
selector: 'app-checkout-payment-step',
standalone: true,
imports: [ButtonComponent, IconButtonComponent, QRCodeComponent],
imports: [CheckoutPaymentQrComponent, CheckoutPaymentTelepagosComponent, CheckoutPaymentTransferComponent],
templateUrl: './checkout-payment-step.component.html',
styleUrl: './checkout-payment-step.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
@@ -17,14 +24,20 @@ export class CheckoutPaymentStepComponent {
readonly paymentMethods = input.required<ReadonlyArray<PaymentMethodOption>>();
readonly selectedPaymentMethod = input.required<PaymentMethod>();
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 qrData = input<string | null>(null);
readonly qrPaymentStatus = input<QrPaymentStatus>('idle');
readonly isCheckingQrPayment = input<boolean>(false);
readonly transferValidationStatus = input<TransferValidationStatus>('idle');
readonly paymentMethodChange = output<PaymentMethod>();
readonly copyTransferValue = output<{ field: TransferField; value: string }>();
readonly cancelStep = output<void>();
readonly complete = output<void>();
readonly generateTransferIntent = output<string>();
readonly retryQrPolling = output<void>();
protected selectPaymentMethod(method: PaymentMethod): void {
this.paymentMethodChange.emit(method);

View File

@@ -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

@@ -27,11 +27,11 @@
}
<div class="d-grid gap-1">
<label class="visually-hidden" for="login-password">Contrasena</label>
<label class="visually-hidden" for="login-password">Contraseña</label>
<app-input
id="login-password"
type="password"
placeholder="Contrasena"
placeholder="Contraseña"
[value]="form.controls.password.value"
[invalid]="showControlError('password')"
(valueChange)="updatePassword($event)"
@@ -41,7 +41,7 @@
}
<div class="text-end">
<button type="button" class="login-page__forgot-password btn btn-link p-0 border-0 text-decoration-none">
Olvide mi contrasena
Olvide mi contraseña
</button>
</div>
</div>
@@ -81,7 +81,8 @@
variant="neutral-outline"
hostClass="w-100 d-block"
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
xmlns="http://www.w3.org/2000/svg"

View File

@@ -1,7 +1,7 @@
import { DOCUMENT } from '@angular/common';
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { DOCUMENT, isPlatformBrowser } from '@angular/common';
import { ChangeDetectionStrategy, Component, inject, PLATFORM_ID, signal } from '@angular/core';
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 { ButtonComponent } from '../../../../shared/components/button/button.component';
@@ -20,8 +20,10 @@ const EMAIL_MAX_LENGTH = 255;
export class LoginPageComponent {
private readonly formBuilder = inject(FormBuilder);
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
private readonly authService = inject(AuthService);
private readonly document = inject(DOCUMENT);
private readonly platformId = inject(PLATFORM_ID);
private readonly submittedState = signal(false);
private readonly serverErrorState = signal<string | null>(null);
@@ -35,6 +37,18 @@ export class LoginPageComponent {
protected readonly serverError = this.serverErrorState.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 {
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 {
this.form.controls.email.setValue(String(value));
}
@@ -107,6 +131,22 @@ export class LoginPageComponent {
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 {
const errorPayload =
typeof error === 'object' && error !== null && 'error' in error

View File

@@ -1,120 +1,120 @@
<section class="product-detail py-5">
<div class="container-xl px-3 px-md-4">
@if (loading()) {
<div class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Cargando...</span>
<section class="product-detail">
@if (loading()) {
<div class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<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>
} @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="product-detail__panel">
<section class="product-detail__section">
<h1 class="product-detail__title mb-0">{{ prod.nombre }}</h1>
<div class="col-12 col-lg-7">
<div class="product-detail__panel">
<section class="product-detail__section">
<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">
<span class="product-detail__price-current">
{{ getFormattedPrice(prod.precio) }}
</span>
<div class="product-detail__price-group">
<span class="product-detail__price-current">
{{ getFormattedPrice(prod.precio) }}
</span>
@if (oldPrice(); as oldPrice) {
<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>
@if (oldPrice(); as oldPrice) {
<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()"
[variants]="prod.variants"
[selectedVariant]="prod.selected_variant ?? null"
[inventoryPolicy]="prod.inventory_policy"
(variantChange)="onVariantChange($event)"
/>
</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>
} @else {
<div class="alert alert-warning text-center mb-0" role="alert">
No se encontró el producto especificado.
</div>
}
</div>
</div>
} @else {
<div class="alert alert-warning text-center mb-0" role="alert">
No se encontró el producto especificado.
</div>
}
</section>

View File

@@ -1,22 +1,22 @@
import { TestBed, getTestBed } from '@angular/core/testing';
import {
BrowserTestingModule,
platformBrowserTesting
} from '@angular/platform-browser/testing';
import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
import { ActivatedRoute, Router } from '@angular/router';
import { By } from '@angular/platform-browser';
import { BehaviorSubject, of, throwError } from 'rxjs';
import { convertToParamMap } from '@angular/router';
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { ProductDetail } from '../../../../core/services/catalog/catalog.interface';
import { CatalogItemDetail } from '../../../../core/services/catalog/catalog.interface';
import { CatalogService } from '../../../../core/services/catalog/catalog.service';
import { CartService } from '../../../../core/services/cart/cart.service';
import { ToastService } from '../../../../core/services/toast.service';
import { ProductDetailPageComponent } from './product-detail-page.component';
import {
PRODUCT_DETAIL_ERROR_MESSAGE,
ProductDetailResolvedData,
} from './product-detail-page.resolver';
describe('ProductDetailPageComponent', () => {
const mockProduct: ProductDetail = {
const mockProduct: CatalogItemDetail = {
id: 1,
category_id: 10,
brand_id: null,
@@ -27,12 +27,16 @@ describe('ProductDetailPageComponent', () => {
category: 'Tecnología',
brand: 'Sony',
images: ['https://example.com/image.png'],
inventory_policy: 'tracked',
has_tickets: false,
minimum_use_date: null,
maximum_use_date: null,
stock_tecnico: 10,
attributes: [],
variants_map: [],
variant: null
variants: [],
};
let paramMapSubject: BehaviorSubject<any>;
let routeDataSubject: BehaviorSubject<{ productDetailData: ProductDetailResolvedData }>;
let catalogServiceStub: any;
let routerStub: any;
let cartServiceStub: any;
@@ -40,10 +44,7 @@ describe('ProductDetailPageComponent', () => {
beforeAll(() => {
try {
getTestBed().initTestEnvironment(
BrowserTestingModule,
platformBrowserTesting()
);
getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
} catch {
// Test environment may already be initialized by another setup entrypoint.
}
@@ -51,20 +52,25 @@ describe('ProductDetailPageComponent', () => {
beforeEach(() => {
vi.restoreAllMocks();
paramMapSubject = new BehaviorSubject(convertToParamMap({ id: '1' }));
routeDataSubject = new BehaviorSubject<{ productDetailData: ProductDetailResolvedData }>({
productDetailData: {
product: mockProduct,
error: null,
},
});
catalogServiceStub = {
getProducto: vi.fn().mockReturnValue(of(mockProduct))
getCatalogItem: vi.fn(),
};
routerStub = {
navigate: vi.fn()
navigate: vi.fn(),
};
cartServiceStub = {
addItem: vi.fn().mockReturnValue(of({ message: 'Producto agregado al carrito' }))
addItem: vi.fn().mockReturnValue(of({ message: 'Producto agregado al carrito' })),
};
toastServiceStub = {
success: vi.fn(),
danger: vi.fn(),
info: vi.fn()
info: vi.fn(),
};
});
@@ -75,47 +81,69 @@ describe('ProductDetailPageComponent', () => {
{
provide: ActivatedRoute,
useValue: {
paramMap: paramMapSubject.asObservable()
}
data: routeDataSubject.asObservable(),
},
},
{
provide: CatalogService,
useValue: catalogServiceStub
useValue: catalogServiceStub,
},
{
provide: Router,
useValue: routerStub
useValue: routerStub,
},
{
provide: CartService,
useValue: cartServiceStub
useValue: cartServiceStub,
},
{
provide: ToastService,
useValue: toastServiceStub
}
]
useValue: toastServiceStub,
},
],
}).compileComponents();
}
it('loads the product on init and embeds the carousel', async () => {
function resolveProduct(product: CatalogItemDetail): void {
routeDataSubject.next({
productDetailData: {
product,
error: null,
},
});
}
function resolveProductError(error: string): void {
routeDataSubject.next({
productDetailData: {
product: null,
error,
},
});
}
it('renders the resolved product and embeds the carousel', async () => {
await configureTestingModule();
const fixture = TestBed.createComponent(ProductDetailPageComponent);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(catalogServiceStub.getProducto).toHaveBeenCalledWith(1);
expect(catalogServiceStub.getCatalogItem).not.toHaveBeenCalled();
expect(element.querySelector('app-product-carousel')).not.toBeNull();
expect(element.querySelector('.product-detail__title')?.textContent).toContain('Auriculares Bluetooth');
expect(element.querySelector('.product-detail__price-current')?.textContent).toContain('$24.999');
expect(element.querySelector('.product-detail__title')?.textContent).toContain(
'Auriculares Bluetooth',
);
expect(element.querySelector('.product-detail__price-current')?.textContent).toContain(
'$24.999',
);
expect(element.querySelector('.product-detail__price-previous')).toBeNull();
expect(element.querySelector('.product-carousel__discount-badge')).toBeNull();
});
it('shows error message if load fails', async () => {
catalogServiceStub.getProducto.mockReturnValue(throwError(() => new Error('load failed')));
it('shows error message if the resolver cannot load the product', async () => {
resolveProductError(PRODUCT_DETAIL_ERROR_MESSAGE);
await configureTestingModule();
const fixture = TestBed.createComponent(ProductDetailPageComponent);
fixture.detectChanges();
@@ -125,35 +153,37 @@ describe('ProductDetailPageComponent', () => {
expect(element.textContent).toContain('No pudimos cargar los detalles del producto.');
});
it('should use default variant images if present', async () => {
const detailProduct: ProductDetail = {
const detailProduct: CatalogItemDetail = {
...mockProduct,
images: ['https://example.com/product.png'],
variant: {
variants: [{ id: 123, stock_tecnico: 10, values: {} }],
selected_variant: {
id: 123,
cantidad_maxima: 10,
stock_tecnico: 10,
images: ['https://example.com/variant1.png', 'https://example.com/variant2.png'],
definitions: {}
}
values: {},
},
};
catalogServiceStub.getProducto.mockReturnValue(of(detailProduct));
resolveProduct(detailProduct);
await configureTestingModule();
const fixture = TestBed.createComponent(ProductDetailPageComponent);
fixture.detectChanges();
const carousel = fixture.debugElement.query(By.css('app-product-carousel')).componentInstance;
expect(carousel.images()).toEqual(['https://example.com/variant1.png', 'https://example.com/variant2.png']);
expect(carousel.images()).toEqual([
'https://example.com/variant1.png',
'https://example.com/variant2.png',
]);
});
it('should fallback to product images if default variant images are not present', async () => {
const detailProduct: ProductDetail = {
const detailProduct: CatalogItemDetail = {
...mockProduct,
images: ['https://example.com/product.png'],
variant: null
};
catalogServiceStub.getProducto.mockReturnValue(of(detailProduct));
resolveProduct(detailProduct);
await configureTestingModule();
const fixture = TestBed.createComponent(ProductDetailPageComponent);
@@ -164,12 +194,11 @@ describe('ProductDetailPageComponent', () => {
});
it('should fallback to empty array if no images are present at all', async () => {
const detailProduct: ProductDetail = {
const detailProduct: CatalogItemDetail = {
...mockProduct,
images: [],
variant: null
};
catalogServiceStub.getProducto.mockReturnValue(of(detailProduct));
resolveProduct(detailProduct);
await configureTestingModule();
const fixture = TestBed.createComponent(ProductDetailPageComponent);
@@ -180,7 +209,7 @@ describe('ProductDetailPageComponent', () => {
});
it('renders generic attributes and preselects default variant options', async () => {
const detailProduct: ProductDetail = {
const detailProduct: CatalogItemDetail = {
...mockProduct,
attributes: [
{
@@ -196,16 +225,16 @@ describe('ProductDetailPageComponent', () => {
value: 'beige',
label: 'Beige',
sort_order: 1,
metadata: { hex: '#D8D1C7' }
metadata: { hex: '#D8D1C7' },
},
{
id: 11,
value: 'brown',
label: 'Marrón',
sort_order: 2,
metadata: { palette: { primary: '#7E6460' } }
}
]
metadata: { palette: { primary: '#7E6460' } },
},
],
},
{
id: 2,
@@ -220,29 +249,30 @@ describe('ProductDetailPageComponent', () => {
value: 'mesh',
label: 'Mesh',
sort_order: 1,
metadata: null
metadata: null,
},
{
id: 21,
value: 'cuero',
label: 'Cuero',
sort_order: 2,
metadata: null
}
]
}
metadata: null,
},
],
},
],
variant: {
variants: [{ id: 123, stock_tecnico: 10, values: { color: 'beige', material: 'Cuero' } }],
selected_variant: {
id: 123,
cantidad_maxima: 10,
stock_tecnico: 10,
images: ['https://example.com/variant1.png'],
definitions: {
values: {
color: 'beige',
material: 'Cuero'
}
}
material: 'Cuero',
},
},
};
catalogServiceStub.getProducto.mockReturnValue(of(detailProduct));
resolveProduct(detailProduct);
await configureTestingModule();
const fixture = TestBed.createComponent(ProductDetailPageComponent);
@@ -252,14 +282,16 @@ describe('ProductDetailPageComponent', () => {
const swatches = element.querySelectorAll('.attribute-selector__swatch');
const textOptions = element.querySelectorAll('.attribute-selector__text-option');
const labels = Array.from(element.querySelectorAll('.attribute-selector__label')).map((label) =>
label.textContent?.trim()
label.textContent?.trim(),
);
expect(labels).toEqual(['Color:', 'Material:']);
expect(swatches).toHaveLength(2);
expect(textOptions).toHaveLength(2);
expect(swatches[0].classList.contains('attribute-selector__swatch--selected')).toBe(true);
expect(textOptions[1].classList.contains('attribute-selector__text-option--selected')).toBe(true);
expect(textOptions[1].classList.contains('attribute-selector__text-option--selected')).toBe(
true,
);
expect((swatches[0] as HTMLElement).style.backgroundColor).not.toBe('');
});
@@ -280,18 +312,18 @@ describe('ProductDetailPageComponent', () => {
fixture.detectChanges();
fixture.componentInstance['selectedVariant'].set({
variant_id: 1,
cantidad_maxima: 10,
attributes: {}
id: 1,
stock_tecnico: 10,
values: {},
});
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const decreaseButton = element.querySelector(
'[aria-label="Disminuir cantidad"]'
'[aria-label="Disminuir cantidad"]',
) as HTMLButtonElement;
const increaseButton = element.querySelector(
'[aria-label="Aumentar cantidad"]'
'[aria-label="Aumentar cantidad"]',
) as HTMLButtonElement;
decreaseButton.click();
@@ -316,7 +348,7 @@ describe('ProductDetailPageComponent', () => {
const element = fixture.nativeElement as HTMLElement;
const toggleButton = element.querySelector(
'.product-detail__description-toggle'
'.product-detail__description-toggle',
) as HTMLButtonElement;
expect(toggleButton.textContent?.trim()).toBe('Mostrar más');
@@ -327,7 +359,7 @@ describe('ProductDetailPageComponent', () => {
expect(
element
.querySelector('.product-detail__description-body')
?.classList.contains('product-detail__description-body--expanded')
?.classList.contains('product-detail__description-body--expanded'),
).toBe(true);
expect(toggleButton.textContent?.trim()).toBe('Mostrar menos');
});
@@ -338,12 +370,12 @@ describe('ProductDetailPageComponent', () => {
fixture.detectChanges();
const buttons = Array.from(
fixture.nativeElement.querySelectorAll('app-button button')
fixture.nativeElement.querySelectorAll('app-button button'),
) as HTMLButtonElement[];
expect(buttons.map((button) => button.textContent?.trim())).toEqual([
'Agregar al carrito',
'Comprar'
'Comprar',
]);
buttons[0].click();
@@ -353,82 +385,168 @@ describe('ProductDetailPageComponent', () => {
});
it('calls CartService.addItem when variant is selected and Add to Cart is clicked', async () => {
const detailProduct: ProductDetail = {
const detailProduct: CatalogItemDetail = {
...mockProduct,
variant: {
id: 123,
cantidad_maxima: 5,
images: [],
definitions: {}
},
variants_map: [
variants: [
{
variant_id: 123,
cantidad_maxima: 5,
attributes: {}
}
]
id: 123,
stock_tecnico: 5,
values: {},
},
],
selected_variant: {
id: 123,
stock_tecnico: 5,
images: [],
values: {},
},
};
catalogServiceStub.getProducto.mockReturnValue(of(detailProduct));
resolveProduct(detailProduct);
await configureTestingModule();
const fixture = TestBed.createComponent(ProductDetailPageComponent);
fixture.detectChanges();
// Select the variant
fixture.componentInstance['selectedVariant'].set(detailProduct.variants_map[0]);
fixture.componentInstance['selectedVariant'].set(detailProduct.variants[0]);
fixture.componentInstance['quantity'].set(3);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const addToCartButton = Array.from(element.querySelectorAll('app-button button')).find(
(btn) => btn.textContent?.trim() === 'Agregar al carrito'
(btn) => btn.textContent?.trim() === 'Agregar al carrito',
) as HTMLButtonElement;
expect(addToCartButton).toBeDefined();
addToCartButton.click();
fixture.detectChanges();
expect(cartServiceStub.addItem).toHaveBeenCalledWith(123, 3);
expect(cartServiceStub.addItem).toHaveBeenCalledWith(1, 123, 3);
expect(toastServiceStub.success).toHaveBeenCalledWith('Producto agregado al carrito');
});
it('shows error toast when CartService.addItem fails', async () => {
const detailProduct: ProductDetail = {
it('adds a catalog item without variants to the cart', async () => {
resolveProduct({
...mockProduct,
variant: {
id: 123,
cantidad_maxima: 5,
images: [],
definitions: {}
},
variants_map: [
variants: [],
stock_tecnico: 4,
});
await configureTestingModule();
const fixture = TestBed.createComponent(ProductDetailPageComponent);
fixture.detectChanges();
const buttons = Array.from(
fixture.nativeElement.querySelectorAll('app-button button'),
) as HTMLButtonElement[];
const addToCartButton = buttons.find(
(button) => button.textContent?.trim() === 'Agregar al carrito',
) as HTMLButtonElement;
addToCartButton.click();
fixture.detectChanges();
expect(cartServiceStub.addItem).toHaveBeenCalledWith(1, null, 1);
});
it('shows error toast when CartService.addItem fails', async () => {
const detailProduct: CatalogItemDetail = {
...mockProduct,
variants: [
{
variant_id: 123,
cantidad_maxima: 5,
attributes: {}
}
]
id: 123,
stock_tecnico: 5,
values: {},
},
],
selected_variant: {
id: 123,
stock_tecnico: 5,
images: [],
values: {},
},
};
catalogServiceStub.getProducto.mockReturnValue(of(detailProduct));
resolveProduct(detailProduct);
cartServiceStub.addItem.mockReturnValue(throwError(() => new Error('Failed to add')));
await configureTestingModule();
const fixture = TestBed.createComponent(ProductDetailPageComponent);
fixture.detectChanges();
fixture.componentInstance['selectedVariant'].set(detailProduct.variants_map[0]);
fixture.componentInstance['selectedVariant'].set(detailProduct.variants[0]);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const addToCartButton = Array.from(element.querySelectorAll('app-button button')).find(
(btn) => btn.textContent?.trim() === 'Agregar al carrito'
(btn) => btn.textContent?.trim() === 'Agregar al carrito',
) as HTMLButtonElement;
addToCartButton.click();
fixture.detectChanges();
expect(cartServiceStub.addItem).toHaveBeenCalled();
expect(toastServiceStub.danger).toHaveBeenCalledWith('No se pudo agregar el producto al carrito.');
expect(toastServiceStub.danger).toHaveBeenCalledWith(
'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

@@ -9,7 +9,7 @@ import {
inject,
signal,
viewChild,
PLATFORM_ID
PLATFORM_ID,
} from '@angular/core';
import { CommonModule, isPlatformBrowser } from '@angular/common';
import { HttpErrorResponse } from '@angular/common/http';
@@ -20,16 +20,14 @@ import { CatalogService } from '../../../../core/services/catalog/catalog.servic
import { ToastService } from '../../../../core/services/toast.service';
import { CartService } from '../../../../core/services/cart/cart.service';
import {
ProductAttribute,
ProductAttributeOption,
ProductDetail,
ProductVariant,
ProductVariantMap
CatalogItemDetail,
CatalogItemVariant,
} from '../../../../core/services/catalog/catalog.interface';
import { ProductCarouselComponent } from '../../components/product-carousel/product-carousel.component';
import { ButtonComponent } from '../../../../shared/components/button/button.component';
import { ProductAttributeSelectorComponent } from '../../components/product-attribute-selector/product-attribute-selector.component';
import { QuantitySelectorComponent } from '../../../../shared/components/quantity-selector/quantity-selector.component';
import { ProductDetailResolvedData } from './product-detail-page.resolver';
@Component({
selector: 'app-product-detail-page',
@@ -40,11 +38,11 @@ import { QuantitySelectorComponent } from '../../../../shared/components/quantit
ProductCarouselComponent,
ButtonComponent,
ProductAttributeSelectorComponent,
QuantitySelectorComponent
QuantitySelectorComponent,
],
templateUrl: './product-detail-page.component.html',
styleUrl: './product-detail-page.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ProductDetailPageComponent implements OnInit, OnDestroy {
private readonly route = inject(ActivatedRoute);
@@ -64,12 +62,12 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
private observedCarouselPreview: HTMLElement | null = null;
private measurementTimer: ReturnType<typeof setTimeout> | null = null;
protected readonly product = signal<ProductDetail | null>(null);
protected readonly product = signal<CatalogItemDetail | null>(null);
protected readonly carouselImages = computed(() => {
const prod = this.product();
if (!prod) return [];
if (prod.variant?.images && prod.variant.images.length > 0) {
return prod.variant.images;
if (prod.selected_variant?.images && prod.selected_variant.images.length > 0) {
return prod.selected_variant.images;
}
if (prod.images && prod.images.length > 0) {
return prod.images;
@@ -80,20 +78,37 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
protected readonly variantLoading = signal(false);
protected readonly addingToCart = signal(false);
protected readonly error = signal<string | null>(null);
protected readonly selectedVariant = signal<ProductVariantMap | null>(null);
protected readonly selectedVariant = signal<CatalogItemVariant | null>(null);
protected readonly quantity = signal(1);
protected readonly selectedVariantMax = computed<number | null>(() => {
const prod = this.product();
const variant = this.selectedVariant();
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 descriptionMaxHeight = signal(0);
protected readonly descriptionHasOverflow = signal(false);
protected readonly renderableAttributes = computed(() =>
(this.product()?.attributes ?? []).filter((attribute) => attribute.options.length > 0)
(this.product()?.attributes ?? []).filter((attribute) => attribute.options.length > 0),
);
protected readonly hasRenderableAttributes = computed(
() => this.renderableAttributes().length > 0
() => this.renderableAttributes().length > 0,
);
protected readonly oldPrice = computed<string | null>(() => null);
protected readonly showDescriptionToggle = computed(
() => this.descriptionExpanded() || this.descriptionHasOverflow()
() => this.descriptionExpanded() || this.descriptionHasOverflow(),
);
constructor() {
@@ -113,14 +128,12 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
}
ngOnInit(): void {
this.routeSub = this.route.paramMap.subscribe((params) => {
const id = this.parseIntegerParam(params.get('id'));
if (id === null) {
this.error.set('ID de producto inválido');
return;
}
this.routeSub = this.route.data.subscribe((data) => {
const resolvedData = data['productDetailData'] as ProductDetailResolvedData | undefined;
this.loadProduct(id);
if (resolvedData) {
this.applyResolvedData(resolvedData);
}
});
}
@@ -131,38 +144,13 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
this.clearMeasurementTimer();
}
private loadProduct(id: number): void {
this.loading.set(true);
this.error.set(null);
this.product.set(null);
this.productSub?.unsubscribe();
this.productSub = this.catalogService.getProducto(id).subscribe({
next: (prod) => {
this.product.set(prod);
const matchingVariant = prod.variants_map.find((v) => v.variant_id === prod.variant?.id) || null;
this.selectedVariant.set(matchingVariant);
this.quantity.set(1);
this.descriptionExpanded.set(false);
this.descriptionHasOverflow.set(false);
this.loading.set(false);
},
error: () => {
this.error.set('No pudimos cargar los detalles del producto.');
this.loading.set(false);
}
});
}
private loadProductVariant(productId: number, variantId: number): void {
this.variantLoading.set(true);
this.productSub?.unsubscribe();
this.productSub = this.catalogService.getProducto(productId, variantId).subscribe({
this.productSub = this.catalogService.getCatalogItem(productId, variantId).subscribe({
next: (prod) => {
this.product.set(prod);
const matchingVariant = prod.variants_map.find((v) => v.variant_id === prod.variant?.id) || null;
this.selectedVariant.set(matchingVariant);
this.applyProduct(prod, false);
this.variantLoading.set(false);
},
error: (err: HttpErrorResponse) => {
@@ -172,23 +160,46 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
this.product.update((currentProduct) => {
if (!currentProduct) return null;
const updatedVariantsMap = currentProduct.variants_map.map((v) => {
if (v.variant_id === variantId) {
return { ...v, cantidad_maxima: 0 };
}
return v;
});
return {
...currentProduct,
variants_map: updatedVariantsMap
variants: currentProduct.variants.filter((variant) => variant.id !== variantId),
};
});
this.attributeSelector()?.reset();
}
},
});
}
private applyResolvedData(resolvedData: ProductDetailResolvedData): void {
this.productSub?.unsubscribe();
this.loading.set(false);
this.variantLoading.set(false);
if (resolvedData.error) {
this.product.set(null);
this.error.set(resolvedData.error);
return;
}
if (resolvedData.product) {
this.applyProduct(resolvedData.product, true);
}
}
private applyProduct(prod: CatalogItemDetail, resetQuantity: boolean): void {
this.product.set(prod);
const matchingVariant =
prod.variants.find((variant) => variant.id === prod.selected_variant?.id) || null;
this.selectedVariant.set(matchingVariant);
if (resetQuantity) {
this.quantity.set(1);
}
this.descriptionExpanded.set(false);
this.descriptionHasOverflow.set(false);
this.error.set(null);
}
protected goBack(): void {
this.router.navigate(['/']);
}
@@ -203,33 +214,32 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
return `$${parts.join(',')}`;
}
protected onVariantChange(variant: ProductVariantMap | null): void {
if (this.selectedVariant()?.variant_id === variant?.variant_id) {
protected onVariantChange(variant: CatalogItemVariant | null): void {
if (this.selectedVariant()?.id === variant?.id) {
return;
}
this.selectedVariant.set(variant);
if (variant && this.quantity() > variant.cantidad_maxima) {
this.quantity.set(Math.max(1, variant.cantidad_maxima));
if (variant && variant.stock_tecnico !== null && this.quantity() > variant.stock_tecnico) {
this.quantity.set(Math.max(1, variant.stock_tecnico));
}
const currentProduct = this.product();
if (variant && currentProduct && currentProduct.variant?.id !== variant.variant_id) {
this.loadProductVariant(currentProduct.id, variant.variant_id);
if (variant && currentProduct && currentProduct.selected_variant?.id !== variant.id) {
this.loadProductVariant(currentProduct.id, variant.id);
}
}
protected addToCart(): void {
const currentProduct = this.product();
const variant = this.selectedVariant();
if (!variant) {
if (!currentProduct || !this.selectedVariantAvailable()) {
this.toastService.danger('Por favor, selecciona una variante.');
return;
}
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) => {
const msg = res.message || 'Producto agregado al carrito';
this.toastService.success(msg);
@@ -239,23 +249,18 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
const errorMessage = err.error?.message || 'No se pudo agregar el producto al carrito.';
this.toastService.danger(errorMessage);
this.addingToCart.set(false);
}
},
});
}
private isVariantAvailable(variant: CatalogItemVariant, product: CatalogItemDetail): boolean {
return product.inventory_policy === 'unlimited' || (variant.stock_tecnico ?? 0) > 0;
}
protected toggleDescription(): void {
this.descriptionExpanded.update((current) => !current);
}
private parseIntegerParam(value: string | null): number | null {
if (!value) {
return null;
}
const parsed = Number(value);
return Number.isInteger(parsed) ? parsed : null;
}
private bindCarouselResizeObserver(): void {
const previewElement = this.getCarouselPreviewElement();
@@ -315,7 +320,7 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
}
this.descriptionHasOverflow.set(
descriptionElement.scrollHeight - descriptionElement.clientHeight > 1
descriptionElement.scrollHeight - descriptionElement.clientHeight > 1,
);
}

View File

@@ -0,0 +1,95 @@
import { TestBed } from '@angular/core/testing';
import { convertToParamMap } from '@angular/router';
import { firstValueFrom, Observable, of, throwError } from 'rxjs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { CatalogItemDetail } from '../../../../core/services/catalog/catalog.interface';
import { CatalogService } from '../../../../core/services/catalog/catalog.service';
import {
PRODUCT_DETAIL_ERROR_MESSAGE,
PRODUCT_DETAIL_INVALID_ID_MESSAGE,
ProductDetailResolvedData,
productDetailResolver,
} from './product-detail-page.resolver';
describe('productDetailResolver', () => {
const product: CatalogItemDetail = {
id: 1,
category_id: 10,
brand_id: null,
slug: 'auriculares-bluetooth',
nombre: 'Auriculares Bluetooth',
descripcion: 'Auriculares bluetooth de prueba',
precio: '24999',
category: 'Tecnologia',
brand: null,
images: [],
inventory_policy: 'tracked',
has_tickets: false,
minimum_use_date: null,
maximum_use_date: null,
stock_tecnico: 0,
attributes: [],
variants: [],
};
let catalogServiceStub: { getCatalogItem: ReturnType<typeof vi.fn> };
beforeEach(() => {
catalogServiceStub = {
getCatalogItem: vi.fn().mockReturnValue(of(product)),
};
TestBed.configureTestingModule({
providers: [
{
provide: CatalogService,
useValue: catalogServiceStub,
},
],
});
});
it('resolves the product detail for the route id', async () => {
const result = TestBed.runInInjectionContext(() =>
productDetailResolver(createRouteSnapshot('1'), {} as never),
);
await expect(firstValueFrom(result as Observable<ProductDetailResolvedData>)).resolves.toEqual({
product,
error: null,
});
expect(catalogServiceStub.getCatalogItem).toHaveBeenCalledWith(1);
});
it('returns an error state for invalid ids', async () => {
const result = TestBed.runInInjectionContext(() =>
productDetailResolver(createRouteSnapshot('abc'), {} as never),
);
await expect(firstValueFrom(result as Observable<ProductDetailResolvedData>)).resolves.toEqual({
product: null,
error: PRODUCT_DETAIL_INVALID_ID_MESSAGE,
});
expect(catalogServiceStub.getCatalogItem).not.toHaveBeenCalled();
});
it('returns an error state when the request fails', async () => {
catalogServiceStub.getCatalogItem.mockReturnValue(throwError(() => new Error('boom')));
const result = TestBed.runInInjectionContext(() =>
productDetailResolver(createRouteSnapshot('1'), {} as never),
);
await expect(firstValueFrom(result as Observable<ProductDetailResolvedData>)).resolves.toEqual({
product: null,
error: PRODUCT_DETAIL_ERROR_MESSAGE,
});
});
});
function createRouteSnapshot(id: string) {
return {
paramMap: convertToParamMap({ id }),
} as never;
}

View File

@@ -0,0 +1,53 @@
import { inject } from '@angular/core';
import { ActivatedRouteSnapshot, ResolveFn } from '@angular/router';
import { catchError, map, of } from 'rxjs';
import { CatalogItemDetail } from '../../../../core/services/catalog/catalog.interface';
import { CatalogService } from '../../../../core/services/catalog/catalog.service';
export const PRODUCT_DETAIL_ERROR_MESSAGE = 'No pudimos cargar los detalles del producto.';
export const PRODUCT_DETAIL_INVALID_ID_MESSAGE = 'ID de producto invalido';
export interface ProductDetailResolvedData {
product: CatalogItemDetail | null;
error: string | null;
}
export const productDetailResolver: ResolveFn<ProductDetailResolvedData> = (
route: ActivatedRouteSnapshot,
) => {
const productId = parseIntegerParam(route.paramMap.get('id'));
if (productId === null) {
return of({
product: null,
error: PRODUCT_DETAIL_INVALID_ID_MESSAGE,
});
}
return inject(CatalogService)
.getCatalogItem(productId)
.pipe(
map(
(product): ProductDetailResolvedData => ({
product,
error: null,
}),
),
catchError(() =>
of({
product: null,
error: PRODUCT_DETAIL_ERROR_MESSAGE,
}),
),
);
};
function parseIntegerParam(value: string | null): number | null {
if (!value) {
return null;
}
const parsed = Number(value);
return Number.isInteger(parsed) ? parsed : null;
}

View File

@@ -35,11 +35,11 @@
<small class="text-danger">{{ errorMessage }}</small>
}
<label class="visually-hidden" for="register-password">Contrasena</label>
<label class="visually-hidden" for="register-password">Contraseña</label>
<app-input
id="register-password"
type="password"
placeholder="Contrasena"
placeholder="Contraseña"
[value]="form.controls.password.value"
[invalid]="showControlError('password')"
(valueChange)="updateField('password', $event)"
@@ -48,11 +48,11 @@
<small class="text-danger">{{ errorMessage }}</small>
}
<label class="visually-hidden" for="register-password-repeat">Repetir Contrasena</label>
<label class="visually-hidden" for="register-password-repeat">Repetir Contraseña</label>
<app-input
id="register-password-repeat"
type="password"
placeholder="Repetir Contrasena"
placeholder="Repetir Contraseña"
[value]="form.controls.password_confirmation.value"
[invalid]="showControlError('password_confirmation')"
(valueChange)="updateField('password_confirmation', $event)"

View File

@@ -50,8 +50,8 @@ describe('RegisterPageComponent', () => {
component.form.setValue({
nombre_apellido: 'Ada Lovelace',
email: 'ada@example.com',
password: 'secret123',
password_confirmation: 'secret123'
password: 'Secret!123',
password_confirmation: 'Secret!123'
});
component.onSubmit();
@@ -59,8 +59,8 @@ describe('RegisterPageComponent', () => {
expect(authService.register).toHaveBeenCalledWith({
nombre_apellido: 'Ada Lovelace',
email: 'ada@example.com',
password: 'secret123',
password_confirmation: 'secret123'
password: 'Secret!123',
password_confirmation: 'Secret!123'
});
expect(modalService.openSimple).toHaveBeenCalledWith({
content: 'Tu cuenta fue creada correctamente',
@@ -102,7 +102,7 @@ describe('RegisterPageComponent', () => {
component.onSubmit();
expect(authService.register).not.toHaveBeenCalled();
expect(component.getControlError('password_confirmation')).toBe('Las contrasenas no coinciden.');
expect(component.getControlError('password_confirmation')).toBe('Las contraseñas no coinciden.');
});
it('validates max length and password minimum length before submit', async () => {
@@ -178,8 +178,8 @@ describe('RegisterPageComponent', () => {
component.form.setValue({
nombre_apellido: 'Ada Lovelace',
email: 'ada@example.com',
password: 'secret123',
password_confirmation: 'secret123'
password: 'Secret!123',
password_confirmation: 'Secret!123'
});
component.onSubmit();

View File

@@ -55,7 +55,11 @@ export class RegisterPageComponent {
'',
[Validators.required, Validators.email, Validators.maxLength(TEXT_MAX_LENGTH)]
],
password: ['', [Validators.required, Validators.minLength(PASSWORD_MIN_LENGTH)]],
password: ['', [
Validators.required,
Validators.minLength(PASSWORD_MIN_LENGTH),
Validators.pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*[\W_]).+$/)
]],
password_confirmation: ['', [Validators.required]]
}, {
validators: [passwordsMatchValidator]
@@ -142,8 +146,12 @@ export class RegisterPageComponent {
return `Debe tener al menos ${PASSWORD_MIN_LENGTH} caracteres.`;
}
if (controlName === 'password' && control.hasError('pattern')) {
return 'Debe contener mayuscula, minuscula y un caracter especial.';
}
if (controlName === 'password_confirmation' && this.form.hasError('passwordMismatch')) {
return 'Las contrasenas no coinciden.';
return 'Las contraseñas no coinciden.';
}
return 'El valor ingresado no es valido.';

View File

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

View File

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

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