32 Commits

Author SHA1 Message Date
109a1cab5a refactor: update payment validation logic to handle transfer statuses more effectively 2026-07-29 09:36:22 -03:00
9bb3374ba0 feat: add submitPurchaseForReview method and update checkout flow to handle purchase review status 2026-07-29 09:20:17 -03:00
76327d34ec refactor: update styles for category items page header and title 2026-07-29 08:33:54 -03:00
4cb81ed70d feat: implement payment verification error component and update checkout flow 2026-07-28 17:02:12 -03:00
c233845ce0 feat: add WhatsApp support and improve payment timeout handling in checkout process 2026-07-28 16:57:00 -03:00
15a74bb56e feat: enhance purchase status page with ticket handling and WhatsApp integration 2026-07-28 16:26:45 -03:00
58e9dc06b1 refactor: simplify purchase status page by removing polling logic and updating messages 2026-07-28 16:03:17 -03:00
b96d6f23b6 feat: implement category dropdown and category items page with routing and styling 2026-07-28 12:20:53 -03:00
0726b2219e feat: add loading overlay and disable actions during PDF generation for tickets 2026-07-28 09:53:54 -03:00
7845c8f20e feat: implement loading state for checkout process and enhance stepper component 2026-07-28 09:25:04 -03:00
c41c60f349 feat: add download and share functionality for selected tickets 2026-07-28 09:01:39 -03:00
163444c91f feat: enhance loading skeleton for tickets list with improved styling and structure 2026-07-27 16:29:22 -03:00
4f1405f6c1 feat: refactor services injection in search and store home components, add direct purchase event tests 2026-07-27 16:26:03 -03:00
f063bac527 feat: implement direct purchase functionality and enhance product buy events 2026-07-27 16:25:38 -03:00
902f65d8d0 feat: enhance checkout process with purchase editing and status handling
- Implemented purchase editing functionality in CheckoutPageComponent, allowing users to modify items in their purchase.
- Added a new guard (checkoutPendingPurchaseGuard) to prevent navigation away from the checkout page while a purchase is in progress.
- Updated the login page to handle return URLs after authentication.
- Enhanced product detail page to support direct purchases with a new buyNow method.
- Introduced new UI elements and logic to handle purchase status, including expired and rejected states in PurchaseStatusPageComponent.
- Improved cart component to allow editing of item quantities with a toggle button.
- Added quantity selector enhancements to disable controls when necessary.
- Updated tests to cover new functionalities and ensure proper behavior of components.
2026-07-27 12:49:00 -03:00
9dcedc9382 Forgot password flow 2026-07-27 09:42:22 -03:00
565bc769ed feat(environment): add homo environment configuration for homologation 2026-07-24 16:59:15 -03:00
4e06145f44 feat(search): enforce minimum character limit for search input and display error message 2026-07-24 14:17:41 -03:00
9bb4ff61f7 feat(search): update search functionality to handle button click and Enter key press 2026-07-24 14:10:43 -03:00
79a6edc3f3 feat(search): implement search functionality with results page and routing 2026-07-24 14:10:26 -03:00
5d5146d784 feat(tickets-page): conditionally render header and bulk actions based on available tickets 2026-07-24 10:42:17 -03:00
34d351afc5 feat(store-layout): add tickets menu button and navigation handling in store header 2026-07-24 10:42:09 -03:00
9845a82f4a feat(product-list): center justify content in adaptive layout for improved alignment 2026-07-24 10:31:27 -03:00
f4e854843e feat(product-list): improve adaptive layout by refining grid-template-columns for better responsiveness 2026-07-24 10:30:24 -03:00
752c5fceea feat(menu): refactor menu handling to use labels directly and remove unused functions 2026-07-24 10:12:11 -03:00
974f947fc6 feat(account-page): implement dynamic account navigation menus in sidebar and user dropdown 2026-07-24 09:58:18 -03:00
fa0e9de886 feat(contact-page): implement contact page with dynamic content and map integration 2026-07-24 09:41:09 -03:00
656e41d189 feat(contact-page): add route for contact page with menu guard 2026-07-24 09:13:12 -03:00
63ae02df22 feat(location-map): implement reusable location map component with dynamic markers and directions 2026-07-24 09:05:16 -03:00
1d5c94b9bc feat(help-page): enhance help section with dynamic footer links and add pending help page 2026-07-23 17:13:43 -03:00
afaa761acc feat(help-page): implement help page layout with FAQ section and sidebar navigation 2026-07-23 17:13:33 -03:00
d50bd40602 feat(menu-guard): enhance menu guard logic to support nested menus 2026-07-23 17:10:04 -03:00
134 changed files with 7120 additions and 684 deletions

View File

@@ -30,16 +30,11 @@
"input": "public"
}
],
"styles": [
"src/styles.scss"
],
"styles": ["node_modules/leaflet/dist/leaflet.css", "src/styles.scss"],
"server": "src/main.server.ts",
"outputMode": "server",
"security": {
"allowedHosts": [
"localhost",
"127.0.0.1"
]
"allowedHosts": ["localhost", "127.0.0.1"]
},
"ssr": {
"entry": "src/server.ts"
@@ -70,6 +65,14 @@
"optimization": false,
"extractLicenses": false,
"sourceMap": true
},
"homo": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.homo.ts"
}
]
}
},
"defaultConfiguration": "production"
@@ -85,6 +88,9 @@
},
"local": {
"buildTarget": "shopit-front:build:local"
},
"homo": {
"buildTarget": "shopit-front:build:homo"
}
},
"defaultConfiguration": "development"
@@ -95,4 +101,4 @@
}
}
}
}
}

25
package-lock.json generated
View File

@@ -19,6 +19,7 @@
"@fortawesome/fontawesome-free": "^7.2.0",
"bootstrap": "^5.3.8",
"express": "^5.1.0",
"leaflet": "^1.9.4",
"qrcode": "1.5.4",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
@@ -28,6 +29,7 @@
"@angular/cli": "^22.0.4",
"@angular/compiler-cli": "^22.0.0",
"@types/express": "^5.0.1",
"@types/leaflet": "^1.9.21",
"@types/node": "^20.17.19",
"jsdom": "^28.0.0",
"prettier": "^3.8.1",
@@ -3816,6 +3818,13 @@
"@types/send": "*"
}
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/http-errors": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
@@ -3823,6 +3832,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/leaflet": {
"version": "1.9.21",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/node": {
"version": "20.19.43",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
@@ -5936,6 +5955,12 @@
],
"license": "MIT"
},
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
},
"node_modules/listr2": {
"version": "10.2.1",
"resolved": "https://registry.npmjs.org/listr2/-/listr2-10.2.1.tgz",

View File

@@ -23,6 +23,7 @@
"@fortawesome/fontawesome-free": "^7.2.0",
"bootstrap": "^5.3.8",
"express": "^5.1.0",
"leaflet": "^1.9.4",
"qrcode": "1.5.4",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
@@ -32,6 +33,7 @@
"@angular/cli": "^22.0.4",
"@angular/compiler-cli": "^22.0.0",
"@types/express": "^5.0.1",
"@types/leaflet": "^1.9.21",
"@types/node": "^20.17.19",
"jsdom": "^28.0.0",
"prettier": "^3.8.1",

View File

@@ -23,7 +23,8 @@ const tenant: Tenant = {
header_bg_color: '#313131',
footer_bg_color: '#313131',
header_logo: 'https://example.com/header.png',
footer_logo: 'https://example.com/footer.png'
footer_logo: 'https://example.com/footer.png',
categories: []
};
function createTenantServiceStub(
@@ -165,6 +166,55 @@ describe('app routes', () => {
]);
});
it('loads the recover password page inside the simple layout', async () => {
const { fixture, router } = await renderAppAt('/recuperar-contrasena');
const compiled = fixture.nativeElement as HTMLElement;
const recoverPasswordPage = compiled.querySelector('app-recover-password-page');
expect(router.url).toBe('/recuperar-contrasena');
expect(compiled.querySelector('.simple-layout')).not.toBeNull();
expect(recoverPasswordPage).not.toBeNull();
expect(recoverPasswordPage?.textContent).toContain('Recuperar contraseña');
expect(recoverPasswordPage?.querySelector('input')?.getAttribute('placeholder')).toBe('Email');
expect(recoverPasswordPage?.textContent).toContain('Recuperar acceso');
expect(recoverPasswordPage?.textContent).toContain('Volver');
});
it('loads the recovery code page and shows the entered email', async () => {
const { fixture, router } = await renderAppAt(
'/recuperar-contrasena/codigo?email=ada%40example.com'
);
const compiled = fixture.nativeElement as HTMLElement;
const codePage = compiled.querySelector('app-recover-password-code-page');
expect(router.url).toBe('/recuperar-contrasena/codigo?email=ada%40example.com');
expect(compiled.querySelector('.simple-layout')).not.toBeNull();
expect(codePage).not.toBeNull();
expect(codePage?.textContent).toContain('ada@example.com');
expect(codePage?.querySelectorAll('app-input')).toHaveLength(4);
expect(codePage?.textContent).toContain('Validar');
});
it('loads the reset password page with the shared password inputs', async () => {
const { fixture, router } = await renderAppAt(
'/recuperar-contrasena/restablecer?email=ada%40example.com&code=1234'
);
const compiled = fixture.nativeElement as HTMLElement;
const resetPage = compiled.querySelector('app-reset-password-page');
const placeholders = Array.from(resetPage?.querySelectorAll('input') ?? []).map((input) =>
input.getAttribute('placeholder')
);
expect(router.url).toBe(
'/recuperar-contrasena/restablecer?email=ada%40example.com&code=1234'
);
expect(compiled.querySelector('.simple-layout')).not.toBeNull();
expect(resetPage).not.toBeNull();
expect(resetPage?.textContent).toContain('Restablecer contraseña');
expect(placeholders).toEqual(['Nueva Contraseña', 'Repetir Nueva Contraseña']);
expect(resetPage?.textContent).toContain('Guardar');
});
it('redirects authenticated users away from /login', async () => {
const { router } = await renderAppAt('/login', createTenantServiceStub(), createAuthServiceStub(true));
@@ -186,7 +236,7 @@ describe('app routes', () => {
it('redirects unauthenticated users from /checkout to /login', async () => {
const { router } = await renderAppAt('/checkout', createTenantServiceStub(), createAuthServiceStub(false));
expect(router.url).toBe('/login');
expect(router.url).toBe('/login?returnUrl=%2Fcheckout');
});
it('allows authenticated users to access /checkout', async () => {

View File

@@ -24,7 +24,8 @@ const tenant: Tenant = {
header_bg_color: '#313131',
footer_bg_color: '#313131',
header_logo: 'https://example.com/header.png',
footer_logo: 'https://example.com/footer.png'
footer_logo: 'https://example.com/footer.png',
categories: []
};
function createTenantServiceStub(status: 'ready' | 'not-found', currentTenant: Tenant | null) {

View File

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

View File

@@ -0,0 +1,39 @@
@if (open() && categories().length) {
<section class="dropdown-menu show p-2 category-dropdown" role="menu" aria-label="Categorías">
@for (category of categories(); track category.id) {
<div class="dropdown-submenu">
<button
type="button"
class="dropdown-item d-flex align-items-center justify-content-between gap-3"
[class.active]="activeCategory()?.id === category.id"
role="menuitem"
[attr.aria-expanded]="category.subcategories.length ? activeCategory()?.id === category.id : null"
(click)="selectCategory(category)"
>
<span>{{ category.nombre }}</span>
@if (category.subcategories.length) {
<i class="fa-solid fa-chevron-right" aria-hidden="true"></i>
}
</button>
@if (activeCategory()?.id === category.id && subcategories().length) {
<div class="dropdown-menu show p-2 category-dropdown__submenu">
@for (subcategory of subcategories(); track subcategory.id) {
<button
type="button"
class="dropdown-item d-flex align-items-center justify-content-between gap-3"
role="menuitem"
(click)="categorySelect.emit(subcategory)"
>
<span>{{ subcategory.nombre }}</span>
@if (subcategory.subcategories.length) {
<i class="fa-solid fa-chevron-right" aria-hidden="true"></i>
}
</button>
}
</div>
}
</div>
}
</section>
}

View File

@@ -0,0 +1,47 @@
.category-dropdown {
min-width: 240px;
background-color: #ffffff;
border: 0;
box-shadow: 0 0 25px rgba(0, 0, 0, 0.05);
}
.category-dropdown .dropdown-item,
.category-dropdown .dropdown-item:active {
display: flex;
align-items: center;
min-height: 2rem;
font-size: 14px;
font-weight: 700;
line-height: 1.25;
color: #8a8a8a;
background-color: #ffffff;
}
.category-dropdown .dropdown-item:hover,
.category-dropdown .dropdown-item:focus,
.category-dropdown .dropdown-item.active {
color: var(--tenant-primary, var(--bs-primary));
background-color: #ffffff;
}
.dropdown-submenu {
position: relative;
}
.category-dropdown__submenu {
top: -0.5rem;
left: calc(100% - 0.1rem);
min-width: 240px;
border: 0;
box-shadow: 0 0 25px rgba(0, 0, 0, 0.05);
}
@media (max-width: 575.98px) {
.category-dropdown__submenu {
position: static;
margin: 0;
border: 0;
border-radius: 0;
box-shadow: none;
}
}

View File

@@ -0,0 +1,46 @@
import { ChangeDetectionStrategy, Component, computed, effect, input, output, signal } from '@angular/core';
import { Category } from '../../../../services/tenant.interface';
@Component({
selector: 'app-category-dropdown',
standalone: true,
templateUrl: './category-dropdown.component.html',
styleUrl: './category-dropdown.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CategoryDropdownComponent {
readonly categories = input<readonly Category[]>([]);
readonly open = input(false);
readonly categorySelect = output<Category>();
protected readonly activeCategory = signal<Category | null>(null);
protected readonly subcategories = computed(() => this.activeCategory()?.subcategories ?? []);
constructor() {
effect(() => {
const categories = this.categories();
const activeCategory = this.activeCategory();
if (!this.open()) {
this.activeCategory.set(null);
return;
}
if (activeCategory && !categories.some((category) => category.id === activeCategory.id)) {
this.activeCategory.set(null);
}
});
}
protected selectCategory(category: Category): void {
if (category.subcategories.length) {
this.activeCategory.update((activeCategory) =>
activeCategory?.id === category.id ? null : category,
);
return;
}
this.categorySelect.emit(category);
}
}

View File

@@ -1,6 +1,8 @@
<header class="store-layout__header">
<div class="store-layout__header-main">
<div class="container-xl px-3 px-md-4 py-3 py-lg-4 d-flex flex-row flex-wrap align-items-center justify-content-between gap-3">
<div
class="container-xl px-3 px-md-4 py-3 py-lg-4 d-flex flex-row flex-wrap align-items-center justify-content-between gap-3"
>
<a
routerLink="/"
class="store-layout__brand-slot d-inline-flex align-items-center gap-3 flex-shrink-0 text-decoration-none"
@@ -16,24 +18,57 @@
}
</a>
<div class="d-flex flex-row align-items-center gap-3 ms-auto flex-nowrap flex-grow-1 justify-content-between justify-content-md-end">
<div class="input-group store-layout__search" role="search">
<label class="visually-hidden" for="store-search-input">Buscar productos</label>
<input
id="store-search-input"
type="search"
class="form-control store-layout__search-input border-end-0 rounded-start"
/>
<button
type="button"
class="btn store-layout__search-button border-start-0 rounded-end px-3"
aria-label="Buscar"
>
<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
</button>
<div
class="d-flex flex-row align-items-center gap-3 ms-auto flex-nowrap flex-grow-1 justify-content-between justify-content-md-end"
>
<div class="store-layout__search-wrapper">
<div class="input-group store-layout__search" role="search">
<label class="visually-hidden" for="store-search-input">Buscar productos</label>
<input
id="store-search-input"
type="search"
class="form-control store-layout__search-input border-end-0 rounded-start"
placeholder="Buscar productos"
autocomplete="off"
[attr.aria-describedby]="showSearchError() ? 'store-search-error' : null"
[attr.aria-invalid]="showSearchError()"
[minlength]="minSearchLength"
[formControl]="searchControl"
(input)="clearSearchError()"
(keydown.enter)="submitSearch($event)"
/>
<button
type="button"
class="btn store-layout__search-button border-start-0 rounded-end px-3"
aria-label="Buscar"
(click)="submitSearch()"
>
<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
</button>
</div>
@if (showSearchError()) {
<p id="store-search-error" class="store-layout__search-error mb-0" role="alert">
Ingresá al menos {{ minSearchLength }} caracteres para buscar.
</p>
}
</div>
<div class="d-flex align-items-center justify-content-end gap-1" aria-label="Acciones de usuario">
@if (ticketsMenu(); as menu) {
<app-button
variant="primary"
hostClass="flex-shrink-0"
data-testid="store-header-tickets"
(click)="ticketsClick.emit()"
>
{{ menu.label }}
</app-button>
}
<div
class="d-flex align-items-center justify-content-end gap-1"
aria-label="Acciones de usuario"
>
<app-cart-icon
[quantity]="cartQuantity()"
ariaLabel="Carrito de compras"
@@ -54,6 +89,7 @@
<app-user-dropdown
id="store-user-dropdown"
[user]="user()"
[accountNavigationMenus]="accountNavigationMenus()"
(logoutClick)="onLogoutClick()"
/>
}
@@ -63,16 +99,28 @@
</div>
</div>
<div>
<div class="store-layout__categories">
<div class="container-xl px-3 px-md-4">
<button
type="button"
class="btn btn-link d-inline-flex align-items-center gap-2 px-0 py-2 text-uppercase fw-bold text-decoration-none store-layout__category-trigger"
aria-label="Categorias"
>
<span>Categorias</span>
<i class="fa-solid fa-chevron-down" aria-hidden="true"></i>
</button>
<div class="store-layout__category-menu">
<button
type="button"
class="btn btn-link d-inline-flex align-items-center gap-2 px-0 py-2 text-uppercase fw-bold text-decoration-none store-layout__category-trigger"
aria-label="Categorias"
aria-controls="store-category-dropdown"
[attr.aria-expanded]="isCategoryDropdownOpen()"
(click)="toggleCategoryDropdown()"
>
<span>Categorias</span>
<i class="fa-solid fa-chevron-down" aria-hidden="true"></i>
</button>
<app-category-dropdown
id="store-category-dropdown"
[categories]="categories()"
[open]="isCategoryDropdownOpen()"
(categorySelect)="onCategorySelect($event)"
/>
</div>
</div>
</div>
</header>

View File

@@ -41,11 +41,22 @@
animation: store-header-logo-skeleton 1.2s ease-in-out infinite;
}
.store-layout__search {
.store-layout__search-wrapper {
width: min(100%, 260px);
min-width: 0;
}
.store-layout__search {
width: 100%;
}
.store-layout__search-error {
margin-top: 0.25rem;
font-size: 0.75rem;
line-height: 1.2;
color: var(--tenant-danger, #dc3545);
}
.store-layout__search-input {
color: #666666;
border-color: #cccccc;
@@ -100,6 +111,19 @@
color: #8a8a8a;
}
.store-layout__category-menu {
position: relative;
display: inline-flex;
flex-direction: column;
}
.store-layout__category-menu app-category-dropdown {
position: absolute;
z-index: 1050;
top: 100%;
left: 0;
}
@keyframes store-header-logo-skeleton {
from {
background-position: 200% 0;

View File

@@ -4,12 +4,25 @@ import { AuthUser } from '../../../services/auth/auth.interfaces';
import { CartIconComponent } from '../../../../shared/components/cart-icon/cart-icon.component';
import { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.component';
import { UserDropdownComponent } from './user-dropdown/user-dropdown.component';
import { Menu } from '../../../services/tenant.interface';
import { ButtonComponent } from '../../../../shared/components/button/button.component';
import { ReactiveFormsModule, FormControl } from '@angular/forms';
import { Category } from '../../../services/tenant.interface';
import { CategoryDropdownComponent } from './category-dropdown/category-dropdown.component';
@Component({
selector: 'app-store-header',
imports: [CartIconComponent, IconButtonComponent, RouterLink, UserDropdownComponent],
imports: [
ButtonComponent,
CartIconComponent,
IconButtonComponent,
RouterLink,
ReactiveFormsModule,
UserDropdownComponent,
CategoryDropdownComponent,
],
templateUrl: './store-header.component.html',
styleUrl: './store-header.component.scss'
styleUrl: './store-header.component.scss',
})
export class StoreHeaderComponent {
private readonly elementRef = inject(ElementRef<HTMLElement>);
@@ -18,27 +31,39 @@ export class StoreHeaderComponent {
readonly cartQuantity = input<number>(0);
readonly user = input<AuthUser | null>(null);
readonly isAuthenticated = input<boolean>(false);
readonly accountNavigationMenus = input<readonly Menu[]>([]);
readonly ticketsMenu = input<Menu | null>(null);
readonly categories = input<readonly Category[]>([]);
readonly cartClick = output<void>();
readonly ticketsClick = output<void>();
readonly loginClick = output<void>();
readonly logoutClick = output<void>();
readonly searchSubmit = output<string>();
readonly categorySelect = output<Category>();
protected readonly isUserDropdownOpen = signal(false);
protected readonly isCategoryDropdownOpen = signal(false);
protected readonly minSearchLength = 3;
protected readonly showSearchError = signal(false);
protected readonly searchControl = new FormControl('', { nonNullable: true });
@HostListener('document:click', ['$event'])
protected onDocumentClick(event: MouseEvent): void {
if (!this.isUserDropdownOpen()) {
if (!this.isUserDropdownOpen() && !this.isCategoryDropdownOpen()) {
return;
}
const target = event.target;
if (target instanceof Node && !this.elementRef.nativeElement.contains(target)) {
this.isUserDropdownOpen.set(false);
this.isCategoryDropdownOpen.set(false);
}
}
@HostListener('document:keydown.escape')
protected onEscapeKey(): void {
this.isUserDropdownOpen.set(false);
this.isCategoryDropdownOpen.set(false);
}
protected toggleUserDropdown(): void {
@@ -59,4 +84,31 @@ export class StoreHeaderComponent {
this.isUserDropdownOpen.set(false);
this.logoutClick.emit();
}
protected submitSearch(event?: Event): void {
event?.preventDefault();
const term = this.searchControl.value.trim();
if (term.length < this.minSearchLength) {
this.showSearchError.set(true);
return;
}
this.showSearchError.set(false);
this.searchSubmit.emit(term);
}
protected clearSearchError(): void {
this.showSearchError.set(false);
}
protected toggleCategoryDropdown(): void {
this.isCategoryDropdownOpen.update((isOpen) => !isOpen);
}
protected onCategorySelect(category: Category): void {
this.isCategoryDropdownOpen.set(false);
this.categorySelect.emit(category);
}
}

View File

@@ -5,12 +5,16 @@
<div class="user-dropdown__divider"></div>
<a routerLink="/mi-cuenta/datos-personales" class="user-dropdown__item" role="menuitem" data-testid="user-dropdown-account">
Mi cuenta
</a>
<a routerLink="/mi-cuenta/compras" class="user-dropdown__item" role="menuitem" data-testid="user-dropdown-purchases">
Mis compras
</a>
@for (menu of accountNavigationMenus(); track menu.code) {
<a
[routerLink]="menu.route"
class="user-dropdown__item"
role="menuitem"
[attr.data-testid]="'user-dropdown-' + menu.code"
>
{{ menu.label }}
</a>
}
<button
type="button"
class="user-dropdown__item"

View File

@@ -1,16 +1,18 @@
import { Component, computed, input, output } from '@angular/core';
import { RouterLink } from '@angular/router';
import { AuthUser } from '../../../../services/auth/auth.interfaces';
import { Menu } from '../../../../services/tenant.interface';
@Component({
selector: 'app-user-dropdown',
standalone: true,
imports: [RouterLink],
templateUrl: './user-dropdown.component.html',
styleUrl: './user-dropdown.component.scss'
styleUrl: './user-dropdown.component.scss',
})
export class UserDropdownComponent {
readonly user = input<AuthUser | null>(null);
readonly accountNavigationMenus = input<readonly Menu[]>([]);
readonly logoutClick = output<void>();
protected readonly displayName = computed(() => this.user()?.nombre_apellido || 'Mi cuenta');

View File

@@ -4,13 +4,22 @@
[logoUrl]="tenant()?.header_logo ?? null"
[user]="user()"
[isAuthenticated]="isAuthenticated()"
[accountNavigationMenus]="accountNavigationMenus()"
[ticketsMenu]="ticketsMenu() ?? null"
[categories]="tenant()?.categories ?? []"
(cartClick)="isCartOpen.set(!isCartOpen())"
(ticketsClick)="onTicketsClick()"
(loginClick)="onLoginClick()"
(logoutClick)="onLogoutClick()"
(searchSubmit)="onSearch($event)"
(categorySelect)="onCategorySelect($event)"
/>
@if (isCartOpen()) {
<div class="store-layout__cart-overlay" (click)="isCartOpen.set(false)"></div>
<div
class="store-layout__cart-overlay"
(click)="isCartOpen.set(false)"
></div>
<div class="store-layout__cart-dropdown card border-0 shadow-lg">
<app-cart
[showClose]="true"
@@ -21,8 +30,20 @@
[backgroundColor]="'#ffffff'"
(closed)="isCartOpen.set(false)"
>
<app-button variant="secondary" class="flex-grow-1" (click)="isCartOpen.set(false)">Seguir comprando</app-button>
<app-button variant="primary" class="flex-grow-1" (click)="onCheckoutClick()">Comprar</app-button>
<app-button
variant="secondary"
class="flex-grow-1"
(click)="isCartOpen.set(false)"
>Seguir comprando</app-button
>
<app-button
variant="primary"
class="flex-grow-1"
[disabled]="isCreatingPurchase()"
(click)="onCheckoutClick()"
>
Comprar
</app-button>
</app-cart>
</div>
}
@@ -37,7 +58,7 @@
<app-store-footer
[currentYear]="currentYear"
[footerSections]="footerSections"
[footerSections]="footerSections()"
[socialMedia]="tenant()?.social_media ?? []"
[logoUrl]="tenant()?.footer_logo ?? null"
(logoutClick)="onLogoutClick()"

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { provideRouter, Router } from '@angular/router';
import { of } from 'rxjs';
@@ -8,10 +9,13 @@ import { of } from 'rxjs';
import { Tenant } from '../../services/tenant.interface';
import { TenantService } from '../../services/tenant.service';
import { CartService } from '../../services/cart/cart.service';
import { Cart } from '../../services/cart/cart.interface';
import { ToastService } from '../../services/toast.service';
import { AuthService } from '../../services/auth/auth.service';
import { AuthUser } from '../../services/auth/auth.interfaces';
import { StoreLayoutComponent } from './store-layout.component';
import { StoreHeaderComponent } from './store-header/store-header.component';
import { CheckoutService } from '../../services/checkout.service';
const tenant: Tenant = {
id: 1,
@@ -26,6 +30,74 @@ const tenant: Tenant = {
footer_bg_color: '#313131',
header_logo: 'https://example.com/header.png',
footer_logo: 'https://example.com/footer.png',
categories: [],
menues: [
{
id: 4,
code: 'account',
label: 'Mi cuenta',
parent_menu_code: null,
content_type: 'dynamic',
route: '/mi-cuenta',
submenues: [
{
id: 5,
code: 'account.profile',
label: 'Datos personales',
parent_menu_code: 'account',
content_type: 'dynamic',
route: '/mi-cuenta/datos-personales',
submenues: [],
},
{
id: 6,
code: 'account.purchases',
label: 'Mis compras',
parent_menu_code: 'account',
content_type: 'dynamic',
route: '/mi-cuenta/compras',
submenues: [],
},
{
id: 7,
code: 'account.tickets',
label: 'Mis tickets',
parent_menu_code: 'account',
content_type: 'dynamic',
route: '/mi-cuenta/tickets',
submenues: [],
},
],
},
{
id: 1,
code: 'help',
label: 'Ayuda',
parent_menu_code: null,
content_type: 'dynamic',
route: '/ayuda',
submenues: [
{
id: 2,
code: 'help.contact',
label: 'Contacto',
parent_menu_code: 'help',
content_type: 'static',
route: '/ayuda/contacto',
submenues: [],
},
{
id: 3,
code: 'help.faq',
label: 'Preguntas frecuentes',
parent_menu_code: 'help',
content_type: 'static',
route: '/ayuda/preguntas-frecuentes',
submenues: [],
},
],
},
],
social_media: [
{
code: 'instagram',
@@ -55,10 +127,19 @@ const tenant: Tenant = {
};
describe('StoreLayoutComponent', () => {
let tenantState = signal<Tenant | null>(tenant);
let cartState = signal<Cart | null>(null);
let authUserState = signal<AuthUser | null>(null);
let checkoutServiceStub: { startCheckout: ReturnType<typeof vi.fn> };
beforeEach(async () => {
const tenantState = signal(tenant);
const authUserState = signal<AuthUser | null>(null);
tenantState = signal<Tenant | null>(tenant);
cartState = signal<Cart | null>(null);
authUserState = signal<AuthUser | null>(null);
const isAuthenticatedState = signal(false);
checkoutServiceStub = {
startCheckout: vi.fn().mockResolvedValue({ id: 55 }),
};
await TestBed.configureTestingModule({
imports: [StoreLayoutComponent],
@@ -70,17 +151,20 @@ describe('StoreLayoutComponent', () => {
tenant: tenantState.asReadonly(),
status: signal<'ready'>('ready').asReadonly(),
getTenant: () => tenantState(),
bootstrap: vi.fn().mockResolvedValue(undefined)
}
bootstrap: vi.fn().mockResolvedValue(undefined),
},
},
{
provide: CartService,
useValue: {
cart: signal(null).asReadonly(),
cart: cartState.asReadonly(),
loadCart: () => of({ id: 1, items: [], subtotal: '0' }),
updateItemQuantity: vi.fn().mockReturnValue(of({ data: { id: 1, items: [], subtotal: '0' } })),
removeItem: vi.fn().mockReturnValue(of({ data: { id: 1, items: [], subtotal: '0' } }))
}
updateItemQuantity: vi
.fn()
.mockReturnValue(of({ data: { id: 1, items: [], subtotal: '0' } })),
removeItem: vi.fn().mockReturnValue(of({ data: { id: 1, items: [], subtotal: '0' } })),
clearCart: vi.fn(() => cartState.set(null)),
},
},
{
provide: ToastService,
@@ -89,18 +173,22 @@ describe('StoreLayoutComponent', () => {
info: vi.fn(),
danger: vi.fn(),
success: vi.fn(),
dismiss: vi.fn()
}
dismiss: vi.fn(),
},
},
{
provide: AuthService,
useValue: {
user: authUserState,
isAuthenticated: isAuthenticatedState,
logout: vi.fn().mockReturnValue(of(void 0))
}
}
]
logout: vi.fn().mockReturnValue(of(void 0)),
},
},
{
provide: CheckoutService,
useValue: checkoutServiceStub,
},
],
}).compileComponents();
});
@@ -125,10 +213,14 @@ describe('StoreLayoutComponent', () => {
expect(compiled.querySelector('[data-testid="store-header-brand-slot"]')).not.toBeNull();
expect(compiled.querySelector('[data-testid="store-footer-brand-slot"]')).not.toBeNull();
expect(
compiled.querySelector(`app-store-header img.store-layout__brand-logo[src="${tenant.header_logo}"]`)
compiled.querySelector(
`app-store-header img.store-layout__brand-logo[src="${tenant.header_logo}"]`,
),
).not.toBeNull();
expect(
compiled.querySelector(`app-store-footer img.store-layout__brand-logo[src="${tenant.footer_logo}"]`)
compiled.querySelector(
`app-store-footer img.store-layout__brand-logo[src="${tenant.footer_logo}"]`,
),
).not.toBeNull();
expect(compiled.querySelector('.fa-cart-shopping')).not.toBeNull();
expect(compiled.querySelector('.fa-circle-user')).not.toBeNull();
@@ -138,6 +230,127 @@ describe('StoreLayoutComponent', () => {
expect(compiled.querySelector('.fa-linkedin-in')).not.toBeNull();
});
it('navigates to search results when the search button is clicked', () => {
const router = TestBed.inject(Router);
vi.spyOn(router, 'navigate');
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
const header = fixture.debugElement.query(By.directive(StoreHeaderComponent));
const searchButton = header.query(By.css('.store-layout__search-button'));
(header.componentInstance as any).searchControl.setValue(' zapatillas ');
searchButton.triggerEventHandler('click');
fixture.detectChanges();
expect(router.navigate).toHaveBeenCalledWith(['/buscar'], {
queryParams: { q: 'zapatillas', page: 1 },
});
});
it('prevents the native submit and searches when Enter is pressed', () => {
const router = TestBed.inject(Router);
vi.spyOn(router, 'navigate');
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
const header = fixture.debugElement.query(By.directive(StoreHeaderComponent));
const input = header.query(By.css('#store-search-input'));
const keyboardEvent = {
preventDefault: vi.fn(),
};
(header.componentInstance as any).searchControl.setValue('remera');
input.triggerEventHandler('keydown.enter', keyboardEvent);
expect(keyboardEvent.preventDefault).toHaveBeenCalled();
expect(router.navigate).toHaveBeenCalledWith(['/buscar'], {
queryParams: { q: 'remera', page: 1 },
});
});
it('requires at least three characters to search', () => {
const router = TestBed.inject(Router);
vi.spyOn(router, 'navigate');
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
const header = fixture.debugElement.query(By.directive(StoreHeaderComponent));
const input = header.query(By.css('#store-search-input'));
const searchButton = header.query(By.css('.store-layout__search-button'));
expect(input.nativeElement.getAttribute('minlength')).toBe('3');
(header.componentInstance as any).searchControl.setValue('ab');
searchButton.triggerEventHandler('click');
fixture.detectChanges();
expect(router.navigate).not.toHaveBeenCalled();
expect((fixture.nativeElement as HTMLElement).textContent).toContain(
'Ingresá al menos 3 caracteres para buscar.',
);
expect(input.nativeElement.getAttribute('aria-invalid')).toBe('true');
});
it('renders the primary tickets action when the tenant has the tickets menu', () => {
const router = TestBed.inject(Router);
vi.spyOn(router, 'navigate');
const configuredTicketsLabel = 'Entradas digitales';
tenantState.set({
...tenant,
menues: tenant.menues?.map((menu) =>
menu.code === 'account'
? {
...menu,
submenues: menu.submenues.map((submenu) =>
submenu.code === 'account.tickets'
? { ...submenu, label: configuredTicketsLabel }
: submenu,
),
}
: menu,
),
});
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
const ticketsAction = (fixture.nativeElement as HTMLElement).querySelector<HTMLElement>(
'app-button[data-testid="store-header-tickets"]',
);
const ticketsButton = ticketsAction?.querySelector('button');
expect(ticketsAction).not.toBeNull();
expect(ticketsButton).not.toBeNull();
expect(ticketsButton?.textContent?.trim()).toBe(configuredTicketsLabel);
expect(ticketsButton?.classList).toContain('btn-primary');
ticketsButton?.click();
expect(router.navigate).toHaveBeenCalledWith(['/mi-cuenta/tickets']);
});
it('does not render the tickets action when the tenant lacks the tickets menu', () => {
tenantState.set({
...tenant,
menues: tenant.menues?.map((menu) =>
menu.code === 'account'
? {
...menu,
submenues: menu.submenues.filter((submenu) => submenu.code !== 'account.tickets'),
}
: menu,
),
});
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
expect(
(fixture.nativeElement as HTMLElement).querySelector('[data-testid="store-header-tickets"]'),
).toBeNull();
});
it('redirects to /login when user icon is clicked and user is not logged in', () => {
const authService = TestBed.inject(AuthService);
const router = TestBed.inject(Router);
@@ -149,7 +362,9 @@ describe('StoreLayoutComponent', () => {
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const userButton = compiled.querySelector('app-icon-button[variant="user"] button') as HTMLButtonElement;
const userButton = compiled.querySelector(
'app-icon-button[variant="user"] button',
) as HTMLButtonElement;
expect(userButton).not.toBeNull();
userButton.click();
fixture.detectChanges();
@@ -167,25 +382,33 @@ describe('StoreLayoutComponent', () => {
(authService.user as any).set({
id: 1,
nombre_apellido: 'Ada Lovelace',
email: 'ada@example.com'
email: 'ada@example.com',
});
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const userButton = compiled.querySelector('app-icon-button[variant="user"] button') as HTMLButtonElement;
const userButton = compiled.querySelector(
'app-icon-button[variant="user"] button',
) as HTMLButtonElement;
expect(userButton).not.toBeNull();
userButton.click();
fixture.detectChanges();
expect(compiled.querySelector('app-user-dropdown')).not.toBeNull();
expect(compiled.textContent).toContain('Ada Lovelace');
expect(compiled.querySelector('[data-testid="user-dropdown-account"]')).not.toBeNull();
expect(
compiled.querySelector('[data-testid="user-dropdown-account.purchases"]'),
).not.toBeNull();
expect(compiled.querySelector('[data-testid="user-dropdown-account.tickets"]')).toBeNull();
expect(router.navigate).not.toHaveBeenCalled();
});
it('logs out from the authenticated user dropdown', () => {
it('logs out from the authenticated user dropdown, clears the cart and returns home', () => {
const authService = TestBed.inject(AuthService);
const cartService = TestBed.inject(CartService);
const router = TestBed.inject(Router);
vi.spyOn(router, 'navigate');
@@ -193,28 +416,34 @@ describe('StoreLayoutComponent', () => {
(authService.user as any).set({
id: 1,
nombre_apellido: 'Ada Lovelace',
email: 'ada@example.com'
email: 'ada@example.com',
});
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const userButton = compiled.querySelector('app-icon-button[variant="user"] button') as HTMLButtonElement;
const userButton = compiled.querySelector(
'app-icon-button[variant="user"] button',
) as HTMLButtonElement;
userButton.click();
fixture.detectChanges();
const logoutButton = compiled.querySelector('[data-testid="user-dropdown-logout"]') as HTMLButtonElement;
const logoutButton = compiled.querySelector(
'[data-testid="user-dropdown-logout"]',
) as HTMLButtonElement;
expect(logoutButton).not.toBeNull();
logoutButton.click();
fixture.detectChanges();
expect(authService.logout).toHaveBeenCalled();
expect(router.navigate).toHaveBeenCalledWith(['/login']);
expect(cartService.clearCart).toHaveBeenCalled();
expect(router.navigate).toHaveBeenCalledWith(['/']);
});
it('provides account actions from the footer', () => {
const authService = TestBed.inject(AuthService);
const cartService = TestBed.inject(CartService);
const router = TestBed.inject(Router);
vi.spyOn(router, 'navigate');
@@ -225,26 +454,98 @@ describe('StoreLayoutComponent', () => {
const footerLinks = Array.from(
compiled.querySelectorAll<HTMLAnchorElement>('app-store-footer a.store-layout__footer-link'),
);
const accountLink = footerLinks.find((link) => link.textContent?.trim() === 'Mi cuenta');
const profileLink = footerLinks.find((link) => link.textContent?.trim() === 'Mi cuenta');
const purchasesLink = footerLinks.find((link) => link.textContent?.trim() === 'Mis compras');
const ticketsLink = footerLinks.find((link) => link.textContent?.trim() === 'Mis tickets');
const logoutButton = Array.from(
compiled.querySelectorAll<HTMLButtonElement>('app-store-footer button'),
).find((button) => button.textContent?.trim() === 'Cerrar sesion');
expect(accountLink?.getAttribute('href')).toBe('/mi-cuenta/datos-personales');
expect(profileLink?.getAttribute('href')).toBe('/mi-cuenta/datos-personales');
expect(purchasesLink?.getAttribute('href')).toBe('/mi-cuenta/compras');
expect(ticketsLink).toBeUndefined();
expect(logoutButton).toBeDefined();
logoutButton!.click();
fixture.detectChanges();
expect(authService.logout).toHaveBeenCalled();
expect(router.navigate).toHaveBeenCalledWith(['/login']);
expect(cartService.clearCart).toHaveBeenCalled();
expect(router.navigate).toHaveBeenCalledWith(['/']);
});
it('redirects to /checkout when cart buy button is clicked', () => {
it('leaves checkout before logging out so the pending purchase can be cancelled', async () => {
const authService = TestBed.inject(AuthService);
const cartService = TestBed.inject(CartService);
const router = TestBed.inject(Router);
vi.spyOn(router, 'url', 'get').mockReturnValue('/checkout?purchase=25');
const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
await (fixture.componentInstance as any).onLogoutClick();
expect(navigateSpy).toHaveBeenCalledWith(['/']);
expect(authService.logout).toHaveBeenCalled();
expect(navigateSpy.mock.invocationCallOrder[0]).toBeLessThan(
(authService.logout as any).mock.invocationCallOrder[0],
);
expect(cartService.clearCart).toHaveBeenCalled();
});
it('renders only the help submenus assigned to the tenant in the footer', () => {
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const footerLinks = Array.from(
compiled.querySelectorAll<HTMLAnchorElement>('app-store-footer a.store-layout__footer-link'),
);
const helpLinks = footerLinks.filter((link) =>
link.getAttribute('href')?.startsWith('/ayuda/'),
);
expect(helpLinks.map((link) => link.textContent?.trim())).toEqual([
'Contacto',
'Preguntas frecuentes',
]);
expect(helpLinks.map((link) => link.getAttribute('href'))).toEqual([
'/ayuda/contacto',
'/ayuda/preguntas-frecuentes',
]);
expect(compiled.textContent).not.toContain('Medios de pago');
});
it('creates a purchase and redirects to checkout when cart buy is clicked', async () => {
const router = TestBed.inject(Router);
vi.spyOn(router, 'navigate');
cartState.set({
id: 1,
tenant_codigo: tenant.codigo,
status: 'active',
subtotal: '100.00',
items: [
{
id: 1,
cantidad: 1,
precio_unitario: '100.00',
catalog_item_id: 1,
variant_id: null,
product: {
nombre: 'Producto',
imagen: null,
},
},
],
});
authUserState.set({
id: 7,
nombre_apellido: 'Juan Perez',
email: 'juan@example.com',
dni: '12345678',
telefono: '3415555555',
});
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
@@ -253,14 +554,58 @@ describe('StoreLayoutComponent', () => {
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const buyButton = Array.from(compiled.querySelectorAll('app-button button'))
.find((button) => button.textContent?.trim() === 'Comprar') as HTMLButtonElement | undefined;
const buyButton = Array.from(compiled.querySelectorAll('app-button button')).find(
(button) => button.textContent?.trim() === 'Comprar',
) as HTMLButtonElement | undefined;
expect(buyButton).toBeDefined();
buyButton!.click();
await Promise.resolve();
fixture.detectChanges();
expect(router.navigate).toHaveBeenCalledWith(['/checkout']);
expect(checkoutServiceStub.startCheckout).toHaveBeenCalledWith('test', {
cart_id: 1,
});
expect(router.navigate).toHaveBeenCalledWith(['/checkout'], {
queryParams: { purchase: 55 },
});
expect((fixture.componentInstance as any).isCartOpen()).toBe(false);
});
it('allows modifying quantities directly in the regular cart without a toggle', () => {
cartState.set({
id: 1,
tenant_codigo: tenant.codigo,
status: 'active',
subtotal: '100.00',
items: [
{
id: 1,
cantidad: 1,
precio_unitario: '100.00',
catalog_item_id: 1,
variant_id: null,
product: {
nombre: 'Producto',
imagen: null,
},
},
],
});
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
(fixture.componentInstance as any).isCartOpen.set(true);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const cartItem = fixture.debugElement.query(By.css('app-cart-item'));
const buyButton = Array.from(
element.querySelectorAll<HTMLButtonElement>('app-button button'),
).find((button) => button.textContent?.trim() === 'Comprar');
expect(element.querySelector('.cart-edit-btn')).toBeNull();
expect(cartItem.componentInstance.quantityDisabled()).toBe(false);
expect(buyButton).toBeDefined();
});
});

View File

@@ -8,6 +8,10 @@ import { CartComponent, CartItemMock } from '../../../shared/components/cart/car
import { ButtonComponent } from '../../../shared/components/button/button.component';
import { CartItem } from '../../services/cart/cart.interface';
import { AuthService } from '../../services/auth/auth.service';
import { findMenu } from '../../services/menu.utils';
import { CheckoutService } from '../../services/checkout.service';
import { ToastService } from '../../services/toast.service';
import { Category } from '../../services/tenant.interface';
@Component({
selector: 'app-store-layout',
@@ -25,9 +29,12 @@ export class StoreLayoutComponent implements OnInit {
private readonly tenantService = inject(TenantService);
private readonly cartService = inject(CartService);
private readonly authService = inject(AuthService);
private readonly checkoutService = inject(CheckoutService);
private readonly toastService = inject(ToastService);
private readonly router = inject(Router);
protected readonly isCartOpen = signal(false);
protected readonly isCreatingPurchase = signal(false);
protected readonly cartSubtotal = computed(() => {
const cart = this.cartService.cart();
@@ -78,6 +85,24 @@ export class StoreLayoutComponent implements OnInit {
protected readonly tenant = this.tenantService.tenant;
protected readonly user = this.authService.user;
protected readonly isAuthenticated = this.authService.isAuthenticated;
protected readonly ticketsMenu = computed(() =>
findMenu(this.tenant()?.menues ?? [], 'account.tickets'),
);
protected readonly accountNavigationMenus = computed(() => {
const accountMenu = findMenu(this.tenant()?.menues ?? [], 'account');
if (!accountMenu) {
return [];
}
const profile = accountMenu.submenues.find((menu) => menu.code === 'account.profile');
const purchases = accountMenu.submenues.find((menu) => menu.code === 'account.purchases');
return [
...(profile ? [{ ...accountMenu, route: profile.route }] : []),
...(purchases ? [purchases] : []),
];
});
protected readonly cartQuantity = computed(() => {
const cart = this.cartService.cart();
@@ -95,35 +120,112 @@ export class StoreLayoutComponent implements OnInit {
void this.router.navigate(['/login']);
}
protected onLogoutClick(): void {
protected onSearch(term: string): void {
void this.router.navigate(['/buscar'], {
queryParams: { q: term, page: 1 },
});
}
protected onCategorySelect(category: Category): void {
void this.router.navigate(['/categoria', category.id], {
queryParams: { page: 1 },
});
}
protected onTicketsClick(): void {
const route = this.ticketsMenu()?.route;
if (route) {
void this.router.navigate([route]);
}
}
protected async onLogoutClick(): Promise<void> {
const isLeavingCheckout = this.router.url.startsWith('/checkout');
// Checkout must be left while the authenticated session is still valid so
// its CanDeactivate guard can cancel the pending purchase.
if (isLeavingCheckout) {
const navigationSucceeded = await this.router.navigate(['/']);
if (!navigationSucceeded) {
return;
}
}
this.authService.logout().subscribe({
next: () => void this.router.navigate(['/login']),
next: () => {
this.cartService.clearCart();
this.isCartOpen.set(false);
if (!isLeavingCheckout) {
void this.router.navigate(['/']);
}
},
error: (err) => console.error('Error logging out', err),
});
}
protected onCheckoutClick(): void {
this.isCartOpen.set(false);
void this.router.navigate(['/checkout']);
protected async onCheckoutClick(): Promise<void> {
if (this.isCreatingPurchase()) {
return;
}
const cart = this.cartService.cart();
const tenant = this.tenantService.tenant();
const user = this.authService.user();
if (!user) {
this.isCartOpen.set(false);
void this.router.navigate(['/login'], { queryParams: { returnUrl: '/' } });
return;
}
if (!cart?.id || !tenant) {
this.toastService.danger('No hay un carrito activo para iniciar la compra.');
return;
}
this.isCreatingPurchase.set(true);
try {
const purchase = await this.checkoutService.startCheckout(tenant.codigo, {
cart_id: cart.id,
});
this.cartService.clearCart();
this.isCartOpen.set(false);
await this.router.navigate(['/checkout'], {
queryParams: { purchase: purchase.id },
});
} catch (error) {
console.error('Failed to create cart purchase:', error);
this.toastService.danger('No se pudo iniciar la compra.');
} finally {
this.isCreatingPurchase.set(false);
}
}
protected readonly footerSections: StoreFooterSection[] = [
{
heading: 'Cuenta',
links: [
{ label: 'Mi cuenta', routerLink: '/mi-cuenta/datos-personales' },
{ label: 'Mis compras', routerLink: '/mi-cuenta/compras' },
{ label: 'Cerrar sesion', action: 'logout' },
],
},
{
heading: 'Ayuda',
links: [
{ label: 'Contacto' },
{ label: 'Ayuda' },
{ label: 'Preguntas frecuentes' },
],
},
];
protected readonly footerSections = computed<StoreFooterSection[]>(() => {
const helpSubmenues = findMenu(this.tenant()?.menues ?? [], 'help')?.submenues ?? [];
return [
{
heading: 'Cuenta',
links: [
...this.accountNavigationMenus().map((menu) => ({
label: menu.label,
routerLink: menu.route,
})),
{ label: 'Cerrar sesion', action: 'logout' },
],
},
{
heading: 'Ayuda',
links: helpSubmenues.map((menu) => ({
label: menu.label,
routerLink: menu.route,
})),
},
];
});
}

View File

@@ -23,10 +23,14 @@ describe('auth guards', () => {
providers: [provideRouter([]), { provide: AuthService, useValue: createAuthServiceStub() }]
});
const result = TestBed.runInInjectionContext(() => authGuard(null as never, null as never));
const result = TestBed.runInInjectionContext(() =>
authGuard(null as never, { url: '/checkout?mode=direct' } as never),
);
expect(result instanceof UrlTree).toBe(true);
expect(TestBed.inject(Router).serializeUrl(result as UrlTree)).toBe('/login');
expect(TestBed.inject(Router).serializeUrl(result as UrlTree)).toBe(
'/login?returnUrl=%2Fcheckout%3Fmode%3Ddirect',
);
});
it('allows authenticated users through authGuard', () => {
@@ -34,7 +38,9 @@ describe('auth guards', () => {
providers: [provideRouter([]), { provide: AuthService, useValue: createAuthServiceStub(true) }]
});
const result = TestBed.runInInjectionContext(() => authGuard(null as never, null as never));
const result = TestBed.runInInjectionContext(() =>
authGuard(null as never, { url: '/checkout' } as never),
);
expect(result).toBe(true);
});

View File

@@ -3,11 +3,15 @@ import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from './auth.service';
export const authGuard: CanActivateFn = () => {
export const authGuard: CanActivateFn = (_route, state) => {
const authService = inject(AuthService);
const router = inject(Router);
return authService.isAuthenticated() ? true : router.createUrlTree(['/login']);
return authService.isAuthenticated()
? true
: router.createUrlTree(['/login'], {
queryParams: { returnUrl: state.url },
});
};
export const guestOnlyGuard: CanActivateFn = () => {

View File

@@ -37,3 +37,25 @@ export interface RegisterResponse {
message: string;
data: AuthUser;
}
export interface ResetPasswordAttemptResponse {
message: string;
status: 'pending';
}
export interface ValidateResetPasswordAttemptResponse {
message: string;
status: 'validated';
}
export interface ResetPasswordPayload {
email: string;
codigo: string;
password: string;
password_confirmation: string;
}
export interface ResetPasswordResponse {
message: string;
status: 'used';
}

View File

@@ -19,8 +19,12 @@ describe('AuthService', () => {
function createCookieServiceStub() {
return {
get: (name: string) => cookieStore[name] || null,
set: (name: string, value: string) => { cookieStore[name] = value; },
delete: (name: string) => { delete cookieStore[name]; }
set: (name: string, value: string) => {
cookieStore[name] = value;
},
delete: (name: string) => {
delete cookieStore[name];
}
};
}
@@ -31,6 +35,10 @@ describe('AuthService', () => {
provideHttpClientTesting(),
AuthService,
{ provide: CookieService, useValue: createCookieServiceStub() },
{
provide: TenantService,
useValue: { getTenant: () => ({ codigo: 'tenant-test' }) }
},
TransferState
]
});
@@ -44,6 +52,12 @@ describe('AuthService', () => {
const request = httpController.expectOne(`${environment.url}login`);
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual({
email: 'ada@example.com',
password: 'secret123',
tenant_codigo: 'tenant-test'
});
expect(request.request.withCredentials).toBe(true);
request.flush({
message: 'Sesion iniciada correctamente.',
token: 'plain-text-token',
@@ -96,6 +110,47 @@ describe('AuthService', () => {
httpController.verify();
});
it('sends tenant and credentials when completing Google login', () => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
AuthService,
{ provide: CookieService, useValue: createCookieServiceStub() },
{
provide: TenantService,
useValue: { getTenant: () => ({ codigo: 'tenant-test' }) }
},
TransferState
]
});
const service = TestBed.inject(AuthService);
const httpController = TestBed.inject(HttpTestingController);
service.completeGoogleLogin('oauth-code').subscribe();
const request = httpController.expectOne(`${environment.url}auth/google/exchange`);
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual({
oauth_code: 'oauth-code',
tenant_codigo: 'tenant-test'
});
expect(request.request.withCredentials).toBe(true);
request.flush({
message: 'Sesion iniciada correctamente.',
token: 'google-token',
token_type: 'Bearer',
user: {
id: 1,
nombre_apellido: 'Ada Lovelace',
email: 'ada@example.com'
}
});
httpController.verify();
});
it('clears an expired session when /me returns 401', async () => {
cookieStore['shopit.auth.token'] = 'expired-token';
@@ -174,6 +229,121 @@ describe('AuthService', () => {
httpController.verify();
});
it('requests a password reset and exposes the final HTTP status', () => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
AuthService,
{ provide: CookieService, useValue: createCookieServiceStub() },
{
provide: TenantService,
useValue: { getTenant: () => ({ codigo: 'tenant-test' }) }
},
TransferState
]
});
const service = TestBed.inject(AuthService);
const httpController = TestBed.inject(HttpTestingController);
service.requestPasswordReset('ada@example.com').subscribe((response) => {
expect(response.status).toBe(202);
expect(response.body?.status).toBe('pending');
});
const request = httpController.expectOne(`${environment.url}password/reset-attempts`);
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual({
email: 'ada@example.com',
tenant_codigo: 'tenant-test'
});
request.flush(
{
message: 'Si el email está registrado, recibirás un código.',
status: 'pending'
},
{ status: 202, statusText: 'Accepted' }
);
httpController.verify();
});
it('validates a password reset code and exposes its final status', () => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
AuthService,
{ provide: CookieService, useValue: createCookieServiceStub() },
TransferState
]
});
const service = TestBed.inject(AuthService);
const httpController = TestBed.inject(HttpTestingController);
service.validatePasswordResetCode('ada@example.com', '0123').subscribe((response) => {
expect(response.status).toBe(200);
expect(response.body?.status).toBe('validated');
});
const request = httpController.expectOne(`${environment.url}password/reset-attempts/validate`);
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual({
email: 'ada@example.com',
codigo: '0123'
});
request.flush(
{
message: 'Código validado correctamente.',
status: 'validated'
},
{ status: 200, statusText: 'OK' }
);
httpController.verify();
});
it('resets the password and exposes the consumed attempt status', () => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
AuthService,
{ provide: CookieService, useValue: createCookieServiceStub() },
TransferState
]
});
const service = TestBed.inject(AuthService);
const httpController = TestBed.inject(HttpTestingController);
const payload = {
email: 'ada@example.com',
codigo: '0123',
password: 'NewSecret!456',
password_confirmation: 'NewSecret!456'
};
service.resetPassword(payload).subscribe((response) => {
expect(response.status).toBe(200);
expect(response.body?.status).toBe('used');
});
const request = httpController.expectOne(`${environment.url}password/reset`);
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual(payload);
request.flush(
{
message: 'Contraseña modificada correctamente.',
status: 'used'
},
{ status: 200, statusText: 'OK' }
);
httpController.verify();
});
it('propagates logout errors without clearing the local session', () => {
TestBed.configureTestingModule({
providers: [
@@ -181,6 +351,10 @@ describe('AuthService', () => {
provideHttpClientTesting(),
AuthService,
{ provide: CookieService, useValue: createCookieServiceStub() },
{
provide: TenantService,
useValue: { getTenant: () => ({ codigo: 'tenant-test' }) }
},
TransferState
]
});

View File

@@ -1,5 +1,5 @@
import { DOCUMENT } from '@angular/common';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { HttpClient, HttpErrorResponse, HttpResponse } from '@angular/common/http';
import { isPlatformBrowser, isPlatformServer } from '@angular/common';
import { computed, inject, Injectable, PLATFORM_ID, signal, TransferState, makeStateKey } from '@angular/core';
import { firstValueFrom, map, Observable, of, tap } from 'rxjs';
@@ -11,7 +11,11 @@ import {
LoginResponse,
RegisterPayload,
RegisterResponse,
UpdateProfilePayload
ResetPasswordPayload,
ResetPasswordAttemptResponse,
ResetPasswordResponse,
UpdateProfilePayload,
ValidateResetPasswordAttemptResponse
} from './auth.interfaces';
import { CookieService } from '../cookie/cookie.service';
import { TenantService } from '../tenant.service';
@@ -38,10 +42,21 @@ export class AuthService {
readonly isAuthenticated = computed(() => this.tokenState() !== null);
login(payload: LoginPayload): Observable<AuthUser> {
return this.http.post<LoginResponse>(`${environment.url}login`, payload).pipe(
tap((response) => this.applyAuthenticatedState(response.token, response.user)),
map((response) => response.user)
);
const tenant = this.tenantService.getTenant();
if (!tenant) {
throw new Error('No se pudo resolver el tenant activo.');
}
return this.http
.post<LoginResponse>(
`${environment.url}login`,
{ ...payload, tenant_codigo: tenant.codigo },
{ withCredentials: true }
)
.pipe(
tap((response) => this.applyAuthenticatedState(response.token, response.user)),
map((response) => response.user)
);
}
register(payload: RegisterPayload): Observable<RegisterResponse> {
@@ -53,6 +68,36 @@ export class AuthService {
});
}
requestPasswordReset(email: string): Observable<HttpResponse<ResetPasswordAttemptResponse>> {
const tenantCode = this.tenantService.getTenant()?.codigo;
return this.http.post<ResetPasswordAttemptResponse>(
`${environment.url}password/reset-attempts`,
{
email,
...(tenantCode ? { tenant_codigo: tenantCode } : {})
},
{ observe: 'response' }
);
}
validatePasswordResetCode(
email: string,
codigo: string
): Observable<HttpResponse<ValidateResetPasswordAttemptResponse>> {
return this.http.post<ValidateResetPasswordAttemptResponse>(
`${environment.url}password/reset-attempts/validate`,
{ email, codigo },
{ observe: 'response' }
);
}
resetPassword(payload: ResetPasswordPayload): Observable<HttpResponse<ResetPasswordResponse>> {
return this.http.post<ResetPasswordResponse>(`${environment.url}password/reset`, payload, {
observe: 'response'
});
}
loginWithGoogle(): void {
const tenant = this.tenantService.getTenant();
if (!tenant) {
@@ -68,16 +113,25 @@ export class AuthService {
}
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)
);
const tenant = this.tenantService.getTenant();
if (!tenant) {
throw new Error('No se pudo resolver el tenant activo.');
}
return this.http
.post<LoginResponse>(
`${environment.url}auth/google/exchange`,
{ oauth_code: oauthCode, tenant_codigo: tenant.codigo },
{ withCredentials: true }
)
.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))
);
return this.http.put<AuthUser>(`${environment.url}me`, payload).pipe(tap((user) => this.userState.set(user)));
}
logout(): Observable<void> {
@@ -86,9 +140,7 @@ export class AuthService {
return of(void 0);
}
return this.http.post<void>(`${environment.url}logout`, {}).pipe(
tap(() => this.clearSession())
);
return this.http.post<void>(`${environment.url}logout`, {}).pipe(tap(() => this.clearSession()));
}
async bootstrap(): Promise<void> {
@@ -127,9 +179,7 @@ export class AuthService {
}
loadCurrentUser(): Observable<AuthUser> {
return this.http.get<AuthUser>(`${environment.url}me`).pipe(
tap((user) => this.userState.set(user))
);
return this.http.get<AuthUser>(`${environment.url}me`).pipe(tap((user) => this.userState.set(user)));
}
hydrateSession(): void {

View File

@@ -36,6 +36,10 @@ export type InventoryPolicy = 'tracked' | 'unlimited';
export interface CatalogItemVariant {
id: number;
stock_tecnico: number | null;
minimum_use_date?: string | null;
maximum_use_date?: string | null;
effective_minimum_use_date?: string | null;
effective_maximum_use_date?: string | null;
values: Record<string, string>;
}
@@ -84,7 +88,8 @@ export interface CatalogFeaturedItem {
}
export type CatalogFeaturedItems =
ApiPaginatedResponse<CatalogFeaturedItem[]> | CatalogFeaturedItem[];
| ApiPaginatedResponse<CatalogFeaturedItem[]>
| CatalogFeaturedItem[];
export interface CatalogFeaturedGroup {
id: number;
@@ -94,3 +99,15 @@ export interface CatalogFeaturedGroup {
group_order: number;
items: CatalogFeaturedItems;
}
export interface CatalogCategory {
id: number;
nombre: string;
categoria_id: number | null;
}
export interface CategoryItemsResponse extends ApiPaginatedResponse<CatalogFeaturedItem[]> {
category: CatalogCategory;
layout: CatalogProductLayout;
group_layout: CatalogGroupLayout;
}

View File

@@ -8,13 +8,19 @@ import { ApiResponse } from '../api-response.interface';
import { TenantService } from '../tenant.service';
import {
CatalogFeaturedGroup,
CatalogFeaturedItem,
CatalogFeaturedItems,
CatalogItemDetail,
CategoryItemsResponse,
Product,
} from './catalog.interface';
type HttpParamValue = string | number | boolean | readonly (string | number | boolean)[];
export interface CatalogSearchQueryParams extends ApiPaginationQueryParams {
q: string;
}
@Injectable({
providedIn: 'root',
})
@@ -36,6 +42,24 @@ export class CatalogService {
return this.http.get<CatalogFeaturedGroup[]>(`${this.tenantApiUrl}/catalog`);
}
searchCatalog(
params: CatalogSearchQueryParams,
): Observable<ApiPaginatedResponse<CatalogFeaturedItem[]>> {
return this.http.get<ApiPaginatedResponse<CatalogFeaturedItem[]>>(
`${this.tenantApiUrl}/catalog-items`,
{ params: this.buildHttpParams(params) },
);
}
getCategoryItems(
categoryId: number,
params?: ApiPaginationQueryParams,
): Observable<CategoryItemsResponse> {
return this.http.get<CategoryItemsResponse>(`${this.tenantApiUrl}/categories/${categoryId}`, {
params: this.buildHttpParams(params),
});
}
getFeaturedGroupItems(
featuredGroupId: number,
params?: ApiPaginationQueryParams,

View File

@@ -4,14 +4,25 @@ import { firstValueFrom } from 'rxjs';
import { environment } from '../../../environments/environment';
export interface CreatePurchasePayload {
cart_id: number;
export interface UpdatePurchaseCustomerPayload {
dni: string;
telefono: string;
nombre_apellido: string;
email: string;
}
export type StartCheckoutPayload =
| {
cart_id: number;
}
| {
direct_item: {
catalog_item_id: number;
variant_id: number | null;
cantidad: number;
};
};
export interface PurchaseStatusResponse {
status: string | null;
}
@@ -57,22 +68,30 @@ export interface PurchaseDetailResponse extends PurchaseStatusResponse {
email: string | null;
items_source: 'purchase';
items: PurchaseDetailItemResponse[];
tickets_count?: number;
has_generated_tickets?: boolean;
subtotal: string;
total: string;
}
@Injectable({
providedIn: 'root'
providedIn: 'root',
})
export class CheckoutService {
private readonly http = inject(HttpClient);
async createPurchase(tenantCode: string, payload: CreatePurchasePayload): Promise<{ id: number }> {
async startCheckout(
tenantCode: string,
payload: StartCheckoutPayload,
): Promise<PurchaseDetailResponse> {
const response = await firstValueFrom(
this.http.post<{ data?: { id: number }; id?: number }>(`${environment.url}tenants/${tenantCode}/compras`, payload)
this.http.post<{ data?: PurchaseDetailResponse } | PurchaseDetailResponse>(
`${environment.url}tenants/${tenantCode}/compras/start-checkout`,
payload,
),
);
const purchase = this.extractResponseData<{ id: number }>(response);
const purchase = this.extractResponseData<PurchaseDetailResponse>(response);
if (!purchase?.id) {
throw new Error('Error al crear la compra.');
@@ -81,13 +100,21 @@ export class CheckoutService {
return purchase;
}
async generatePaymentIntent(tenantCode: string, purchaseId: number, method: 'qr' | 'transfer' | 'telepagos', payerDni?: string): Promise<any> {
async generatePaymentIntent(
tenantCode: string,
purchaseId: number,
method: 'qr' | 'transfer' | 'telepagos',
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`, payload)
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.');
@@ -95,9 +122,72 @@ export class CheckoutService {
return response;
}
async updateCustomerData(
tenantCode: string,
purchaseId: number,
payload: UpdatePurchaseCustomerPayload,
): Promise<PurchaseDetailResponse> {
const response = await firstValueFrom(
this.http.patch<{ data?: PurchaseDetailResponse } | PurchaseDetailResponse>(
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/customer-data`,
payload,
),
);
const purchase = this.extractResponseData<PurchaseDetailResponse>(response);
if (!purchase) {
throw new Error('Error al actualizar los datos de la compra.');
}
return purchase;
}
async updateItemQuantity(
tenantCode: string,
purchaseId: number,
itemId: number,
quantity: number,
): Promise<PurchaseDetailResponse> {
const response = await firstValueFrom(
this.http.patch<{ data?: PurchaseDetailResponse } | PurchaseDetailResponse>(
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/items/${itemId}`,
{ quantity },
),
);
const purchase = this.extractResponseData<PurchaseDetailResponse>(response);
if (!purchase) {
throw new Error('Error al actualizar la cantidad del producto.');
}
return purchase;
}
async prepareItemEditing(
tenantCode: string,
purchaseId: number,
): Promise<PurchaseDetailResponse> {
const response = await firstValueFrom(
this.http.post<{ data?: PurchaseDetailResponse } | PurchaseDetailResponse>(
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/edit-items`,
{},
),
);
const purchase = this.extractResponseData<PurchaseDetailResponse>(response);
if (!purchase) {
throw new Error('Error al preparar la compra para editarla.');
}
return purchase;
}
async completePurchase(tenantCode: string, purchaseId: number): Promise<PurchaseStatusResponse> {
const response = await firstValueFrom(
this.http.post<{ data?: PurchaseStatusResponse } | PurchaseStatusResponse>(`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/complete`, {})
this.http.post<{ data?: PurchaseStatusResponse } | PurchaseStatusResponse>(
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/complete`,
{},
),
);
const purchase = this.extractResponseData<PurchaseStatusResponse>(response);
@@ -107,27 +197,68 @@ export class CheckoutService {
}
return {
status: purchase.status ?? null
status: purchase.status ?? null,
};
}
async getPurchases(tenantCode: string, status?: string): Promise<{ data: PurchaseSummaryResponse[] }> {
async submitPurchaseForReview(
tenantCode: string,
purchaseId: number,
): Promise<PurchaseStatusResponse> {
const response = await firstValueFrom(
this.http.post<{ data?: PurchaseStatusResponse } | PurchaseStatusResponse>(
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/review`,
{},
),
);
const purchase = this.extractResponseData<PurchaseStatusResponse>(response);
if (!purchase) {
throw new Error('Error al enviar la compra a revisi\u00f3n.');
}
return { status: purchase.status ?? null };
}
async cancelPurchase(tenantCode: string, purchaseId: number): Promise<PurchaseStatusResponse> {
const response = await firstValueFrom(
this.http.post<{ data?: PurchaseStatusResponse } | PurchaseStatusResponse>(
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/cancel`,
{},
),
);
const purchase = this.extractResponseData<PurchaseStatusResponse>(response);
if (!purchase) {
throw new Error('Error al cancelar la compra.');
}
return { status: purchase.status ?? null };
}
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: PurchaseSummaryResponse[] }>(url)
);
const response = await firstValueFrom(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> {
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}`)
this.http.get<{ data?: PurchaseDetailResponse } | PurchaseDetailResponse>(
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}`,
),
);
const purchase = this.extractResponseData<PurchaseDetailResponse>(response);

View File

@@ -0,0 +1,17 @@
import { Menu } from './tenant.interface';
export function findMenu(menues: readonly Menu[], menuCode: string): Menu | undefined {
for (const menu of menues) {
if (menu.code === menuCode) {
return menu;
}
const submenu = findMenu(menu.submenues ?? [], menuCode);
if (submenu) {
return submenu;
}
}
return undefined;
}

View File

@@ -26,7 +26,12 @@ export interface EventConfig {
export interface Menu {
id: number;
code: string;
label: string;
parent_menu_code: string | null;
content_type: 'static' | 'dynamic';
route: string;
static_content?: unknown;
submenues: Menu[];
}
export interface SocialMedia {
@@ -36,6 +41,12 @@ export interface SocialMedia {
url: string;
}
export interface Category {
id: number;
nombre: string;
subcategories: Category[];
}
export interface Tenant {
id: number;
codigo: string;
@@ -53,10 +64,13 @@ export interface Tenant {
selected_bank_account?: BankAccount | null;
hero_config?: HeroConfig | null;
event_config?: EventConfig | null;
search_product_layout?: 'row' | 'column_with_image' | 'column_with_cart';
search_group_layout?: 'paginated' | 'simple' | 'simple_vertical' | 'carousel';
search_items_per_page?: number;
main_carousel_images?: string[];
social_media?: SocialMedia[];
menues?: Menu[];
categories: Category[];
}
export type TenantBootstrapResponse = ApiResponse<Tenant>;

View File

@@ -20,7 +20,20 @@ const tenant: Tenant = {
header_bg_color: '#313131',
footer_bg_color: '#313131',
header_logo: 'https://example.com/header.png',
footer_logo: 'https://example.com/footer.png'
footer_logo: 'https://example.com/footer.png',
categories: [
{
id: 1,
nombre: 'Remeras',
subcategories: [
{
id: 2,
nombre: 'Manga corta',
subcategories: []
}
]
}
]
};
const tenantResponse: TenantBootstrapResponse = {

View File

@@ -398,8 +398,8 @@
<div class="accordion-showcase">
<h2>Accordion</h2>
<p class="text-muted mb-4">
Accordion reutilizable basado en las clases globales de Bootstrap. Por defecto mantiene
un solo item abierto.
Accordion reutilizable basado en las clases globales de Bootstrap. Por defecto mantiene un
solo item abierto.
</p>
<app-accordion>
@@ -409,8 +409,8 @@
</app-accordion-item>
<app-accordion-item title="¿Qué medios de pago están disponibles?">
Los medios de pago dependen de la configuración de cada tienda y se muestran durante
el checkout.
Los medios de pago dependen de la configuración de cada tienda y se muestran durante el
checkout.
</app-accordion-item>
<app-accordion-item title="Item deshabilitado" [disabled]="true">
@@ -692,6 +692,23 @@
</div>
</section>
<section class="component-demo card shadow-sm mt-4" data-testid="location-map-demo">
<div class="card-body">
<span class="eyebrow">Reusable Component</span>
<h2 class="mb-2">Mapa de ubicaciones</h2>
<p class="text-muted mb-4">
Prueba de Leaflet con múltiples puntos, encuadre automático y acceso a indicaciones.
</p>
<app-location-map
[locations]="mapLocations"
[markerColor]="tenant()?.primary_color ?? '#24a9e0'"
mapStyle="minimal-light"
ariaLabel="Mapa de puntos de atención en Rosario"
/>
</div>
</section>
<section class="component-demo card shadow-sm mt-4">
<div class="card-body">
<span class="eyebrow">Reusable Component</span>

View File

@@ -23,6 +23,7 @@ const tenant: Tenant = {
footer_bg_color: '#313131',
header_logo: 'https://example.com/header.png',
footer_logo: 'https://example.com/footer.png',
categories: [],
};
function createTenantServiceStub(currentTenant: Tenant | null) {
@@ -141,6 +142,40 @@ describe('ReutilizablesTestPageComponent', () => {
);
});
it('renders the reusable map demo with all configured locations', async () => {
await TestBed.configureTestingModule({
imports: [ReutilizablesTestPageComponent],
providers: [
{
provide: TenantService,
useValue: createTenantServiceStub(tenant),
},
{
provide: ToastService,
useValue: createToastServiceStub(),
},
{
provide: ModalService,
useValue: createModalServiceStub(),
},
],
}).compileComponents();
const fixture = TestBed.createComponent(ReutilizablesTestPageComponent);
fixture.detectChanges();
const mapDemo = fixture.nativeElement.querySelector(
'[data-testid="location-map-demo"]',
) as HTMLElement;
const accessibleLocations = mapDemo.querySelectorAll('app-location-map .visually-hidden li');
expect(mapDemo.querySelector('app-location-map')).not.toBeNull();
expect(accessibleLocations).toHaveLength(3);
expect(mapDemo.textContent).toContain('Gigante de Arroyito');
fixture.destroy();
});
it('renders the carousel demo with mocked product images', async () => {
await TestBed.configureTestingModule({
imports: [ReutilizablesTestPageComponent],

View File

@@ -18,6 +18,10 @@ import { CarouselComponent } from '../../../../shared/components/carousel/carous
import { MainCarouselComponent } from '../../../../shared/components/main-carousel/main-carousel.component';
import { AccordionComponent } from '../../../../shared/components/accordion/accordion.component';
import { AccordionItemComponent } from '../../../../shared/components/accordion/accordion-item.component';
import {
LocationMapComponent,
MapLocation,
} from '../../../../shared/components/location-map/location-map.component';
interface CarouselProductMock {
id: number;
@@ -47,6 +51,7 @@ interface CarouselProductMock {
MainCarouselComponent,
AccordionComponent,
AccordionItemComponent,
LocationMapComponent,
],
templateUrl: './reutilizables-test-page.component.html',
styleUrl: './reutilizables-test-page.component.scss',
@@ -79,6 +84,29 @@ export class ReutilizablesTestPageComponent {
protected lastModalResult = 'Todavia no se abrio ningun modal.';
protected readonly paginatorTotalPages = 8;
protected readonly cartBackgroundColor = '#ffffff';
protected readonly mapLocations: readonly MapLocation[] = [
{
id: 'gigante-de-arroyito',
label: 'Gigante de Arroyito',
address: 'Av. Génova 640, Rosario',
latitude: -32.913997,
longitude: -60.674567,
},
{
id: 'punto-centro',
label: 'Punto Centro',
address: 'Centro, Rosario',
latitude: -32.94682,
longitude: -60.63932,
},
{
id: 'punto-sur',
label: 'Punto Sur',
address: 'Zona sur, Rosario',
latitude: -32.97151,
longitude: -60.65064,
},
];
protected readonly mainCarouselImages = [
'https://images.unsplash.com/photo-1441986300917-64674bd600d8?auto=format&fit=crop&w=1600&q=80',
'https://images.unsplash.com/photo-1529139574466-a303027c1d8b?auto=format&fit=crop&w=1600&q=80',

View File

@@ -1,11 +1,11 @@
<div class="sidebar-container">
<h2 class="sidebar-title">MI CUENTA</h2>
<h2 class="sidebar-title">{{ menu()?.label }}</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 class="sidebar-nav" aria-label="Secciones de mi cuenta">
@for (menu of submenues(); track menu.code) {
<a [routerLink]="menu.route" routerLinkActive="active" class="nav-link">
{{ menu.label }}
</a>
}
</nav>
</div>

View File

@@ -7,6 +7,7 @@
font-weight: bold;
color: #1a1a1a;
margin-bottom: 12px;
text-transform: uppercase;
}
.divider {
height: 1px;

View File

@@ -0,0 +1,85 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { describe, expect, it } from 'vitest';
import { Tenant } from '../../../../../../core/services/tenant.interface';
import { TenantService } from '../../../../../../core/services/tenant.service';
import { AccountSidebar } from './account-sidebar';
describe('AccountSidebar', () => {
it('renders only the account submenus configured for the tenant', async () => {
const tenant = signal<Tenant>({
id: 1,
codigo: 'test',
nombre: 'Test',
dominio: 'localhost',
primary_color: '#000000',
secondary_color: '#000000',
danger_color: '#000000',
success_color: '#000000',
header_bg_color: '#ffffff',
footer_bg_color: '#ffffff',
header_logo: '',
footer_logo: '',
categories: [],
menues: [
{
id: 1,
code: 'account',
label: 'Mi cuenta',
parent_menu_code: null,
content_type: 'dynamic',
route: '/mi-cuenta',
submenues: [
{
id: 2,
code: 'account.profile',
label: 'Datos personales',
parent_menu_code: 'account',
content_type: 'dynamic',
route: '/mi-cuenta/datos-personales',
submenues: [],
},
{
id: 3,
code: 'account.purchases',
label: 'Mis compras',
parent_menu_code: 'account',
content_type: 'dynamic',
route: '/mi-cuenta/compras',
submenues: [],
},
],
},
],
});
await TestBed.configureTestingModule({
imports: [AccountSidebar],
providers: [
provideRouter([]),
{
provide: TenantService,
useValue: { tenant: tenant.asReadonly() },
},
],
}).compileComponents();
const fixture = TestBed.createComponent(AccountSidebar);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const links = Array.from(element.querySelectorAll<HTMLAnchorElement>('.nav-link'));
expect(element.querySelector('.sidebar-title')?.textContent?.trim()).toBe('Mi cuenta');
expect(links.map((link) => link.textContent?.trim())).toEqual([
'Datos personales',
'Mis compras',
]);
expect(links.map((link) => link.getAttribute('href'))).toEqual([
'/mi-cuenta/datos-personales',
'/mi-cuenta/compras',
]);
});
});

View File

@@ -1,6 +1,9 @@
import { Component } from '@angular/core';
import { Component, computed, inject } from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router';
import { findMenu } from '../../../../../../core/services/menu.utils';
import { TenantService } from '../../../../../../core/services/tenant.service';
@Component({
selector: 'app-account-sidebar',
standalone: true,
@@ -8,4 +11,11 @@ import { RouterLink, RouterLinkActive } from '@angular/router';
templateUrl: './account-sidebar.html',
styleUrl: './account-sidebar.scss',
})
export class AccountSidebar {}
export class AccountSidebar {
private readonly tenantService = inject(TenantService);
protected readonly menu = computed(() =>
findMenu(this.tenantService.tenant()?.menues ?? [], 'account'),
);
protected readonly submenues = computed(() => this.menu()?.submenues ?? []);
}

View File

@@ -41,9 +41,15 @@
<app-icon-button
variant="download"
ariaLabel="Descargar ticket"
[disabled]="isGeneratingPdf()"
(clicked)="download.emit()"
/>
<app-icon-button variant="share" ariaLabel="Compartir ticket" (clicked)="share.emit()" />
<app-icon-button
variant="share"
ariaLabel="Compartir ticket"
[disabled]="isGeneratingPdf()"
(clicked)="share.emit()"
/>
}
</div>
</article>

View File

@@ -16,6 +16,7 @@ export class TicketComponent {
readonly date = input<string | null>(null);
readonly disabled = input(false);
readonly expired = input(false);
readonly isGeneratingPdf = input(false);
readonly selected = model(false);
readonly viewQr = output<void>();

View File

@@ -30,4 +30,14 @@ export class TicketService {
this.http.get<{ data: TicketResponse[] }>(`${this.tenantService.getTenantApiUrl()}/tickets`),
).then((response) => response.data ?? []);
}
downloadPdf(ticketIds: number[]): Promise<Blob> {
return firstValueFrom(
this.http.post(
`${this.tenantService.getTenantApiUrl()}/tickets/pdf`,
{ ticket_ids: ticketIds },
{ responseType: 'blob' },
),
);
}
}

View File

@@ -1,33 +1,74 @@
<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 class="tickets-list" [attr.aria-busy]="isLoading() || isGeneratingPdf()">
@if (isGeneratingPdf()) {
<div class="tickets-list__generating" role="status" aria-live="polite">
<span class="tickets-list__generating-spinner" aria-hidden="true"></span>
<span>Generando PDF...</span>
</div>
</header>
}
@if (activeTickets().length) {
<header class="tickets-list__header">
<label class="tickets-list__select-all">
<input
type="checkbox"
[checked]="allSelected()"
(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"
[disabled]="isGeneratingPdf()"
(clicked)="downloadSelected()"
/>
<small>Descargar</small>
</span>
<span class="tickets-list__bulk-action">
<app-icon-button
variant="share"
ariaLabel="Compartir tickets seleccionados"
[disabled]="isGeneratingPdf()"
(clicked)="shareSelected()"
/>
<small>Compartir</small>
</span>
</div>
</header>
}
@if (isLoading()) {
<header class="tickets-list__skeleton-header" aria-hidden="true">
<div class="tickets-list__skeleton-selection">
<span class="tickets-list__skeleton-check"></span>
<span class="tickets-list__skeleton-line tickets-list__skeleton-line--section"></span>
</div>
<div class="tickets-list__skeleton-bulk-actions">
<span class="tickets-list__skeleton-icon"></span>
<span class="tickets-list__skeleton-icon"></span>
</div>
</header>
@for (item of [1, 2, 3, 4]; track item) {
<div class="tickets-list__skeleton" aria-hidden="true"></div>
<div class="tickets-list__skeleton" aria-hidden="true">
<span class="tickets-list__skeleton-check"></span>
<div class="tickets-list__skeleton-details">
<span class="tickets-list__skeleton-line tickets-list__skeleton-line--title"></span>
<span class="tickets-list__skeleton-line tickets-list__skeleton-line--id"></span>
</div>
<span class="tickets-list__skeleton-line tickets-list__skeleton-line--date"></span>
<div class="tickets-list__skeleton-actions">
<span class="tickets-list__skeleton-button"></span>
<span class="tickets-list__skeleton-icon"></span>
<span class="tickets-list__skeleton-icon"></span>
</div>
</div>
}
} @else if (tickets().length) {
@for (ticket of activeTickets(); track ticket.id) {
@@ -36,8 +77,11 @@
[ticketId]="ticket.id"
[date]="formatDate(ticket)"
[selected]="isSelected(ticket.id)"
[isGeneratingPdf]="isGeneratingPdf()"
(selectedChange)="toggleTicket(ticket.id, $event)"
(viewQr)="viewQr(ticket)"
(download)="downloadTicket(ticket)"
(share)="shareTicket(ticket)"
/>
}

View File

@@ -10,6 +10,35 @@
font-weight: 700;
}
.tickets-list {
position: relative;
}
.tickets-list__generating {
position: absolute;
z-index: 10;
inset: 0;
display: flex;
min-height: 180px;
align-items: center;
justify-content: center;
gap: 12px;
border-radius: 8px;
color: #26322a;
font-size: 14px;
font-weight: 700;
backdrop-filter: blur(2px);
}
.tickets-list__generating-spinner {
width: 24px;
height: 24px;
border: 3px solid #dce8e0;
border-top-color: var(--tenant-primary, #009933);
border-radius: 50%;
animation: tickets-spinner 0.75s linear infinite;
}
.tickets-list__header {
display: flex;
min-height: 58px;
@@ -75,22 +104,128 @@
text-align: center;
}
.tickets-list__skeleton {
height: 77px;
.tickets-list__skeleton-header {
display: flex;
min-height: 58px;
align-items: center;
justify-content: space-between;
padding-bottom: 12px;
border-bottom: 1px solid #dddddd;
background: linear-gradient(90deg, transparent, #f3f3f3, transparent);
background-size: 200% 100%;
animation: loading 1.4s infinite;
}
@keyframes loading {
.tickets-list__skeleton-selection,
.tickets-list__skeleton-bulk-actions,
.tickets-list__skeleton-actions {
display: flex;
align-items: center;
}
.tickets-list__skeleton-selection {
gap: 16px;
}
.tickets-list__skeleton-bulk-actions,
.tickets-list__skeleton-actions {
gap: 8px;
}
.tickets-list__skeleton {
display: grid;
grid-template-columns: auto minmax(0, 1fr) 78px auto;
min-height: 76px;
align-items: center;
gap: 16px;
padding: 16px 0;
border-bottom: 1px solid #dddddd;
}
.tickets-list__skeleton-check,
.tickets-list__skeleton-line,
.tickets-list__skeleton-icon,
.tickets-list__skeleton-button {
display: block;
border-radius: 4px;
background: linear-gradient(90deg, #eeeeee 20%, #f7f7f7 50%, #eeeeee 80%);
background-size: 200% 100%;
animation: tickets-loading 1.4s ease-in-out infinite;
}
.tickets-list__skeleton-check {
width: 21px;
height: 21px;
border-radius: 2px;
}
.tickets-list__skeleton-details {
display: grid;
gap: 8px;
}
.tickets-list__skeleton-line--section {
width: 120px;
height: 14px;
}
.tickets-list__skeleton-line--title {
width: min(240px, 75%);
height: 13px;
}
.tickets-list__skeleton-line--id {
width: 86px;
height: 11px;
}
.tickets-list__skeleton-line--date {
width: 64px;
height: 11px;
justify-self: center;
}
.tickets-list__skeleton-button {
width: 115px;
height: 36px;
}
.tickets-list__skeleton-icon {
width: 28px;
height: 28px;
border-radius: 50%;
}
@keyframes tickets-loading {
to {
background-position: -200% 0;
}
}
@keyframes tickets-spinner {
to {
transform: rotate(360deg);
}
}
@media (max-width: 767.98px) {
.tickets-page__title {
margin-top: 24px;
}
.tickets-list__skeleton {
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 10px;
}
.tickets-list__skeleton-line--date {
grid-column: 2;
justify-self: start;
}
.tickets-list__skeleton-actions {
grid-column: 3;
grid-row: 1 / span 2;
}
.tickets-list__skeleton-actions .tickets-list__skeleton-icon {
display: none;
}
}

View File

@@ -26,6 +26,149 @@ const ticket = (overrides: Partial<TicketResponse>): TicketResponse => ({
});
describe('TicketsPage', () => {
it('shares the generated PDF as a file without text that Windows could prioritize', async () => {
const share = vi.fn().mockResolvedValue(undefined);
const canShare = vi.fn().mockReturnValue(true);
vi.stubGlobal('navigator', { canShare, share });
await TestBed.configureTestingModule({
imports: [TicketsPage],
providers: [{ provide: ToastService, useValue: { danger: vi.fn() } }],
})
.overrideComponent(TicketsPage, {
set: {
providers: [
{
provide: TicketService,
useValue: {
getTickets: () => Promise.resolve([ticket({})]),
downloadPdf: () => Promise.resolve(new Blob(['pdf-content'])),
},
},
],
},
})
.compileComponents();
const fixture = TestBed.createComponent(TicketsPage);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const page = fixture.nativeElement as HTMLElement;
page.querySelector<HTMLButtonElement>('[aria-label="Compartir ticket"]')?.click();
await vi.waitFor(() => expect(share).toHaveBeenCalledOnce());
const shareData = share.mock.calls[0][0] as ShareData;
const [sharedFile] = shareData.files ?? [];
expect(Object.keys(shareData)).toEqual(['files']);
expect(canShare).toHaveBeenCalledWith({ files: [sharedFile] });
expect(sharedFile).toBeInstanceOf(File);
expect(sharedFile.name).toBe('tickets_1.pdf');
expect(sharedFile.type).toBe('application/pdf');
expect(sharedFile.size).toBeGreaterThan(0);
vi.unstubAllGlobals();
});
it('shows a loading overlay and disables PDF actions while the file is generated', async () => {
let resolvePdf!: (pdf: Blob) => void;
const downloadPdf = vi.fn(
() =>
new Promise<Blob>((resolve) => {
resolvePdf = resolve;
}),
);
const createObjectUrl = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:ticket');
const revokeObjectUrl = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined);
let downloadedFilename = '';
const linkClick = vi
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(function (this: HTMLAnchorElement) {
downloadedFilename = this.download;
});
await TestBed.configureTestingModule({
imports: [TicketsPage],
providers: [{ provide: ToastService, useValue: { danger: vi.fn() } }],
})
.overrideComponent(TicketsPage, {
set: {
providers: [
{
provide: TicketService,
useValue: {
getTickets: () => Promise.resolve([ticket({})]),
downloadPdf,
},
},
],
},
})
.compileComponents();
const fixture = TestBed.createComponent(TicketsPage);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const page = fixture.nativeElement as HTMLElement;
page.querySelector<HTMLButtonElement>('[aria-label="Descargar ticket"]')?.click();
fixture.detectChanges();
expect(page.querySelector('.tickets-list__generating')?.textContent).toContain(
'Generando PDF...',
);
expect(page.querySelector('.tickets-list')?.getAttribute('aria-busy')).toBe('true');
expect(page.querySelector<HTMLButtonElement>('[aria-label="Descargar ticket"]')?.disabled).toBe(
true,
);
expect(page.querySelector<HTMLButtonElement>('[aria-label="Compartir ticket"]')?.disabled).toBe(
true,
);
resolvePdf(new Blob(['pdf'], { type: 'application/pdf' }));
await fixture.whenStable();
await vi.waitFor(() => expect(downloadedFilename).toBe('tickets_1.pdf'));
fixture.detectChanges();
expect(page.querySelector('.tickets-list__generating')).toBeNull();
expect(page.querySelector('.tickets-list')?.getAttribute('aria-busy')).toBe('false');
createObjectUrl.mockRestore();
revokeObjectUrl.mockRestore();
linkClick.mockRestore();
});
it('hides the active tickets header when there are no available tickets', async () => {
await TestBed.configureTestingModule({
imports: [TicketsPage],
providers: [{ provide: ToastService, useValue: { danger: vi.fn() } }],
})
.overrideComponent(TicketsPage, {
set: {
providers: [
{ provide: TicketService, useValue: { getTickets: () => Promise.resolve([]) } },
],
},
})
.compileComponents();
const fixture = TestBed.createComponent(TicketsPage);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const page = fixture.nativeElement as HTMLElement;
expect(page.querySelector('.tickets-list__select-all')).toBeNull();
expect(page.querySelector('.tickets-list__bulk-actions')).toBeNull();
expect(page.textContent).not.toContain('TICKETS ACTIVOS');
expect(page.textContent).toContain('Aún no tenés tickets disponibles.');
});
it('separates expired and used tickets into the disabled inactive list', async () => {
const tickets = [
ticket({ id: 1 }),

View File

@@ -27,6 +27,7 @@ export class TicketsPage implements OnInit {
);
protected readonly selectedIds = signal<Set<number>>(new Set());
protected readonly isLoading = signal(true);
protected readonly isGeneratingPdf = signal(false);
protected readonly allSelected = computed(
() =>
this.activeTickets().length > 0 && this.selectedIds().size === this.activeTickets().length,
@@ -86,4 +87,91 @@ export class TicketsPage implements OnInit {
ticket: ticket.ticket,
});
}
protected async downloadTicket(ticket: TicketResponse): Promise<void> {
await this.downloadTickets([ticket]);
}
protected async downloadSelected(): Promise<void> {
const tickets = this.activeTickets().filter((ticket) => this.selectedIds().has(ticket.id));
if (!tickets.length) {
this.toastService.danger('Seleccioná al menos un ticket para descargar.');
return;
}
await this.downloadTickets(tickets);
}
protected async shareTicket(ticket: TicketResponse): Promise<void> {
await this.shareTickets([ticket]);
}
protected async shareSelected(): Promise<void> {
const tickets = this.activeTickets().filter((ticket) => this.selectedIds().has(ticket.id));
if (!tickets.length) {
this.toastService.danger('Seleccioná al menos un ticket para compartir.');
return;
}
await this.shareTickets(tickets);
}
private async downloadTickets(tickets: TicketResponse[]): Promise<void> {
const pdf = await this.generateTicketsPdf(tickets);
if (pdf) this.downloadPdf(pdf, this.pdfFilename(tickets));
}
private async shareTickets(tickets: TicketResponse[]): Promise<void> {
const pdf = await this.generateTicketsPdf(tickets);
if (!pdf) return;
try {
const filename = this.pdfFilename(tickets);
const file = new File([pdf], filename, { type: 'application/pdf' });
if (navigator.canShare?.({ files: [file] })) {
await navigator.share({ files: [file] });
return;
}
this.downloadPdf(pdf, filename);
this.toastService.success('Tu navegador no permite compartir archivos; descargamos el PDF.');
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') return;
this.toastService.danger('No se pudo compartir el PDF de los tickets.');
}
}
private async generateTicketsPdf(tickets: TicketResponse[]): Promise<Blob | null> {
if (this.isGeneratingPdf()) return null;
this.isGeneratingPdf.set(true);
try {
return await this.ticketService.downloadPdf(tickets.map((ticket) => ticket.id));
} catch {
this.toastService.danger('No se pudo generar el PDF de los tickets.');
return null;
} finally {
this.isGeneratingPdf.set(false);
}
}
private pdfFilename(tickets: TicketResponse[]): string {
return `tickets_${tickets.map((ticket) => ticket.id).join('_')}.pdf`;
}
private downloadPdf(pdf: Blob, filename: string): void {
const url = URL.createObjectURL(pdf);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 0);
}
}

View File

@@ -0,0 +1,29 @@
<section class="category-items" aria-labelledby="category-items-title">
@if (error()) {
<p class="alert text-center mb-0 category-items__alert-error">{{ error() }}</p>
} @else if (loading()) {
<p class="alert alert-light border text-center mb-0">Cargando productos...</p>
} @else if (results(); as categoryResults) {
<header class="category-items__header mb-5">
<h1 id="category-items-title" class="category-items__title mb-0">
{{ categoryResults.category.nombre }}
</h1>
</header>
@if (!categoryResults.data.length) {
<p class="alert alert-light border text-center mb-0">
No hay productos disponibles en esta categoría.
</p>
} @else {
<app-product-list
[layout]="categoryResults.layout"
[groupLayout]="paginatedLayout"
[items]="categoryResults"
[loading]="loading()"
(buy)="onBuyProduct($event)"
(addToCart)="onAddToCart($event)"
(pageChange)="onPageChange($event)"
/>
}
}
</section>

View File

@@ -0,0 +1,18 @@
.category-items__header {
display: flex;
align-items: center;
justify-content: center;
padding: 16px 28px;
}
.category-items__title {
font-size: 24px;
font-weight: bold;
line-height: 1.2;
text-align: center;
color:var(--bs-primary)
}
.category-items__alert-error {
color: var(--tenant-danger-color, var(--bs-danger));
}

View File

@@ -0,0 +1,124 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { CartService } from '../../../../core/services/cart/cart.service';
import { CatalogService } from '../../../../core/services/catalog/catalog.service';
import { Tenant } from '../../../../core/services/tenant.interface';
import { TenantService } from '../../../../core/services/tenant.service';
import { ToastService } from '../../../../core/services/toast.service';
import { ProductListComponent } from '../../../../shared/components/product-list/product-list.component';
import { CategoryItemsPageComponent } from './category-items-page.component';
describe('CategoryItemsPageComponent', () => {
const getCategoryItems = vi.fn();
const tenant = signal({ codigo: 'shop' } as Tenant);
beforeEach(async () => {
getCategoryItems.mockReset();
getCategoryItems.mockReturnValue(
of({
category: {
id: 7,
nombre: 'Remeras',
categoria_id: null,
},
layout: 'row',
group_layout: 'simple_vertical',
data: [
{
id: 1,
nombre: 'Remera clásica',
descripcion: 'Algodón',
precio: '100.00',
variants: [],
},
],
links: {
first: '/categories/7?page=1',
last: '/categories/7?page=2',
prev: null,
next: '/categories/7?page=2',
},
meta: {
current_page: 1,
from: 1,
last_page: 2,
links: [],
path: '/categories/7',
per_page: 12,
to: 1,
total: 13,
},
}),
);
await TestBed.configureTestingModule({
imports: [CategoryItemsPageComponent],
providers: [
provideRouter([]),
{
provide: ActivatedRoute,
useValue: {
paramMap: of(convertToParamMap({ id: '7' })),
queryParamMap: of(convertToParamMap({ page: '1' })),
},
},
{
provide: CatalogService,
useValue: { getCategoryItems },
},
{
provide: TenantService,
useValue: { tenant: tenant.asReadonly() },
},
{
provide: CartService,
useValue: { addItem: vi.fn() },
},
{
provide: ToastService,
useValue: {
success: vi.fn(),
danger: vi.fn(),
},
},
],
}).compileComponents();
});
it('loads the category and renders its products with the configured product layout', () => {
const fixture = TestBed.createComponent(CategoryItemsPageComponent);
fixture.detectChanges();
expect(getCategoryItems).toHaveBeenCalledWith(7, { page: 1 });
expect((fixture.nativeElement as HTMLElement).textContent).toContain('Remeras');
const productList = fixture.debugElement.query(By.directive(ProductListComponent))
.componentInstance as ProductListComponent;
expect(productList.layout()).toBe('row');
expect(productList.groupLayout()).toBe('paginated');
expect(Array.isArray(productList.items())).toBe(false);
});
it('does not request the API when the category id is invalid', () => {
TestBed.overrideProvider(ActivatedRoute, {
useValue: {
paramMap: of(convertToParamMap({ id: 'invalid' })),
queryParamMap: of(convertToParamMap({ page: '1' })),
},
});
const fixture = TestBed.createComponent(CategoryItemsPageComponent);
fixture.detectChanges();
expect(getCategoryItems).not.toHaveBeenCalled();
expect((fixture.nativeElement as HTMLElement).textContent).toContain(
'La categoría solicitada no es válida.',
);
});
});

View File

@@ -0,0 +1,165 @@
import { HttpErrorResponse } from '@angular/common/http';
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
Injector,
inject,
signal,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router } from '@angular/router';
import { catchError, combineLatest, distinctUntilChanged, map, of, switchMap, tap } from 'rxjs';
import { AuthService } from '../../../../core/services/auth/auth.service';
import { CartService } from '../../../../core/services/cart/cart.service';
import { CheckoutService } from '../../../../core/services/checkout.service';
import {
CatalogGroupLayout,
CategoryItemsResponse,
} from '../../../../core/services/catalog/catalog.interface';
import { CatalogService } from '../../../../core/services/catalog/catalog.service';
import { TenantService } from '../../../../core/services/tenant.service';
import { ToastService } from '../../../../core/services/toast.service';
import {
ProductListBuyEvent,
ProductListCartEvent,
ProductListComponent,
} from '../../../../shared/components/product-list/product-list.component';
interface CategoryRouteState {
categoryId: number;
page: number;
}
@Component({
selector: 'app-category-items-page',
imports: [ProductListComponent],
templateUrl: './category-items-page.component.html',
styleUrl: './category-items-page.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CategoryItemsPageComponent {
private readonly cartService = inject(CartService);
private readonly catalogService = inject(CatalogService);
private readonly injector = inject(Injector);
private readonly destroyRef = inject(DestroyRef);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly tenantService = inject(TenantService);
private readonly toastService = inject(ToastService);
protected readonly results = signal<CategoryItemsResponse | null>(null);
protected readonly loading = signal(true);
protected readonly error = signal<string | null>(null);
protected readonly creatingDirectPurchase = signal(false);
protected readonly paginatedLayout: CatalogGroupLayout = 'paginated';
constructor() {
combineLatest([this.route.paramMap, this.route.queryParamMap])
.pipe(
map(
([params, queryParams]): CategoryRouteState => ({
categoryId: this.parsePositiveInteger(params.get('id')),
page: this.parsePositiveInteger(queryParams.get('page'), 1),
}),
),
distinctUntilChanged(
(previous, current) =>
previous.categoryId === current.categoryId && previous.page === current.page,
),
tap(() => {
this.results.set(null);
this.error.set(null);
this.loading.set(true);
}),
switchMap(({ categoryId, page }) => {
if (categoryId === 0) {
this.error.set('La categoría solicitada no es válida.');
return of(null);
}
return this.catalogService.getCategoryItems(categoryId, { page }).pipe(
catchError(() => {
this.error.set('No pudimos cargar los productos de esta categoría.');
return of(null);
}),
);
}),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((results) => {
this.results.set(results);
this.loading.set(false);
});
}
protected onPageChange(page: number): void {
void this.router.navigate([], {
relativeTo: this.route,
queryParams: { page },
queryParamsHandling: 'merge',
});
}
protected async onBuyProduct(event: ProductListBuyEvent): Promise<void> {
if (!event.directPurchase) {
await this.router.navigate(['/producto', event.product.id]);
return;
}
if (this.creatingDirectPurchase()) {
return;
}
if (!this.injector.get(AuthService).user()) {
await this.router.navigate(['/login'], {
queryParams: { returnUrl: `/producto/${event.product.id}` },
});
return;
}
const tenant = this.tenantService.tenant();
if (!tenant) {
this.toastService.danger('No se pudo identificar la tienda.');
return;
}
this.creatingDirectPurchase.set(true);
try {
const purchase = await this.injector.get(CheckoutService).startCheckout(tenant.codigo, {
direct_item: {
catalog_item_id: event.product.id,
variant_id: event.variant ?? null,
cantidad: event.quantity,
},
});
await this.router.navigate(['/checkout'], { queryParams: { purchase: purchase.id } });
} catch (error) {
console.error('Failed to create direct purchase:', error);
this.toastService.danger('No se pudo iniciar la compra directa.');
} finally {
this.creatingDirectPurchase.set(false);
}
}
protected onAddToCart(event: ProductListCartEvent): void {
this.cartService.addItem(event.product.id, event.variant ?? null, event.quantity).subscribe({
next: (response) => {
this.toastService.success(response.message || 'Producto agregado al carrito');
},
error: (error: HttpErrorResponse) => {
const message = error.error?.message || 'No se pudo agregar el producto al carrito.';
this.toastService.danger(message);
},
});
}
private parsePositiveInteger(value: string | null, fallback = 0): number {
const parsed = Number(value ?? fallback);
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
}
}

View File

@@ -1,7 +1,17 @@
@if (isLoadingPurchase()) {
<div class="checkout-page__loading" role="status" aria-live="polite">
<span class="checkout-page__spinner" aria-hidden="true"></span>
<span>Cargando compra...</span>
</div>
} @else {
<div class="checkout-page">
<div class="checkout-page__stepper-col ">
<app-stepper #stepper>
<div
class="checkout-page__stepper-col"
[class.checkout-page__stepper-col--editing]="isEditingItems()"
[attr.aria-hidden]="isEditingItems()"
[attr.inert]="isEditingItems() ? '' : null"
>
<app-stepper #stepper [initialStepIndex]="checkoutStepIndex()">
<app-step label="Datos" [isValid]="isStep1Valid()">
<app-checkout-data-step
[form]="form"
@@ -21,6 +31,8 @@
[qrData]="qrData()"
[qrPaymentStatus]="qrPaymentStatus()"
[isCheckingQrPayment]="isCheckingQrPayment()"
[qrPaymentAmount]="cartTotal()"
[whatsappUrl]="whatsappUrl()"
[transferValidationStatus]="transferValidationStatus()"
(paymentMethodChange)="selectPaymentMethod($event)"
(copyTransferValue)="copyTransferValue($event.field, $event.value)"
@@ -33,13 +45,30 @@
</app-stepper>
</div>
@if (isEditingItems()) {
<div class="checkout-page__editing-notice" role="status">
<p>Terminá de modificar las cantidades para continuar con el pago.</p>
</div>
}
<div class="checkout-page__cart-col">
<app-cart
title="COMPRA"
[items]="mappedCartItems()"
[subtotal]="cartSubtotal()"
[discount]="cartDiscount()"
[total]="cartTotal()"
[allowEditing]="
createdPurchase()?.status === 'created' || createdPurchase()?.status === 'pending_payment'
"
[allowRemove]="false"
[persistQuantityChanges]="false"
[editing]="isEditingItems()"
[editingDisabled]="isUpdatingItem() || isPreparingItemEdit()"
backgroundColor="transparent"
(editingChange)="onEditingItemsChange($event)"
(itemQuantityChange)="onPurchaseItemQuantityChange($event)"
/>
</div>
</div>
}

View File

@@ -12,13 +12,57 @@
min-width: 0;
border-radius: 4px;
min-height: 420px;
&--editing {
display: none;
}
}
&__editing-notice {
display: grid;
min-height: 420px;
place-items: center;
padding: 2rem;
border-radius: 4px;
background: #f5f5f5;
color: #666666;
text-align: center;
p {
max-width: 360px;
margin: 0;
font-size: 14px;
}
}
&__cart-col {
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
}
.checkout-page__loading {
display: grid;
min-height: 420px;
place-items: center;
align-content: center;
gap: 1rem;
color: #666666;
}
.checkout-page__spinner {
width: 3rem;
height: 3rem;
border: 0.25rem solid rgba(32, 32, 32, 0.08);
border-top-color: var(--tenant-primary);
border-radius: 50%;
animation: checkout-page-spin 0.8s linear infinite;
}
@keyframes checkout-page-spin {
to {
transform: rotate(360deg);
}
}

View File

@@ -1,20 +1,27 @@
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 { ActivatedRoute, convertToParamMap, 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 { CatalogService } from '../../../../core/services/catalog/catalog.service';
import { TenantService } from '../../../../core/services/tenant.service';
import { CheckoutPageComponent } from './checkout-page.component';
describe('CheckoutPageComponent payment validation', () => {
let checkoutServiceStub: {
startCheckout: ReturnType<typeof vi.fn>;
updateCustomerData: ReturnType<typeof vi.fn>;
updateItemQuantity: ReturnType<typeof vi.fn>;
prepareItemEditing: ReturnType<typeof vi.fn>;
cancelPurchase: ReturnType<typeof vi.fn>;
generatePaymentIntent: ReturnType<typeof vi.fn>;
getPurchase: ReturnType<typeof vi.fn>;
submitPurchaseForReview: ReturnType<typeof vi.fn>;
};
let cartServiceStub: {
cart: ReturnType<typeof signal>;
@@ -23,6 +30,8 @@ describe('CheckoutPageComponent payment validation', () => {
clearCart: ReturnType<typeof vi.fn>;
};
let routerStub: { navigate: ReturnType<typeof vi.fn> };
let routeQueryParamMap: ReturnType<typeof convertToParamMap>;
let authUserState: ReturnType<typeof signal>;
beforeAll(() => {
try {
@@ -36,10 +45,21 @@ describe('CheckoutPageComponent payment validation', () => {
vi.useFakeTimers();
checkoutServiceStub = {
startCheckout: vi.fn().mockResolvedValue({
id: 25,
items: [],
subtotal: '0.00',
total: '0.00',
}),
updateCustomerData: vi.fn(),
updateItemQuantity: vi.fn(),
prepareItemEditing: vi.fn(),
cancelPurchase: vi.fn().mockResolvedValue({ status: 'cancelled' }),
generatePaymentIntent: vi.fn().mockResolvedValue({
qr_data: { qr_code: 'qr-value' },
}),
getPurchase: vi.fn().mockResolvedValue({ status: 'pending_payment' }),
submitPurchaseForReview: vi.fn().mockResolvedValue({ status: 'in_review' }),
};
cartServiceStub = {
cart: signal({
@@ -63,14 +83,27 @@ describe('CheckoutPageComponent payment validation', () => {
});
});
routerStub = { navigate: vi.fn() };
routeQueryParamMap = convertToParamMap({});
authUserState = signal(null);
await TestBed.configureTestingModule({
imports: [CheckoutPageComponent],
providers: [
{ provide: CheckoutService, useValue: checkoutServiceStub },
{ provide: CatalogService, useValue: { getCatalogItem: vi.fn() } },
{ provide: CartService, useValue: cartServiceStub },
{ provide: TenantService, useValue: { tenant: signal({ codigo: 'tenant-test' }) } },
{ provide: AuthService, useValue: { user: signal(null) } },
{ provide: AuthService, useValue: { user: authUserState } },
{
provide: ActivatedRoute,
useValue: {
snapshot: {
get queryParamMap() {
return routeQueryParamMap;
},
},
},
},
{ provide: Router, useValue: routerStub },
],
})
@@ -87,6 +120,7 @@ describe('CheckoutPageComponent payment validation', () => {
const fixture = TestBed.createComponent(CheckoutPageComponent);
fixture.detectChanges();
fixture.componentInstance['createdPurchaseId'].set(25);
routerStub.navigate.mockClear();
return { fixture, component: fixture.componentInstance as any };
}
@@ -105,7 +139,7 @@ describe('CheckoutPageComponent payment validation', () => {
await vi.advanceTimersByTimeAsync(5_000);
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(2);
expect(cartServiceStub.clearCart).toHaveBeenCalledOnce();
expect(cartServiceStub.clearCart).not.toHaveBeenCalled();
expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25]);
expect(checkoutServiceStub.generatePaymentIntent).toHaveBeenCalledTimes(1);
});
@@ -128,19 +162,19 @@ describe('CheckoutPageComponent payment validation', () => {
fixture.destroy();
});
it('stops QR polling after five minutes and can restart it', async () => {
it('stops QR polling after 45 seconds and can restart it', async () => {
const { component } = createComponent();
await component.selectPaymentMethod('qr');
await vi.advanceTimersByTimeAsync(300_000);
await vi.advanceTimersByTimeAsync(45_000);
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(60);
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(9);
expect(component.qrPaymentStatus()).toBe('timed_out');
component.retryQrPolling();
expect(component.qrPaymentStatus()).toBe('waiting');
await vi.advanceTimersByTimeAsync(5_000);
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(61);
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(10);
});
it('cancels QR polling when payment method changes or component is destroyed', async () => {
@@ -157,37 +191,37 @@ describe('CheckoutPageComponent payment validation', () => {
expect(checkoutServiceStub.getPurchase).not.toHaveBeenCalled();
});
it('checks a transfer once and redirects to purchase status while pending', async () => {
it.each(['created', 'pending_payment', 'in_review'])(
'shows the payment error when a transfer remains %s',
async (status) => {
checkoutServiceStub.submitPurchaseForReview.mockResolvedValue({ status });
const { component } = createComponent();
component.selectedPaymentMethod.set('transfer');
await component.onComplete();
expect(checkoutServiceStub.submitPurchaseForReview).toHaveBeenCalledWith('tenant-test', 25);
expect(component.transferValidationStatus()).toBe('error');
expect(cartServiceStub.clearCart).not.toHaveBeenCalled();
expect(routerStub.navigate).not.toHaveBeenCalled();
},
);
it('submits a paid transfer and navigates to its confirmation page', async () => {
checkoutServiceStub.submitPurchaseForReview.mockResolvedValue({ status: 'paid' });
const { component } = createComponent();
component.selectedPaymentMethod.set('transfer');
await component.onComplete();
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(1);
expect(component.transferValidationStatus()).toBe('pending');
expect(checkoutServiceStub.submitPurchaseForReview).toHaveBeenCalledWith('tenant-test', 25);
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'));
checkoutServiceStub.submitPurchaseForReview.mockRejectedValue(new Error('network error'));
const { component } = createComponent();
component.selectedPaymentMethod.set('transfer');
@@ -197,4 +231,262 @@ describe('CheckoutPageComponent payment validation', () => {
expect(cartServiceStub.clearCart).not.toHaveBeenCalled();
expect(routerStub.navigate).not.toHaveBeenCalled();
});
it('loads purchase items but prefills customer data from the user', async () => {
const purchase = {
id: 25,
cart_id: null,
status: 'created',
payment_method: null,
transfer_payer_dni: null,
nombre_apellido: 'Datos de la compra',
email: 'compra@example.com',
dni: '11111111',
telefono: '1111111111',
items: [
{
id: 91,
quantity: 2,
unit_price: '1250.50',
line_total: '2501.00',
source_catalog_item_id: 8,
source_variant_id: 21,
item_details: {
nombre: 'Remera',
descripcion: null,
slug: 'remera',
imagen: 'https://example.com/remera.jpg',
attributes: [{ name: 'Color', value: 'Negro' }],
},
},
],
subtotal: '2501.00',
total: '2501.00',
};
routeQueryParamMap = convertToParamMap({ purchase: 25 });
checkoutServiceStub.getPurchase.mockResolvedValue(purchase);
authUserState.set({
id: 7,
nombre_apellido: 'Juan Perez',
email: 'juan@example.com',
dni: '12345678',
telefono: '3415555555',
});
const { component } = createComponent();
await Promise.resolve();
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledWith('tenant-test', 25);
expect(component.mappedCartItems()).toEqual([
{
cartItemId: 91,
imageUrl: 'https://example.com/remera.jpg',
product: 'Remera',
originalPrice: null,
discountedPrice: 1250.5,
discountPercentage: null,
attributes: [{ label: 'Color', value: 'Negro' }],
quantity: 2,
},
]);
expect(component.cartSubtotal()).toBe(2501);
expect(component.cartTotal()).toBe(2501);
expect(component.form.getRawValue()).toEqual({
nombre: 'Juan Perez',
email: 'juan@example.com',
dni: '12345678',
telefono: '3415555555',
});
});
it('keeps the checkout hidden while the purchase is loading', async () => {
let resolvePurchase!: (purchase: any) => void;
routeQueryParamMap = convertToParamMap({ purchase: 25 });
checkoutServiceStub.getPurchase.mockReturnValue(
new Promise((resolve) => {
resolvePurchase = resolve;
}),
);
const { component } = createComponent();
expect(component.isLoadingPurchase()).toBe(true);
resolvePurchase({
id: 25,
status: 'created',
payment_method: null,
transfer_payer_dni: null,
items: [],
subtotal: '0.00',
total: '0.00',
});
await Promise.resolve();
expect(component.isLoadingPurchase()).toBe(false);
expect(component.checkoutStepIndex()).toBe(0);
});
it('opens a pending purchase on the payment step and restores its payment method', async () => {
routeQueryParamMap = convertToParamMap({ purchase: 25 });
checkoutServiceStub.getPurchase.mockResolvedValue({
id: 25,
status: 'pending_payment',
payment_method: 'transfer',
transfer_payer_dni: '12345678',
items: [],
subtotal: '100.00',
total: '100.00',
});
const { component } = createComponent();
await Promise.resolve();
expect(component.checkoutStepIndex()).toBe(1);
expect(component.selectedPaymentMethod()).toBe('transfer');
expect(component.transferDni()).toBe('12345678');
expect(component.isLoadingPurchase()).toBe(false);
});
it('generates a new QR when reopening a pending QR purchase', async () => {
routeQueryParamMap = convertToParamMap({ purchase: 25 });
checkoutServiceStub.getPurchase.mockResolvedValue({
id: 25,
status: 'pending_payment',
payment_method: 'qr',
transfer_payer_dni: null,
items: [],
subtotal: '100.00',
total: '100.00',
});
const { component } = createComponent();
await Promise.resolve();
await Promise.resolve();
expect(component.checkoutStepIndex()).toBe(1);
expect(component.selectedPaymentMethod()).toBe('qr');
expect(checkoutServiceStub.generatePaymentIntent).toHaveBeenCalledWith('tenant-test', 25, 'qr');
expect(component.qrData()).toBe('qr-value');
expect(component.qrPaymentStatus()).toBe('waiting');
});
it.each(['in_review', 'paid', 'cancelled', 'rejected', 'expired'])(
'redirects a %s purchase to its status page',
async (status) => {
routeQueryParamMap = convertToParamMap({ purchase: 25 });
checkoutServiceStub.getPurchase.mockResolvedValue({
id: 25,
status,
payment_method: null,
transfer_payer_dni: null,
items: [],
subtotal: '100.00',
total: '100.00',
});
createComponent();
await Promise.resolve();
expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25]);
},
);
it('updates a purchase item while editing and refreshes checkout totals', async () => {
const updatedPurchase = {
id: 25,
items: [],
subtotal: '300.00',
total: '300.00',
};
checkoutServiceStub.updateItemQuantity.mockResolvedValue(updatedPurchase);
const { component } = createComponent();
component.isEditingItems.set(true);
await component.onPurchaseItemQuantityChange({
item: {
cartItemId: 91,
imageUrl: null,
product: 'Remera',
originalPrice: null,
discountedPrice: 100,
discountPercentage: null,
attributes: [],
quantity: 2,
},
quantity: 3,
});
expect(checkoutServiceStub.updateItemQuantity).toHaveBeenCalledWith('tenant-test', 25, 91, 3);
expect(component.createdPurchase()).toBe(updatedPurchase);
expect(component.isUpdatingItem()).toBe(false);
});
it('keeps the payment step selected while editing and regenerates payment afterward', async () => {
const editablePurchase = {
id: 25,
status: 'created',
items: [],
subtotal: '100.00',
total: '100.00',
};
checkoutServiceStub.prepareItemEditing.mockResolvedValue(editablePurchase);
const { component } = createComponent();
component.stepper = { currentStepIndex: signal(1) };
const selectPaymentMethod = vi
.spyOn(component, 'selectPaymentMethod')
.mockResolvedValue(undefined);
await component.onEditingItemsChange(true);
expect(checkoutServiceStub.prepareItemEditing).toHaveBeenCalledWith('tenant-test', 25);
expect(component.isEditingItems()).toBe(true);
expect(component.createdPurchase()).toBe(editablePurchase);
await component.onEditingItemsChange(false);
expect(component.stepper.currentStepIndex()).toBe(1);
expect(selectPaymentMethod).toHaveBeenCalledWith('qr');
expect(component.isEditingItems()).toBe(false);
});
it('updates customer data on the existing purchase before payment', async () => {
const updatedPurchase = {
id: 25,
cart_id: 10,
items: [],
subtotal: '100.00',
total: '100.00',
};
checkoutServiceStub.updateCustomerData.mockResolvedValue(updatedPurchase);
const { component } = createComponent();
component.form.setValue({
nombre: 'Juan Perez',
email: 'juan@example.com',
dni: '12345678',
telefono: '3415555555',
});
component.stepper = { next: vi.fn() };
await component.onStep1Continue();
expect(checkoutServiceStub.startCheckout).not.toHaveBeenCalled();
expect(checkoutServiceStub.updateCustomerData).toHaveBeenCalledWith('tenant-test', 25, {
dni: '12345678',
telefono: '3415555555',
email: 'juan@example.com',
nombre_apellido: 'Juan Perez',
});
expect(component.createdPurchase()).toEqual({
...updatedPurchase,
status: 'pending_payment',
});
expect(component.stepper.next).toHaveBeenCalledOnce();
});
it('cancels the pending purchase before allowing navigation away', async () => {
const { component } = createComponent();
await expect(component.canDeactivate()).resolves.toBe(true);
expect(checkoutServiceStub.cancelPurchase).toHaveBeenCalledWith('tenant-test', 25);
expect(cartServiceStub.loadCart).toHaveBeenCalled();
expect(cartServiceStub.clearCart).not.toHaveBeenCalled();
expect(component.createdPurchaseId()).toBeNull();
});
});

View File

@@ -2,24 +2,25 @@ 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';
import { ActivatedRoute, Router } from '@angular/router';
import { firstValueFrom, startWith } from 'rxjs';
import { CartService } from '../../../../core/services/cart/cart.service';
import { TenantService } from '../../../../core/services/tenant.service';
import { CheckoutService, CreatePurchasePayload } from '../../../../core/services/checkout.service';
import {
CheckoutService,
PurchaseDetailItemResponse,
PurchaseDetailResponse,
} from '../../../../core/services/checkout.service';
import { AuthService } from '../../../../core/services/auth/auth.service';
import { CartService } from '../../../../core/services/cart/cart.service';
import { BankAccount } from '../../../../core/services/tenant.interface';
import { CartItem } from '../../../../core/services/cart/cart.interface';
import { CartComponent, CartItemMock } from '../../../../shared/components/cart/cart.component';
import { StepComponent } from '../../../../shared/components/stepper/step.component';
import { StepperComponent } from '../../../../shared/components/stepper/stepper.component';
@@ -51,13 +52,14 @@ import {
})
export class CheckoutPageComponent implements OnInit, OnDestroy {
private readonly formBuilder = inject(FormBuilder);
private readonly cartService = inject(CartService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly tenantService = inject(TenantService);
private readonly checkoutService = inject(CheckoutService);
private readonly authService = inject(AuthService);
private readonly cartService = inject(CartService);
private readonly qrPollingIntervalMs = 5_000;
private readonly qrPollingMaxAttempts = 60;
private readonly qrPollingMaxAttempts = 9;
private qrPollingTimeoutId: ReturnType<typeof setTimeout> | null = null;
private qrPollingAttempts = 0;
@@ -74,19 +76,25 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
telefono: ['', [Validators.required]],
});
protected readonly createdPurchase = signal<PurchaseDetailResponse | null>(null);
protected readonly isLoadingPurchase = signal(true);
protected readonly checkoutStepIndex = signal(0);
protected readonly cartSubtotal = computed(() => {
const cart = this.cartService.cart();
return cart ? parseFloat(cart.subtotal) : 0;
const purchase = this.createdPurchase();
return purchase ? parseFloat(purchase.subtotal) : 0;
});
protected readonly cartDiscount = computed(() => 0);
protected readonly cartTotal = computed(() => this.cartSubtotal() - this.cartDiscount());
protected readonly cartTotal = computed(() => {
const purchase = this.createdPurchase();
return purchase ? parseFloat(purchase.total) : this.cartSubtotal() - this.cartDiscount();
});
protected readonly mappedCartItems = computed<CartItemMock[]>(() => {
const cart = this.cartService.cart();
if (!cart || !cart.items) return [];
return cart.items.map((item) => this.mapCartItemToMock(item));
const purchase = this.createdPurchase();
return purchase ? purchase.items.map((item) => this.mapPurchaseItemToMock(item)) : [];
});
protected readonly isStep1Valid = signal(this.form.valid);
@@ -100,32 +108,25 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
protected readonly transferAccount = signal<TransferAccount | null>(null);
protected readonly transferDni = signal<string>('');
protected readonly isCreatingPurchase = signal(false);
protected readonly isUpdatingPurchase = signal(false);
protected readonly isEditingItems = signal(false);
protected readonly isUpdatingItem = signal(false);
protected readonly isPreparingItemEdit = 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());
protected readonly qrData = signal<string | null>(null);
protected readonly qrPaymentStatus = signal<QrPaymentStatus>('idle');
protected readonly isCheckingQrPayment = signal(false);
protected readonly transferValidationStatus = signal<TransferValidationStatus>('idle');
protected readonly whatsappUrl = computed(
() =>
this.tenantService
.tenant()
?.social_media?.find((socialMedia) => socialMedia.code === 'whatsapp')?.url ?? null,
);
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 && !this.navigationStarted) {
// Trigger payment intent generation when cart changes and we are on the payment step
void this.selectPaymentMethod(this.selectedPaymentMethod());
}
});
});
this.form.statusChanges
.pipe(startWith(this.form.status))
.subscribe(() => this.isStep1Valid.set(this.form.valid));
@@ -144,68 +145,123 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
}
ngOnInit(): void {
this.cartService.loadCart().subscribe();
const purchaseId = Number(this.route.snapshot.queryParamMap.get('purchase'));
if (!Number.isInteger(purchaseId) || purchaseId <= 0) {
void this.router.navigate(['/']);
return;
}
void this.loadPurchase(purchaseId);
}
ngOnDestroy(): void {
this.stopQrPolling();
}
private mapCartItemToMock(item: CartItem): CartItemMock {
const fullName = item.product?.nombre ?? '';
let product = fullName;
let attributes: { label: string; value: string }[] = [];
const match = fullName.match(/^(.*?)\s*\((.*?)\)$/);
if (match) {
product = match[1];
const attributesString = match[2];
attributes = attributesString.split(',').map((attr) => {
const parts = attr.split(':');
if (parts.length === 2) {
return { label: parts[0].trim(), value: parts[1].trim() };
}
return { label: '', value: attr.trim() };
});
}
private mapPurchaseItemToMock(item: PurchaseDetailItemResponse): CartItemMock {
return {
cartItemId: item.id,
imageUrl: item.product?.imagen ?? null,
product,
imageUrl: item.item_details.imagen,
product: item.item_details.nombre,
originalPrice: null,
discountedPrice: parseFloat(item.precio_unitario),
discountedPrice: parseFloat(item.unit_price),
discountPercentage: null,
attributes,
quantity: item.cantidad,
attributes: item.item_details.attributes.map((attribute) => ({
label: attribute.name,
value: attribute.value === null ? '' : String(attribute.value),
})),
quantity: item.quantity,
};
}
protected async onStep1Continue(): Promise<void> {
if (this.form.invalid) return;
protected async onEditingItemsChange(editing: boolean): Promise<void> {
if (this.isUpdatingItem() || this.isPreparingItemEdit()) {
return;
}
const tenant = this.tenantService.tenant();
if (!tenant) return;
if (!editing) {
this.isEditingItems.set(false);
this.isCreatingPurchase.set(true);
try {
const cart = this.cartService.cart();
if (!cart?.id) {
throw new Error('No hay un carrito activo para finalizar la compra.');
if (this.stepper?.currentStepIndex() === 1) {
void this.selectPaymentMethod(this.selectedPaymentMethod());
}
return;
}
this.isEditingItems.set(true);
this.stopQrPolling();
this.qrData.set(null);
this.qrPaymentStatus.set('idle');
this.transferAccount.set(null);
this.transferValidationStatus.set('idle');
const tenant = this.tenantService.tenant();
const purchaseId = this.createdPurchaseId();
if (!tenant || !purchaseId) {
this.isEditingItems.set(false);
return;
}
this.isPreparingItemEdit.set(true);
try {
const purchase = await this.checkoutService.prepareItemEditing(tenant.codigo, purchaseId);
this.createdPurchase.set(purchase);
} catch (error) {
console.error('Failed to prepare purchase item editing:', error);
this.isEditingItems.set(false);
} finally {
this.isPreparingItemEdit.set(false);
}
}
protected async onPurchaseItemQuantityChange(event: {
item: CartItemMock;
quantity: number;
}): Promise<void> {
const tenant = this.tenantService.tenant();
const purchaseId = this.createdPurchaseId();
const itemId = event.item.cartItemId;
if (!tenant || !purchaseId || !itemId || this.isUpdatingItem() || !this.isEditingItems()) {
return;
}
this.isUpdatingItem.set(true);
try {
const purchase = await this.checkoutService.updateItemQuantity(
tenant.codigo,
purchaseId,
itemId,
event.quantity,
);
this.createdPurchase.set(purchase);
} catch (error) {
console.error('Failed to update purchase item quantity:', error);
} finally {
this.isUpdatingItem.set(false);
}
}
protected async onStep1Continue(): Promise<void> {
if (this.form.invalid || this.isUpdatingPurchase() || this.isEditingItems()) return;
const tenant = this.tenantService.tenant();
const purchaseId = this.createdPurchaseId();
if (!tenant || !purchaseId) return;
this.isUpdatingPurchase.set(true);
try {
const formValue = this.form.getRawValue();
const payload: CreatePurchasePayload = {
cart_id: cart.id,
const purchase = await this.checkoutService.updateCustomerData(tenant.codigo, purchaseId, {
dni: formValue.dni,
telefono: formValue.telefono,
email: formValue.email,
nombre_apellido: formValue.nombre,
};
const response = await this.checkoutService.createPurchase(tenant.codigo, payload);
this.createdPurchaseId.set(response.id);
});
this.createdPurchase.set(purchase);
this.stepper.next();
@@ -215,17 +271,50 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
console.error('Failed to create purchase:', error);
// Here we could show an alert or toast
} finally {
this.isCreatingPurchase.set(false);
this.isUpdatingPurchase.set(false);
}
}
protected onCancel(): void {
protected async onCancel(): Promise<void> {
if (await this.canDeactivate()) {
void this.router.navigate(['/']);
}
}
public async canDeactivate(): Promise<boolean> {
this.stopQrPolling();
void this.router.navigate(['/']);
if (this.navigationStarted) {
return true;
}
const purchaseId = this.createdPurchaseId();
const tenant = this.tenantService.tenant();
if (!purchaseId || !tenant) {
return true;
}
try {
await this.checkoutService.cancelPurchase(tenant.codigo, purchaseId);
this.createdPurchaseId.set(null);
this.createdPurchase.set(null);
try {
await firstValueFrom(this.cartService.loadCart());
} catch (error) {
console.error('Failed to restore cart after cancelling checkout:', error);
}
return true;
} catch (error) {
console.error('Failed to cancel purchase:', error);
return false;
}
}
protected async selectPaymentMethod(method: PaymentMethod): Promise<void> {
if (this.navigationStarted) {
if (this.navigationStarted || this.isEditingItems()) {
return;
}
@@ -266,6 +355,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
}
this.qrData.set(response.qr_data.qr_code);
this.markPurchasePendingPayment();
this.startQrPolling();
}
} catch (error) {
@@ -276,6 +366,10 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
}
protected async generateTransferIntent(dni: string): Promise<void> {
if (this.isEditingItems()) {
return;
}
const purchaseId = this.createdPurchaseId();
const tenant = this.tenantService.tenant();
@@ -291,10 +385,11 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
tenant.codigo,
purchaseId,
'transfer',
dni
dni,
);
if (response.transfer_data) {
this.markPurchasePendingPayment();
this.transferAccount.set({
titular: response.transfer_data.titular,
entidad: response.transfer_data.entidad,
@@ -334,6 +429,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
!purchaseId ||
!tenant ||
this.navigationStarted ||
this.isEditingItems() ||
this.transferValidationStatus() === 'checking'
) {
return;
@@ -342,22 +438,19 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
this.transferValidationStatus.set('checking');
try {
const purchase = await this.checkoutService.getPurchase(tenant.codigo, purchaseId);
const purchase = await this.checkoutService.submitPurchaseForReview(
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');
this.transferValidationStatus.set('error');
} catch (error) {
console.error('Failed to validate transfer payment:', error);
console.error('Failed to submit purchase for review:', error);
this.transferValidationStatus.set('error');
}
}
@@ -448,11 +541,22 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
}
}
private handleConfirmedPayment(purchaseId: number): void {
this.navigateToPurchaseStatus(purchaseId, true);
private markPurchasePendingPayment(): void {
this.createdPurchase.update((purchase) =>
purchase
? {
...purchase,
status: 'pending_payment',
}
: purchase,
);
}
private navigateToPurchaseStatus(purchaseId: number, clearCart = false): void {
private handleConfirmedPayment(purchaseId: number): void {
this.navigateToPurchaseStatus(purchaseId);
}
private navigateToPurchaseStatus(purchaseId: number): void {
if (this.navigationStarted) {
return;
}
@@ -460,10 +564,55 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
this.navigationStarted = true;
this.stopQrPolling();
if (clearCart) {
this.cartService.clearCart();
}
void this.router.navigate(['/checkout/status', purchaseId]);
}
private async loadPurchase(purchaseId: number): Promise<void> {
const tenant = this.tenantService.tenant();
if (!tenant) {
void this.router.navigate(['/']);
return;
}
try {
const purchase = await this.checkoutService.getPurchase(tenant.codigo, purchaseId);
if (
purchase.status === 'in_review' ||
purchase.status === 'paid' ||
purchase.status === 'cancelled' ||
purchase.status === 'rejected' ||
purchase.status === 'expired'
) {
this.navigateToPurchaseStatus(purchaseId);
return;
}
if (purchase.status !== 'created' && purchase.status !== 'pending_payment') {
void this.router.navigate(['/']);
return;
}
this.createdPurchaseId.set(purchase.id);
this.createdPurchase.set(purchase);
this.checkoutStepIndex.set(purchase.status === 'pending_payment' ? 1 : 0);
if (
purchase.status === 'pending_payment' &&
this.paymentMethods.some((method) => method.id === purchase.payment_method)
) {
this.selectedPaymentMethod.set(purchase.payment_method as PaymentMethod);
this.transferDni.set(purchase.transfer_payer_dni ?? '');
}
this.isLoadingPurchase.set(false);
if (purchase.status === 'pending_payment' && purchase.payment_method === 'qr') {
await this.selectPaymentMethod('qr');
}
} catch (error) {
console.error('Failed to load purchase:', error);
void this.router.navigate(['/']);
}
}
}

View File

@@ -0,0 +1,8 @@
import { CanDeactivateFn } from '@angular/router';
interface PendingCheckoutComponent {
canDeactivate(): Promise<boolean>;
}
export const checkoutPendingPurchaseGuard: CanDeactivateFn<PendingCheckoutComponent> = (component) =>
component.canDeactivate();

View File

@@ -1,14 +1,13 @@
<div class="checkout-payment">
<div class="checkout-payment__content">
<section class="payment-methods" aria-labelledby="payment-methods-title">
<h2 id="payment-methods-title" class="payment-methods__title">Selecciona el metodo de pago</h2>
<h2 id="payment-methods-title" class="payment-methods__title">
Selecciona el metodo de pago
</h2>
<div class="payment-methods__list" role="radiogroup" aria-label="Metodo de pago">
@for (method of paymentMethods(); track method.id) {
<label
class="payment-method"
[class.is-selected]="selectedPaymentMethod() === method.id"
>
<label class="payment-method" [class.is-selected]="selectedPaymentMethod() === method.id">
<input
class="payment-method__radio"
type="radio"
@@ -21,7 +20,8 @@
<span class="payment-method__label">
@if (method.id === 'telepagos') {
<span class="telepagos-logo" aria-label="TelePagos">
<span class="telepagos-logo__tele">tele</span><span class="telepagos-logo__pagos">pagos</span>
<span class="telepagos-logo__tele">tele</span
><span class="telepagos-logo__pagos">pagos</span>
</span>
} @else {
{{ method.label }}
@@ -36,7 +36,7 @@
<section class="payment-panel d-flex justify-content-end" aria-live="polite">
@if (isGeneratingIntent()) {
<div class="payment-panel__card" style="text-align: center; padding: 2rem;">
<div class="payment-panel__card" style="text-align: center; padding: 2rem">
<h3 class="payment-panel__title">Cargando información de pago...</h3>
</div>
} @else if (selectedPaymentMethod() === 'qr') {
@@ -44,7 +44,8 @@
[qrData]="qrData()"
[paymentStatus]="qrPaymentStatus()"
[isCheckingPayment]="isCheckingQrPayment()"
(retryPolling)="retryQrPolling.emit()"
[paymentAmount]="qrPaymentAmount()"
[whatsappUrl]="whatsappUrl()"
/>
} @else if (selectedPaymentMethod() === 'transfer') {
<app-checkout-payment-transfer
@@ -52,6 +53,8 @@
[transferDni]="transferDni()"
[copiedTransferField]="copiedTransferField()"
[validationStatus]="transferValidationStatus()"
[paymentAmount]="qrPaymentAmount()"
[whatsappUrl]="whatsappUrl()"
(copyTransferValue)="requestCopy($event.field, $event.value)"
(submitDni)="generateTransferIntent.emit($event)"
(completePurchase)="complete.emit()"
@@ -62,4 +65,3 @@
</section>
</div>
</div>

View File

@@ -30,6 +30,8 @@ export class CheckoutPaymentStepComponent {
readonly qrData = input<string | null>(null);
readonly qrPaymentStatus = input<QrPaymentStatus>('idle');
readonly isCheckingQrPayment = input<boolean>(false);
readonly qrPaymentAmount = input<number>(0);
readonly whatsappUrl = input<string | null>(null);
readonly transferValidationStatus = input<TransferValidationStatus>('idle');
readonly paymentMethodChange = output<PaymentMethod>();
@@ -37,7 +39,6 @@ export class CheckoutPaymentStepComponent {
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

@@ -1,34 +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>
<app-payment-verification-error
[paymentAmount]="paymentAmount()"
[whatsappUrl]="whatsappUrl()"
/>
} @else {
<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() === 'failed') {
<p class="payment-status payment-status--warning" role="alert">
El pago fue rechazado o cancelado.
</p>
}
}
</div>

View File

@@ -57,7 +57,9 @@
rgba(17, 17, 17, 0.95) 84% 100%
);
background-size: 18px 18px;
background-position: 0 0, 9px 9px;
background-position:
0 0,
9px 9px;
display: flex;
justify-content: center;
align-items: center;
@@ -86,7 +88,9 @@
height: 38px;
border: 5px solid #111111;
background: #ffffff;
box-shadow: inset 0 0 0 8px #ffffff, inset 0 0 0 14px #111111;
box-shadow:
inset 0 0 0 8px #ffffff,
inset 0 0 0 14px #111111;
&--tl {
top: 10px;

View File

@@ -27,4 +27,25 @@ describe('CheckoutPaymentQrComponent', () => {
expect(overlay.textContent).toContain('Verificando pago');
expect(overlay.querySelector('.payment-verification__spinner')).not.toBeNull();
});
it('shows the payment verification error and WhatsApp action after timeout', async () => {
await TestBed.configureTestingModule({
imports: [CheckoutPaymentQrComponent],
}).compileComponents();
const fixture = TestBed.createComponent(CheckoutPaymentQrComponent);
fixture.componentRef.setInput('paymentStatus', 'timed_out');
fixture.componentRef.setInput('paymentAmount', 300000);
fixture.componentRef.setInput('whatsappUrl', 'https://wa.me/543412602222');
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const whatsapp = Array.from(element.querySelectorAll('button')).find((button) =>
button.textContent?.includes('WhatsApp'),
);
expect(element.textContent).toMatch(/No pudimos verificar el pago de \$\s*300\.000\./);
expect(element.textContent).toContain('Por favor contactate con nosotros para resolverlo.');
expect(whatsapp).toBeDefined();
expect(element.textContent).not.toContain('Volver a verificar');
});
});

View File

@@ -1,19 +1,20 @@
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
import { ChangeDetectionStrategy, Component, input } 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';
import { PaymentVerificationErrorComponent } from '../payment-verification-error/payment-verification-error.component';
@Component({
selector: 'app-checkout-payment-qr',
standalone: true,
imports: [QRCodeComponent, ButtonComponent],
imports: [PaymentVerificationErrorComponent, QRCodeComponent],
templateUrl: './checkout-payment-qr.component.html',
styleUrl: './checkout-payment-qr.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
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>();
readonly paymentAmount = input<number>(0);
readonly whatsappUrl = input<string | null>(null);
}

View File

@@ -1,105 +1,106 @@
<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>
@if (validationStatus() === 'pending' || validationStatus() === 'error') {
<app-payment-verification-error
[paymentAmount]="paymentAmount()"
[whatsappUrl]="whatsappUrl()"
/>
} @else {
<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>
<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 (transferAccount() && !isEditing()) {
<div class="payment-panel__divider"></div>
<div class="payment-panel__account">
<p class="payment-panel__eyebrow">DATOS DE CUENTA:</p>
@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 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>
</div>
}
}
</div>

View File

@@ -115,17 +115,3 @@
height: 100%;
}
}
.payment-validation {
margin: 0.75rem 0 0;
font-size: 0.8rem;
line-height: 1.35;
&--pending {
color: #8a681d;
}
&--error {
color: #b42318;
}
}

View File

@@ -1,23 +1,46 @@
import { ChangeDetectionStrategy, Component, computed, input, output, signal, OnInit, effect, untracked } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
signal,
OnInit,
effect,
untracked,
} from '@angular/core';
import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
import { IconButtonComponent } from '../../../../../../shared/components/icon-button/icon-button.component';
import { ButtonComponent } from '../../../../../../shared/components/button/button.component';
import { InputComponent } from '../../../../../../shared/components/input/input.component';
import { TransferAccount, TransferField, TransferValidationStatus } from '../../checkout-page.models';
import {
TransferAccount,
TransferField,
TransferValidationStatus,
} from '../../checkout-page.models';
import { PaymentVerificationErrorComponent } from '../payment-verification-error/payment-verification-error.component';
@Component({
selector: 'app-checkout-payment-transfer',
standalone: true,
imports: [IconButtonComponent, ButtonComponent, InputComponent, ReactiveFormsModule],
imports: [
IconButtonComponent,
ButtonComponent,
InputComponent,
ReactiveFormsModule,
PaymentVerificationErrorComponent,
],
templateUrl: './checkout-payment-transfer.component.html',
styleUrl: './checkout-payment-transfer.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
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 paymentAmount = input<number>(0);
readonly whatsappUrl = input<string | null>(null);
readonly copyTransferValue = output<{ field: TransferField; value: string }>();
readonly submitDni = output<string>();
@@ -25,7 +48,7 @@ export class CheckoutPaymentTransferComponent implements OnInit {
protected readonly dniControl = new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.pattern(/^\d{7,8}$/)]
validators: [Validators.required, Validators.pattern(/^\d{7,8}$/)],
});
protected readonly isEditing = signal<boolean>(false);

View File

@@ -0,0 +1,17 @@
<div class="payment-timeout" role="alert">
<div class="payment-timeout__icon" aria-hidden="true">
<i class="fa-solid fa-xmark"></i>
</div>
<h4 class="payment-timeout__title">No pudimos verificar el pago de {{ formattedAmount() }}.</h4>
<p class="payment-timeout__message">Por favor contactate con nosotros para resolverlo.</p>
@if (whatsappUrl()) {
<app-button
type="button"
variant="primary"
buttonClass="d-inline-flex align-items-center justify-content-center gap-2"
(click)="openWhatsApp()"
>
<span>WhatsApp</span>
</app-button>
}
</div>

View File

@@ -0,0 +1,35 @@
.payment-timeout {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
padding-top: 0.85rem;
&__icon {
display: grid;
width: 84px;
height: 84px;
place-items: center;
border: 1px solid var(--tenant-danger, #dc3545);
border-radius: 50%;
color: var(--tenant-danger, #dc3545);
font-size: 2.7rem;
}
&__title {
max-width: 250px;
margin: 1.35rem 0 0;
color: var(--tenant-danger, #dc3545);
font-size: 1.05rem;
font-weight: 700;
line-height: 1.3;
}
&__message {
max-width: 250px;
margin: 2rem 0 1.75rem;
color: #666666;
font-size: 1rem;
line-height: 1.25;
}
}

View File

@@ -0,0 +1,32 @@
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
import { ButtonComponent } from '../../../../../../shared/components/button/button.component';
@Component({
selector: 'app-payment-verification-error',
standalone: true,
imports: [ButtonComponent],
templateUrl: './payment-verification-error.component.html',
styleUrl: './payment-verification-error.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PaymentVerificationErrorComponent {
readonly paymentAmount = input<number>(0);
readonly whatsappUrl = input<string | null>(null);
protected readonly formattedAmount = computed(() =>
new Intl.NumberFormat('es-AR', {
style: 'currency',
currency: 'ARS',
maximumFractionDigits: 0,
}).format(this.paymentAmount()),
);
protected openWhatsApp(): void {
const url = this.whatsappUrl();
if (url) {
window.open(url, '_blank', 'noopener,noreferrer');
}
}
}

View File

@@ -0,0 +1,16 @@
<aside class="help-sidebar">
<h1 class="help-sidebar__title">{{ menu()?.label }}</h1>
<div class="help-sidebar__divider"></div>
<nav class="help-sidebar__nav" aria-label="Secciones de ayuda">
@for (menu of submenues(); track menu.code) {
<a
class="help-sidebar__link"
[routerLink]="menu.route"
routerLinkActive="help-sidebar__link--active"
>
{{ menu.label }}
</a>
}
</nav>
</aside>

View File

@@ -0,0 +1,57 @@
.help-sidebar {
display: flex;
flex-direction: column;
}
.help-sidebar__title {
margin: 0 0 0.75rem;
color: #1a1a1a;
font-size: 1rem;
font-weight: 700;
line-height: 1.25;
text-transform: uppercase;
}
.help-sidebar__divider {
height: 1px;
margin-bottom: 1.25rem;
background: #dedede;
}
.help-sidebar__nav {
display: flex;
flex-direction: column;
gap: 1rem;
}
.help-sidebar__link {
color: #888888;
font-size: 0.875rem;
font-weight: 500;
line-height: 1.35;
text-decoration: none;
transition: color 150ms ease;
&:hover,
&:focus-visible {
color: #5a5a5a;
}
&:focus-visible {
border-radius: 2px;
outline: 2px solid var(--color-primary);
outline-offset: 3px;
}
}
.help-sidebar__link--active {
color: var(--color-primary);
font-weight: 600;
}
@media (max-width: 767.98px) {
.help-sidebar__nav {
flex-flow: row wrap;
gap: 0.75rem 1.25rem;
}
}

View File

@@ -0,0 +1,72 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { describe, expect, it } from 'vitest';
import { Tenant } from '../../../../../../core/services/tenant.interface';
import { TenantService } from '../../../../../../core/services/tenant.service';
import { HelpSidebar } from './help-sidebar';
describe('HelpSidebar', () => {
it('renders the parent and submenu labels supplied by the tenant', async () => {
const tenant = signal<Tenant>({
id: 1,
codigo: 'test',
nombre: 'Test',
dominio: 'localhost',
primary_color: '#000000',
secondary_color: '#000000',
danger_color: '#000000',
success_color: '#000000',
header_bg_color: '#ffffff',
footer_bg_color: '#ffffff',
header_logo: '',
footer_logo: '',
categories: [],
menues: [
{
id: 1,
code: 'help',
label: 'Centro de ayuda',
parent_menu_code: null,
content_type: 'dynamic',
route: '/ayuda',
submenues: [
{
id: 2,
code: 'help.faq',
label: 'Consultas habituales',
parent_menu_code: 'help',
content_type: 'dynamic',
route: '/ayuda/preguntas-frecuentes',
submenues: [],
},
],
},
],
});
await TestBed.configureTestingModule({
imports: [HelpSidebar],
providers: [
provideRouter([]),
{
provide: TenantService,
useValue: { tenant: tenant.asReadonly() },
},
],
}).compileComponents();
const fixture = TestBed.createComponent(HelpSidebar);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('.help-sidebar__title')?.textContent?.trim()).toBe(
'Centro de ayuda',
);
expect(element.querySelector('.help-sidebar__link')?.textContent?.trim()).toBe(
'Consultas habituales',
);
});
});

View File

@@ -0,0 +1,20 @@
import { Component, computed, inject } from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router';
import { findMenu } from '../../../../../../core/services/menu.utils';
import { TenantService } from '../../../../../../core/services/tenant.service';
@Component({
selector: 'app-help-sidebar',
imports: [RouterLink, RouterLinkActive],
templateUrl: './help-sidebar.html',
styleUrl: './help-sidebar.scss',
})
export class HelpSidebar {
private readonly tenantService = inject(TenantService);
protected readonly menu = computed(() =>
findMenu(this.tenantService.tenant()?.menues ?? [], 'help'),
);
protected readonly submenues = computed(() => this.menu()?.submenues ?? []);
}

View File

@@ -0,0 +1,53 @@
<section class="contact-page" aria-labelledby="contact-page-title">
<div class="contact-page__details">
<h2 id="contact-page-title" class="contact-page__title">CONTACTO</h2>
@if (contact().whatsappUrl && contact().whatsappLabel) {
<a
class="contact-page__contact-link"
[href]="contact().whatsappUrl"
target="_blank"
rel="noopener noreferrer"
>
<i class="fa-brands fa-whatsapp" aria-hidden="true"></i>
<span>{{ contact().whatsappLabel }}</span>
</a>
}
@if (contact().phone; as phone) {
<a class="contact-page__contact-link" [href]="phoneUrl(phone)">
<i class="fa-solid fa-phone" aria-hidden="true"></i>
<span>{{ phone }}</span>
</a>
}
@for (location of contact().locations; track location.id) {
<article class="contact-page__location">
<h3 class="contact-page__location-title">
<i class="fa-solid fa-location-dot" aria-hidden="true"></i>
<span>{{ location.label }}</span>
</h3>
<ul class="contact-page__addresses">
@for (address of location.addresses; track address.id) {
<li>{{ address.address }}</li>
}
</ul>
</article>
}
@if (!contact().whatsappUrl && !contact().phone && contact().locations.length === 0) {
<p class="contact-page__empty">No hay datos de contacto disponibles.</p>
}
</div>
@if (contact().mapLocations.length > 0) {
<app-location-map
class="contact-page__map"
[locations]="contact().mapLocations"
[markerColor]="markerColor()"
mapStyle="minimal-light"
ariaLabel="Mapa de sucursales y puntos de atención"
/>
}
</section>

View File

@@ -0,0 +1,129 @@
:host {
display: block;
width: 100%;
}
.contact-page {
display: grid;
grid-template-columns: minmax(13rem, 0.72fr) minmax(20rem, 1.45fr);
align-items: start;
gap: clamp(2rem, 5vw, 4.75rem);
width: 100%;
color: #666666;
font-weight: 400;
}
.contact-page__details {
display: flex;
min-width: 0;
flex-direction: column;
align-items: flex-start;
}
.contact-page__title {
margin: 0 0 1.25rem;
color: inherit;
font-size: 1rem;
font-weight: 400;
line-height: 1.3;
}
.contact-page__contact-link {
display: inline-flex;
align-items: center;
gap: 0.65rem;
margin-bottom: 1rem;
color: inherit;
font-size: 0.8125rem;
font-weight: 400;
line-height: 1.35;
text-decoration: none;
transition: color 150ms ease;
i {
width: 0.9rem;
color: inherit;
font-size: 0.875rem;
text-align: center;
transition: color 150ms ease;
}
&:hover,
&:focus-visible,
&:active {
color: var(--color-primary);
}
&:focus-visible {
border-radius: 2px;
outline: 2px solid var(--color-primary);
outline-offset: 4px;
}
}
.contact-page__location {
margin-top: 0.15rem;
& + & {
margin-top: 1rem;
}
}
.contact-page__location-title {
display: flex;
align-items: center;
gap: 0.65rem;
margin: 0 0 0.3rem;
color: inherit;
font-size: 0.8125rem;
font-weight: 700;
line-height: 1.35;
i {
width: 0.9rem;
font-size: 0.875rem;
text-align: center;
}
}
.contact-page__addresses {
display: grid;
gap: 0.15rem;
margin: 0;
padding-left: 2.3rem;
color: inherit;
font-size: 0.75rem;
font-weight: 400;
line-height: 1.35;
}
.contact-page__empty {
margin: 0;
color: inherit;
font-size: 0.8125rem;
font-weight: 400;
}
.contact-page__map {
display: block;
min-width: 0;
&::ng-deep .location-map {
min-height: clamp(15rem, 26vw, 20rem);
border-radius: 0.25rem;
filter: saturate(0.72) contrast(0.96);
}
}
@media (max-width: 991.98px) {
.contact-page {
grid-template-columns: 1fr;
gap: 2rem;
}
}
@media (max-width: 575.98px) {
.contact-page__map::ng-deep .location-map {
min-height: 16rem;
}
}

View File

@@ -0,0 +1,140 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Tenant } from '../../../../../../core/services/tenant.interface';
import { TenantService } from '../../../../../../core/services/tenant.service';
import { ContactPage } from './contact-page';
const tenant: Tenant = {
id: 1,
codigo: 'test',
nombre: 'Test',
dominio: 'localhost',
primary_color: '#3366ff',
secondary_color: '#a0a0a0',
danger_color: '#ff8888',
success_color: '#198754',
header_bg_color: '#ffffff',
footer_bg_color: '#202020',
header_logo: '',
footer_logo: '',
categories: [],
menues: [
{
id: 1,
code: 'help',
label: 'Ayuda',
parent_menu_code: null,
content_type: 'dynamic',
route: '/ayuda',
submenues: [
{
id: 2,
code: 'help.contact',
label: 'Contacto',
parent_menu_code: 'help',
content_type: 'static',
route: '/ayuda/contacto',
static_content: {
whatsapp: {
whatsapp_url: 'https://wa.me/543412602222',
whatsapp_label: 'Chatea con nosotros',
},
phone: '+54 9 (0341) 6658247',
locations: {
rosario: {
label: 'Rosario',
addresses: [
{
label: 'Gigante de Arroyito',
address: 'Av. Génova 640, Rosario',
coordinates: [-32.913997, -60.674567],
},
{
label: 'Telepagos',
address: 'Rioja 1150, piso 12, dpto. 3, Rosario',
coordinates: [-32.94682, -60.63932],
},
],
},
santa_fe: {
label: 'Santa Fe',
addresses: [
{
label: 'San Martín 2450',
address: 'San Martín 2450, Santa Fe',
coordinates: [-31.63333, -60.7],
},
],
},
},
},
submenues: [],
},
],
},
],
};
describe('ContactPage', () => {
let fixture: ComponentFixture<ContactPage>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ContactPage],
providers: [
{
provide: TenantService,
useValue: {
tenant: signal<Tenant | null>(tenant).asReadonly(),
},
},
],
}).compileComponents();
fixture = TestBed.createComponent(ContactPage);
fixture.detectChanges();
});
it('renders the contact content configured for the tenant', () => {
const element = fixture.nativeElement as HTMLElement;
const whatsapp = element.querySelector<HTMLAnchorElement>(
'a[href="https://wa.me/543412602222"]',
);
const phone = element.querySelector<HTMLAnchorElement>('a[href="tel:+54903416658247"]');
expect(element.textContent).toContain('CONTACTO');
expect(whatsapp?.textContent).toContain('Chatea con nosotros');
expect(phone?.textContent).toContain('+54 9 (0341) 6658247');
expect(element.textContent).toContain('Rosario');
expect(element.textContent).toContain('Gigante de Arroyito');
expect(element.textContent).toContain('Telepagos');
expect(element.querySelector('.contact-page__addresses')?.textContent).toContain(
'Av. Génova 640, Rosario',
);
expect(element.querySelector('.contact-page__addresses')?.textContent).toContain(
'Rioja 1150, piso 12, dpto. 3, Rosario',
);
expect(element.textContent).not.toContain('Santa Fe');
expect(element.textContent).not.toContain('San Martín 2450');
});
it('passes the tenant map coordinates to the reusable map component', () => {
const element = fixture.nativeElement as HTMLElement;
const map = element.querySelector('app-location-map');
const accessibleLocations = element.querySelectorAll('app-location-map .visually-hidden li');
const directionsLinks = element.querySelectorAll(
'app-location-map .visually-hidden a[target="_blank"]',
);
expect(map).not.toBeNull();
expect(map?.getAttribute('mapstyle')).toBe('minimal-light');
expect(accessibleLocations).toHaveLength(2);
expect(map?.textContent).toContain('Gigante de Arroyito');
expect(map?.textContent).toContain('Telepagos');
expect(map?.textContent).toContain('Av. Génova 640, Rosario');
expect(map?.textContent).toContain('Rioja 1150, piso 12, dpto. 3, Rosario');
expect(directionsLinks).toHaveLength(2);
expect(map?.textContent).not.toContain('San Martín 2450');
});
});

View File

@@ -0,0 +1,148 @@
import { Component, computed, inject } from '@angular/core';
import { findMenu } from '../../../../../../core/services/menu.utils';
import { TenantService } from '../../../../../../core/services/tenant.service';
import {
LocationMapComponent,
MapLocation,
} from '../../../../../../shared/components/location-map/location-map.component';
type ContactLocation = {
id: string;
label: string;
addresses: ContactAddress[];
};
type ContactAddress = {
id: string;
label: string;
address: string;
coordinates: [number, number];
};
type ContactContent = {
whatsappUrl: string | null;
whatsappLabel: string | null;
phone: string | null;
locations: ContactLocation[];
mapLocations: MapLocation[];
};
@Component({
selector: 'app-contact-page',
imports: [LocationMapComponent],
templateUrl: './contact-page.html',
styleUrl: './contact-page.scss',
})
export class ContactPage {
private readonly tenantService = inject(TenantService);
protected readonly markerColor = computed(
() => this.tenantService.tenant()?.primary_color ?? '#6376f3',
);
protected readonly contact = computed(() =>
this.parseContactContent(
findMenu(this.tenantService.tenant()?.menues ?? [], 'help.contact')?.static_content,
),
);
protected phoneUrl(phone: string): string {
const normalizedPhone = phone.replace(/[^\d+]/g, '');
return `tel:${normalizedPhone}`;
}
private parseContactContent(value: unknown): ContactContent {
const empty: ContactContent = {
whatsappUrl: null,
whatsappLabel: null,
phone: null,
locations: [],
mapLocations: [],
};
if (!this.isRecord(value)) {
return empty;
}
const whatsapp = this.isRecord(value['whatsapp']) ? value['whatsapp'] : null;
const locations = this.parseLocations(value['locations']).slice(0, 1);
return {
whatsappUrl: whatsapp ? this.stringValue(whatsapp['whatsapp_url']) : null,
whatsappLabel: whatsapp ? this.stringValue(whatsapp['whatsapp_label']) : null,
phone: this.stringValue(value['phone']),
locations,
mapLocations: locations.flatMap((location) =>
location.addresses.map(
(address): MapLocation => ({
id: address.id,
label: address.label,
latitude: address.coordinates[0],
longitude: address.coordinates[1],
address: address.address,
}),
),
),
};
}
private parseLocations(value: unknown): ContactLocation[] {
return this.recordEntries(value).flatMap(([id, item]): ContactLocation[] => {
if (!this.isRecord(item)) {
return [];
}
const label = this.stringValue(item['label']);
const addresses: ContactAddress[] = Array.isArray(item['addresses'])
? item['addresses'].flatMap((address, index): ContactAddress[] => {
if (!this.isRecord(address)) {
return [];
}
const addressLabel = this.stringValue(address['label']);
const addressText = this.stringValue(address['address']);
const coordinates = address['coordinates'];
if (
!addressLabel ||
!addressText ||
!Array.isArray(coordinates) ||
coordinates.length !== 2 ||
typeof coordinates[0] !== 'number' ||
typeof coordinates[1] !== 'number'
) {
return [];
}
return [
{
id: `${id}-${index}`,
label: addressLabel,
address: addressText,
coordinates: [coordinates[0], coordinates[1]],
},
];
})
: [];
return label && addresses.length > 0 ? [{ id, label, addresses }] : [];
});
}
private recordEntries(value: unknown): [string, unknown][] {
if (Array.isArray(value)) {
return value.map((item, index) => [String(index), item]);
}
return this.isRecord(value) ? Object.entries(value) : [];
}
private stringValue(value: unknown): string | null {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
private isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
}

View File

@@ -0,0 +1,19 @@
<section class="faq-page" aria-labelledby="faq-page-title">
<h2 id="faq-page-title" class="faq-page__title">PREGUNTAS FRECUENTES</h2>
@if (faqs().length > 0) {
<app-accordion [flush]="true">
@for (faq of faqs(); track $index; let last = $last) {
<app-accordion-item
[title]="faq.question"
[headingLevel]="3"
[open]="last"
>
<p>{{ faq.answer }}</p>
</app-accordion-item>
}
</app-accordion>
} @else {
<p class="faq-page__empty">No hay preguntas frecuentes disponibles.</p>
}
</section>

View File

@@ -0,0 +1,28 @@
:host {
display: block;
width: 100%;
}
.faq-page {
width: 100%;
max-width: 760px;
}
.faq-page__title {
margin: 0 0 0.5rem;
color: #a0a0a0;
font-size: 0.9375rem;
font-weight: 700;
line-height: 1.3;
}
.faq-page p {
margin: 0;
}
.faq-page__empty {
padding: 1rem 0;
border-bottom: 1px solid #dedede;
color: #888888;
font-size: 0.8125rem;
}

View File

@@ -0,0 +1,85 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Tenant } from '../../../../../../core/services/tenant.interface';
import { TenantService } from '../../../../../../core/services/tenant.service';
import { FaqPage } from './faq-page';
const tenant: Tenant = {
id: 1,
codigo: 'test',
nombre: 'Test',
dominio: 'localhost',
primary_color: '#6376f3',
secondary_color: '#a0a0a0',
danger_color: '#ff8888',
success_color: '#198754',
header_bg_color: '#ffffff',
footer_bg_color: '#202020',
header_logo: '',
footer_logo: '',
categories: [],
menues: [
{
id: 1,
code: 'help',
label: 'Ayuda',
parent_menu_code: null,
content_type: 'dynamic',
route: '/ayuda',
submenues: [
{
id: 2,
code: 'help.faq',
label: 'Preguntas frecuentes',
parent_menu_code: 'help',
content_type: 'static',
route: '/ayuda/preguntas-frecuentes',
static_content: [
{
pregunta: '¿Hay algún límite de compra?',
respuesta: 'No hay un límite general de compra.',
},
{
pregunta: '¿Cuáles son los medios de pago disponibles?',
respuesta: 'Podés seleccionarlos al finalizar tu compra.',
},
],
submenues: [],
},
],
},
],
};
describe('FaqPage', () => {
let fixture: ComponentFixture<FaqPage>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [FaqPage],
providers: [
{
provide: TenantService,
useValue: {
tenant: signal<Tenant | null>(tenant).asReadonly(),
},
},
],
}).compileComponents();
fixture = TestBed.createComponent(FaqPage);
fixture.detectChanges();
});
it('renders the tenant FAQ content using the reusable accordion', () => {
const element = fixture.nativeElement as HTMLElement;
const buttons = element.querySelectorAll<HTMLButtonElement>('.accordion-button');
expect(element.textContent).toContain('PREGUNTAS FRECUENTES');
expect(buttons).toHaveLength(2);
expect(buttons[0].textContent).toContain('¿Hay algún límite de compra?');
expect(buttons[1].getAttribute('aria-expanded')).toBe('true');
expect(element.textContent).toContain('Podés seleccionarlos al finalizar tu compra.');
});
});

View File

@@ -0,0 +1,47 @@
import { Component, computed, inject } from '@angular/core';
import { findMenu } from '../../../../../../core/services/menu.utils';
import { TenantService } from '../../../../../../core/services/tenant.service';
import { AccordionItemComponent } from '../../../../../../shared/components/accordion/accordion-item.component';
import { AccordionComponent } from '../../../../../../shared/components/accordion/accordion.component';
type Faq = {
question: string;
answer: string;
};
@Component({
selector: 'app-faq-page',
imports: [AccordionComponent, AccordionItemComponent],
templateUrl: './faq-page.html',
styleUrl: './faq-page.scss',
})
export class FaqPage {
private readonly tenantService = inject(TenantService);
protected readonly faqs = computed(() => {
const staticContent = findMenu(
this.tenantService.tenant()?.menues ?? [],
'help.faq',
)?.static_content;
if (!Array.isArray(staticContent)) {
return [];
}
return staticContent.flatMap((item): Faq[] => {
if (
typeof item !== 'object' ||
item === null ||
!('pregunta' in item) ||
!('respuesta' in item) ||
typeof item.pregunta !== 'string' ||
typeof item.respuesta !== 'string'
) {
return [];
}
return [{ question: item.pregunta, answer: item.respuesta }];
});
});
}

View File

@@ -0,0 +1,7 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-pending-help-page',
template: '',
})
export class PendingHelpPage {}

View File

@@ -0,0 +1,7 @@
<div class="help-layout">
<app-help-sidebar class="help-layout__sidebar" />
<div class="help-layout__content">
<router-outlet />
</div>
</div>

View File

@@ -0,0 +1,25 @@
:host {
display: block;
}
.help-layout {
display: grid;
grid-template-columns: minmax(190px, 250px) minmax(0, 1fr);
gap: clamp(3rem, 8vw, 6.25rem);
width: 100%;
}
.help-layout__sidebar {
min-width: 0;
}
.help-layout__content {
min-width: 0;
}
@media (max-width: 767.98px) {
.help-layout {
grid-template-columns: 1fr;
gap: 2rem;
}
}

View File

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

View File

@@ -40,7 +40,11 @@
<small class="text-danger">{{ errorMessage }}</small>
}
<div class="text-end">
<button type="button" class="login-page__forgot-password btn btn-link p-0 border-0 text-decoration-none">
<button
type="button"
class="login-page__forgot-password btn btn-link p-0 border-0 text-decoration-none"
(click)="goToRecoverPassword()"
>
Olvide mi contraseña
</button>
</div>
@@ -56,7 +60,7 @@
>
{{ isSubmitting() ? 'Ingresando...' : 'Ingresar' }}
</app-button>
<app-button
type="button"
variant="borderless"

View File

@@ -9,6 +9,7 @@ import { InputComponent } from '../../../../shared/components/input/input.compon
const PASSWORD_MIN_LENGTH = 8;
const EMAIL_MAX_LENGTH = 255;
const POST_LOGIN_RETURN_URL_KEY = 'shopit.auth.return-url';
@Component({
selector: 'app-login-page',
@@ -53,6 +54,10 @@ export class LoginPageComponent {
void this.router.navigate(['/register']);
}
goToRecoverPassword(): void {
void this.router.navigate(['/recuperar-contrasena']);
}
onSubmit(): void {
this.submittedState.set(true);
this.serverErrorState.set(null);
@@ -80,6 +85,10 @@ export class LoginPageComponent {
this.serverErrorState.set(null);
try {
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl');
if (returnUrl?.startsWith('/') && !returnUrl.startsWith('//')) {
this.document.defaultView?.sessionStorage.setItem(POST_LOGIN_RETURN_URL_KEY, returnUrl);
}
this.authService.loginWithGoogle();
} catch (error: unknown) {
this.serverErrorState.set(this.resolveErrorMessage(error));
@@ -127,8 +136,16 @@ export class LoginPageComponent {
}
protected redirectToHome(): void {
const homeUrl = this.router.serializeUrl(this.router.createUrlTree(['/']));
this.document.location.assign(homeUrl);
const requestedUrl =
this.route.snapshot.queryParamMap.get('returnUrl') ??
this.document.defaultView?.sessionStorage.getItem(POST_LOGIN_RETURN_URL_KEY);
const destination =
requestedUrl?.startsWith('/') && !requestedUrl.startsWith('//')
? requestedUrl
: this.router.serializeUrl(this.router.createUrlTree(['/']));
this.document.defaultView?.sessionStorage.removeItem(POST_LOGIN_RETURN_URL_KEY);
this.document.location.assign(destination);
}
private completeGoogleLogin(oauthCode: string): void {

View File

@@ -73,9 +73,12 @@
<app-button
class="product-detail__cta"
type="button"
[disabled]="!selectedVariantAvailable() || variantLoading()"
[disabled]="
!selectedVariantAvailable() || variantLoading() || creatingDirectPurchase()
"
(click)="buyNow()"
>
@if (variantLoading()) {
@if (variantLoading() || creatingDirectPurchase()) {
<div class="spinner-border spinner-border-sm" role="status"></div>
} @else {
Comprar

View File

@@ -1,4 +1,5 @@
import { TestBed, getTestBed } from '@angular/core/testing';
import { signal } from '@angular/core';
import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
import { ActivatedRoute, Router } from '@angular/router';
import { By } from '@angular/platform-browser';
@@ -10,6 +11,9 @@ import { CatalogService } from '../../../../core/services/catalog/catalog.servic
import { CartService } from '../../../../core/services/cart/cart.service';
import { ToastService } from '../../../../core/services/toast.service';
import { ProductDetailPageComponent } from './product-detail-page.component';
import { CheckoutService } from '../../../../core/services/checkout.service';
import { TenantService } from '../../../../core/services/tenant.service';
import { AuthService } from '../../../../core/services/auth/auth.service';
import {
PRODUCT_DETAIL_ERROR_MESSAGE,
ProductDetailResolvedData,
@@ -41,6 +45,7 @@ describe('ProductDetailPageComponent', () => {
let routerStub: any;
let cartServiceStub: any;
let toastServiceStub: any;
let checkoutServiceStub: any;
beforeAll(() => {
try {
@@ -72,6 +77,9 @@ describe('ProductDetailPageComponent', () => {
danger: vi.fn(),
info: vi.fn(),
};
checkoutServiceStub = {
startCheckout: vi.fn().mockResolvedValue({ id: 44 }),
};
});
async function configureTestingModule() {
@@ -100,6 +108,26 @@ describe('ProductDetailPageComponent', () => {
provide: ToastService,
useValue: toastServiceStub,
},
{
provide: CheckoutService,
useValue: checkoutServiceStub,
},
{
provide: TenantService,
useValue: { tenant: signal({ codigo: 'tenant-test' }) },
},
{
provide: AuthService,
useValue: {
user: signal({
id: 7,
nombre_apellido: 'Juan Perez',
email: 'juan@example.com',
dni: '12345678',
telefono: '3415555555',
}),
},
},
],
}).compileComponents();
}
@@ -364,7 +392,7 @@ describe('ProductDetailPageComponent', () => {
expect(toggleButton.textContent?.trim()).toBe('Mostrar menos');
});
it('renders purchase actions without side effects', async () => {
it('renders purchase actions and starts a direct checkout', async () => {
await configureTestingModule();
const fixture = TestBed.createComponent(ProductDetailPageComponent);
fixture.detectChanges();
@@ -380,8 +408,18 @@ describe('ProductDetailPageComponent', () => {
buttons[0].click();
buttons[1].click();
await Promise.resolve();
expect(routerStub.navigate).not.toHaveBeenCalled();
expect(checkoutServiceStub.startCheckout).toHaveBeenCalledWith('tenant-test', {
direct_item: {
catalog_item_id: 1,
variant_id: null,
cantidad: 1,
},
});
expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout'], {
queryParams: { purchase: 44 },
});
});
it('calls CartService.addItem when variant is selected and Add to Cart is clicked', async () => {

View File

@@ -28,6 +28,9 @@ import { ButtonComponent } from '../../../../shared/components/button/button.com
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';
import { CheckoutService } from '../../../../core/services/checkout.service';
import { TenantService } from '../../../../core/services/tenant.service';
import { AuthService } from '../../../../core/services/auth/auth.service';
@Component({
selector: 'app-product-detail-page',
@@ -50,6 +53,9 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
private readonly catalogService = inject(CatalogService);
private readonly toastService = inject(ToastService);
private readonly cartService = inject(CartService);
private readonly checkoutService = inject(CheckoutService);
private readonly tenantService = inject(TenantService);
private readonly authService = inject(AuthService);
private readonly attributeSelector = viewChild(ProductAttributeSelectorComponent);
private readonly carouselHost = viewChild<ElementRef<HTMLElement>>('carouselHost');
private readonly descriptionBody = viewChild<ElementRef<HTMLElement>>('descriptionBody');
@@ -77,6 +83,7 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
protected readonly loading = signal(false);
protected readonly variantLoading = signal(false);
protected readonly addingToCart = signal(false);
protected readonly creatingDirectPurchase = signal(false);
protected readonly error = signal<string | null>(null);
protected readonly selectedVariant = signal<CatalogItemVariant | null>(null);
protected readonly quantity = signal(1);
@@ -253,6 +260,55 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
});
}
protected async buyNow(): Promise<void> {
const currentProduct = this.product();
const variant = this.selectedVariant();
if (this.creatingDirectPurchase()) {
return;
}
if (!currentProduct || !this.selectedVariantAvailable()) {
this.toastService.danger('Por favor, selecciona una variante.');
return;
}
const tenant = this.tenantService.tenant();
const user = this.authService.user();
if (!user) {
void this.router.navigate(['/login'], {
queryParams: { returnUrl: `/producto/${currentProduct.id}` },
});
return;
}
if (!tenant) {
this.toastService.danger('No se pudo identificar la tienda.');
return;
}
this.creatingDirectPurchase.set(true);
try {
const purchase = await this.checkoutService.startCheckout(tenant.codigo, {
direct_item: {
catalog_item_id: currentProduct.id,
variant_id: variant?.id ?? null,
cantidad: this.quantity(),
},
});
await this.router.navigate(['/checkout'], {
queryParams: { purchase: purchase.id },
});
} catch (error) {
console.error('Failed to create direct purchase:', error);
this.toastService.danger('No se pudo iniciar la compra directa.');
} finally {
this.creatingDirectPurchase.set(false);
}
}
private isVariantAvailable(variant: CatalogItemVariant, product: CatalogItemDetail): boolean {
return product.inventory_policy === 'unlimited' || (variant.stock_tecnico ?? 0) > 0;
}

View File

@@ -16,18 +16,34 @@
<hr class="status-content__divider" />
<div class="status-content__section status-content__section--secondary">
<p class="status-content__message">Comunicate con nosotros para coordinar el env&iacute;o.</p>
@if (ticketsRoute()) {
<p class="status-content__message">A continuación, vas a poder ver los tickets que debés presentar en el evento.</p>
<app-button
type="button"
variant="secondary"
hostClass="status-content__button d-block"
buttonClass="status-content__button-control d-inline-flex align-items-center justify-content-center gap-2 py-2"
(click)="openWhatsApp()"
>
<i class="fa-brands fa-whatsapp status-content__button-icon"></i>
<span>WhatsApp</span>
</app-button>
<app-button
type="button"
variant="primary"
hostClass="status-content__button d-block"
buttonClass="status-content__button-control d-inline-flex align-items-center justify-content-center gap-2 py-2"
(click)="goToTickets()"
>
<span>Mis tickets</span>
</app-button>
} @else if (whatsappUrl()) {
<p class="status-content__message">Comunicate con nosotros para coordinar el env&iacute;o.</p>
<app-button
type="button"
variant="secondary"
hostClass="status-content__button d-block"
buttonClass="status-content__button-control d-inline-flex align-items-center justify-content-center gap-2 py-2"
(click)="openWhatsApp()"
>
<i class="fa-brands fa-whatsapp status-content__button-icon"></i>
<span>WhatsApp</span>
</app-button>
} @else {
<p class="status-content__message">Tu compra qued&oacute; registrada correctamente.</p>
}
</div>
} @else if (status() === 'pending') {
<div class="status-content__section status-content__section--primary">
@@ -41,21 +57,21 @@
<hr class="status-content__divider" />
<div class="status-content__section status-content__section--secondary">
<p class="status-content__message">Esta pantalla se actualiza autom&aacute;ticamente cuando el pago impacta.</p>
@if (isRefreshing()) {
<p class="status-content__hint">Actualizando estado...</p>
}
<p class="status-content__message">Te avisaremos cuando el pago sea confirmado.</p>
</div>
} @else if (status() === 'expired') {
<div class="status-content__section status-content__section--primary">
<div class="status-content__icon status-content__icon--warning">
<i class="fa-solid fa-clock"></i>
</div>
<h2 class="status-content__title">LA COMPRA VENCI&Oacute;</h2>
<p class="status-content__subtitle">El plazo de pago termin&oacute; y liberamos el stock reservado.</p>
</div>
<app-button
type="button"
variant="secondary"
hostClass="status-content__button d-block"
buttonClass="status-content__button-control d-inline-flex align-items-center justify-content-center py-2"
[disabled]="isRefreshing()"
(click)="retryStatusCheck()"
>
Actualizar estado
</app-button>
<hr class="status-content__divider" />
<div class="status-content__section status-content__section--secondary">
<p class="status-content__message">Pod&eacute;s volver a la tienda e iniciar una nueva compra.</p>
</div>
} @else if (status() === 'rejected') {
<div class="status-content__section status-content__section--primary">
@@ -69,30 +85,22 @@
<hr class="status-content__divider" />
<div class="status-content__section status-content__section--secondary">
<p class="status-content__message">Si ya pagaste, pod&eacute;s reintentar la consulta o escribirnos para revisarlo.</p>
<p class="status-content__message">Si ya pagaste, escribinos para que podamos revisarlo.</p>
<div class="status-content__actions">
<app-button
type="button"
variant="secondary"
hostClass="status-content__button d-block"
buttonClass="status-content__button-control d-inline-flex align-items-center justify-content-center py-2"
(click)="retryStatusCheck()"
>
Reintentar
</app-button>
<app-button
type="button"
variant="secondary"
hostClass="status-content__button d-block"
buttonClass="status-content__button-control d-inline-flex align-items-center justify-content-center gap-2 py-2"
(click)="openWhatsApp()"
>
<i class="fa-brands fa-whatsapp status-content__button-icon"></i>
<span>WhatsApp</span>
</app-button>
</div>
@if (whatsappUrl()) {
<div class="status-content__actions">
<app-button
type="button"
variant="secondary"
hostClass="status-content__button d-block"
buttonClass="status-content__button-control d-inline-flex align-items-center justify-content-center gap-2 py-2"
(click)="openWhatsApp()"
>
<i class="fa-brands fa-whatsapp status-content__button-icon"></i>
<span>WhatsApp</span>
</app-button>
</div>
}
</div>
} @else {
<div class="status-content__section status-content__section--primary">
@@ -106,30 +114,22 @@
<hr class="status-content__divider" />
<div class="status-content__section status-content__section--secondary">
<p class="status-content__message">Reintent&aacute; en unos segundos. Si el problema sigue, comunicate con nosotros.</p>
<p class="status-content__message">Volv&eacute; a ingresar m&aacute;s tarde. Si el problema sigue, comunicate con nosotros.</p>
<div class="status-content__actions">
<app-button
type="button"
variant="secondary"
hostClass="status-content__button d-block"
buttonClass="status-content__button-control d-inline-flex align-items-center justify-content-center py-2"
(click)="retryStatusCheck()"
>
Reintentar
</app-button>
<app-button
type="button"
variant="secondary"
hostClass="status-content__button d-block"
buttonClass="status-content__button-control d-inline-flex align-items-center justify-content-center gap-2 py-2"
(click)="openWhatsApp()"
>
<i class="fa-brands fa-whatsapp status-content__button-icon"></i>
<span>WhatsApp</span>
</app-button>
</div>
@if (whatsappUrl()) {
<div class="status-content__actions">
<app-button
type="button"
variant="secondary"
hostClass="status-content__button d-block"
buttonClass="status-content__button-control d-inline-flex align-items-center justify-content-center gap-2 py-2"
(click)="openWhatsApp()"
>
<i class="fa-brands fa-whatsapp status-content__button-icon"></i>
<span>WhatsApp</span>
</app-button>
</div>
}
</div>
}
</div>

View File

@@ -98,7 +98,7 @@
font-size: 13px;
font-weight: 325;
line-height: 1.45;
color: var(--color-text);
color: #666666;
}
&__hint {

View File

@@ -0,0 +1,130 @@
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap, Router } from '@angular/router';
import { CheckoutService, PurchaseDetailResponse } from '../../../../core/services/checkout.service';
import { Tenant } from '../../../../core/services/tenant.interface';
import { TenantService } from '../../../../core/services/tenant.service';
import { PurchaseStatusPageComponent } from './purchase-status-page.component';
const tenant: Tenant = {
id: 1,
codigo: 'tickets-tenant',
nombre: 'Tickets tenant',
dominio: 'tickets.local',
primary_color: '#000000',
secondary_color: '#000000',
danger_color: '#000000',
success_color: '#000000',
header_bg_color: '#000000',
footer_bg_color: '#000000',
header_logo: '',
footer_logo: '',
categories: [],
social_media: [
{
code: 'whatsapp',
icon: 'whatsapp',
name: 'WhatsApp',
url: 'https://wa.me/543411234567',
},
],
menues: [
{
id: 1,
code: 'account',
label: 'Mi cuenta',
parent_menu_code: null,
content_type: 'dynamic',
route: '/mi-cuenta',
submenues: [
{
id: 2,
code: 'account.tickets',
label: 'Mis tickets',
parent_menu_code: 'account',
content_type: 'dynamic',
route: '/mi-cuenta/tickets',
submenues: [],
},
],
},
],
};
const purchase = (hasGeneratedTickets: boolean): PurchaseDetailResponse =>
({
status: 'paid',
has_generated_tickets: hasGeneratedTickets,
tickets_count: hasGeneratedTickets ? 1 : 0,
}) as PurchaseDetailResponse;
describe('PurchaseStatusPageComponent', () => {
beforeEach(() => {
TestBed.resetTestingModule();
});
async function render(hasGeneratedTickets: boolean) {
const checkoutService = {
getPurchase: vi.fn().mockResolvedValue(purchase(hasGeneratedTickets)),
};
const router = {
navigate: vi.fn().mockResolvedValue(true),
navigateByUrl: vi.fn().mockResolvedValue(true),
};
await TestBed.configureTestingModule({
imports: [PurchaseStatusPageComponent],
providers: [
{ provide: CheckoutService, useValue: checkoutService },
{ provide: TenantService, useValue: { tenant: () => tenant } },
{
provide: ActivatedRoute,
useValue: { snapshot: { paramMap: convertToParamMap({ id: '42' }) } },
},
{ provide: Router, useValue: router },
],
}).compileComponents();
const fixture = TestBed.createComponent(PurchaseStatusPageComponent);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
return {
fixture,
element: fixture.nativeElement as HTMLElement,
checkoutService,
router,
};
}
it('shows the tickets action when this purchase generated tickets', async () => {
const { element, checkoutService, router } = await render(true);
expect(checkoutService.getPurchase).toHaveBeenCalledOnce();
expect(element.textContent).toContain('Ver mis tickets');
expect(element.textContent).not.toContain('WhatsApp');
element.querySelector<HTMLButtonElement>('app-button button')?.click();
expect(router.navigateByUrl).toHaveBeenCalledWith('/mi-cuenta/tickets');
});
it('shows the tenant WhatsApp action when this purchase did not generate tickets', async () => {
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
const { element } = await render(false);
expect(element.textContent).toContain('WhatsApp');
expect(element.textContent).not.toContain('Ver mis tickets');
element.querySelector<HTMLButtonElement>('app-button button')?.click();
expect(openSpy).toHaveBeenCalledWith(
'https://wa.me/543411234567',
'_blank',
'noopener,noreferrer',
);
openSpy.mockRestore();
});
});

View File

@@ -1,11 +1,12 @@
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit, inject, signal } from '@angular/core';
import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { CheckoutService, PurchaseStatusResponse } from '../../../../core/services/checkout.service';
import { findMenu } from '../../../../core/services/menu.utils';
import { TenantService } from '../../../../core/services/tenant.service';
import { ButtonComponent } from '../../../../shared/components/button/button.component';
type PurchaseStatusView = 'approved' | 'pending' | 'rejected' | 'error';
type PurchaseStatusView = 'approved' | 'pending' | 'rejected' | 'expired' | 'error';
@Component({
selector: 'app-purchase-status-page',
@@ -15,20 +16,31 @@ type PurchaseStatusView = 'approved' | 'pending' | 'rejected' | 'error';
styleUrl: './purchase-status-page.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class PurchaseStatusPageComponent implements OnInit, OnDestroy {
export class PurchaseStatusPageComponent implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly checkoutService = inject(CheckoutService);
private readonly tenantService = inject(TenantService);
private readonly pollIntervalMs = 5000;
private purchaseId: string | null = null;
private tenantCode: string | null = null;
private pollTimeoutId: ReturnType<typeof setTimeout> | null = null;
protected readonly isLoading = signal(true);
protected readonly isRefreshing = signal(false);
protected readonly status = signal<PurchaseStatusView>('pending');
protected readonly hasGeneratedTickets = signal(false);
protected readonly ticketsRoute = computed(() => {
if (!this.hasGeneratedTickets()) {
return null;
}
return findMenu(this.tenantService.tenant()?.menues ?? [], 'account.tickets')?.route ?? null;
});
protected readonly whatsappUrl = computed(
() =>
this.tenantService
.tenant()
?.social_media?.find((socialMedia) => socialMedia.code === 'whatsapp')?.url ?? null,
);
ngOnInit(): void {
const purchaseId = this.route.snapshot.paramMap.get('id');
@@ -42,45 +54,23 @@ export class PurchaseStatusPageComponent implements OnInit, OnDestroy {
this.purchaseId = purchaseId;
this.tenantCode = tenant.codigo;
void this.refreshStatus(true);
void this.loadStatus();
}
ngOnDestroy(): void {
this.clearScheduledPoll();
}
protected retryStatusCheck(): void {
void this.refreshStatus(false);
}
private async refreshStatus(showLoader: boolean): Promise<void> {
private async loadStatus(): Promise<void> {
if (!this.purchaseId || !this.tenantCode) {
return;
}
this.clearScheduledPoll();
if (showLoader) {
this.isLoading.set(true);
} else {
this.isRefreshing.set(true);
}
try {
const purchase = await this.checkoutService.getPurchase(this.tenantCode, this.purchaseId);
const resolvedStatus = this.resolveStatus(purchase);
this.status.set(resolvedStatus);
if (resolvedStatus === 'pending') {
this.scheduleNextPoll();
}
this.status.set(this.resolveStatus(purchase));
this.hasGeneratedTickets.set(purchase.has_generated_tickets === true);
} catch (error) {
console.error('Failed to fetch purchase status:', error);
this.status.set('error');
} finally {
this.isLoading.set(false);
this.isRefreshing.set(false);
}
}
@@ -93,36 +83,26 @@ export class PurchaseStatusPageComponent implements OnInit, OnDestroy {
return 'rejected';
}
if (purchase.status === 'expired') {
return 'expired';
}
return 'pending';
}
private scheduleNextPoll(): void {
if (this.pollTimeoutId !== null) {
return;
protected goToTickets(): void {
const route = this.ticketsRoute();
if (route) {
void this.router.navigateByUrl(route);
}
this.pollTimeoutId = setTimeout(() => {
this.pollTimeoutId = null;
void this.refreshStatus(false);
}, this.pollIntervalMs);
}
private clearScheduledPoll(): void {
if (this.pollTimeoutId === null) {
return;
}
clearTimeout(this.pollTimeoutId);
this.pollTimeoutId = null;
}
protected getWhatsAppLink(): string {
const phone = '5493416658247';
const message = encodeURIComponent('Hola! Mi compra fue realizada con \u00E9xito.');
return `https://wa.me/${phone}?text=${message}`;
}
protected openWhatsApp(): void {
window.open(this.getWhatsAppLink(), '_blank', 'noopener,noreferrer');
const url = this.whatsappUrl();
if (url) {
window.open(url, '_blank', 'noopener,noreferrer');
}
}
}

View File

@@ -0,0 +1,106 @@
<header class="recover-password-code-page__header text-center mb-4">
<h1 id="recover-password-code-title" class="auth-title mb-4">Ingresá el código</h1>
<p class="recover-password-code-page__description mb-0">
Te enviamos un código a <strong>{{ email }}</strong>
</p>
<p class="recover-password-code-page__description fst-italic mb-0">
Recordá revisar en Correo no deseado.
</p>
</header>
<form
class="recover-password-code-page__form d-grid gap-4"
novalidate
aria-labelledby="recover-password-code-title"
[formGroup]="form"
(ngSubmit)="onSubmit()"
>
<div>
<fieldset class="border-0 p-0 m-0">
<legend class="visually-hidden">Código de recuperación de cuatro dígitos</legend>
<div class="recover-password-code-page__inputs d-flex justify-content-center gap-2">
<app-input
id="recover-password-code-digit-1"
type="text"
[maxlength]="1"
[value]="form.controls.digit1.value"
[invalid]="isCodeInvalid()"
(valueChange)="updateDigit('digit1', $event)"
(pasteEvent)="pasteCode($event)"
/>
<app-input
id="recover-password-code-digit-2"
type="text"
[maxlength]="1"
[value]="form.controls.digit2.value"
[invalid]="isCodeInvalid()"
(valueChange)="updateDigit('digit2', $event)"
(pasteEvent)="pasteCode($event)"
/>
<app-input
id="recover-password-code-digit-3"
type="text"
[maxlength]="1"
[value]="form.controls.digit3.value"
[invalid]="isCodeInvalid()"
(valueChange)="updateDigit('digit3', $event)"
(pasteEvent)="pasteCode($event)"
/>
<app-input
id="recover-password-code-digit-4"
type="text"
[maxlength]="1"
[value]="form.controls.digit4.value"
[invalid]="isCodeInvalid()"
(valueChange)="updateDigit('digit4', $event)"
(pasteEvent)="pasteCode($event)"
/>
</div>
</fieldset>
@if (isCodeInvalid()) {
<small class="text-danger d-block text-center mt-2">
Ingresá los cuatro dígitos del código.
</small>
}
@if (serverError(); as errorMessage) {
<small class="text-danger d-block text-center mt-2" role="alert">
{{ errorMessage }}
</small>
}
<div class="text-center">
<app-button
type="button"
variant="borderless"
buttonClass="recover-password-code-page__resend-action px-3 py-2"
[disabled]="isResending()"
(click)="resendCode()"
>
{{ isResending() ? 'Reenviando...' : 'Reenviar Código' }}
</app-button>
</div>
</div>
<div class="d-grid gap-2 text-center">
<app-button
type="submit"
hostClass="w-100 d-block"
buttonClass="w-100"
[disabled]="isSubmitting()"
>
{{ isSubmitting() ? 'Validando...' : 'Validar' }}
</app-button>
<app-button
type="button"
variant="borderless"
hostClass="w-100 d-block text-center"
buttonClass="recover-password-code-page__back-action px-3 py-2"
(click)="goBack()"
>
Volver
</app-button>
</div>
</form>

View File

@@ -0,0 +1,65 @@
:host {
display: block;
}
.recover-password-code-page__description {
color: #a0a0a0;
font-size: 12px;
font-weight: 325;
}
.recover-password-code-page__form {
width: 100%;
}
.recover-password-code-page__inputs app-input {
width: 52px;
}
:host ::ng-deep .recover-password-code-page__inputs .form-control {
min-height: 52px;
padding: 0.5rem;
text-align: center;
font-size: 20px;
}
:host ::ng-deep .recover-password-code-page__resend-action {
min-height: 36px;
color: #a0a0a0 !important;
font-size: 12px !important;
font-weight: 400 !important;
transition:
color 0.15s ease,
transform 0.1s ease;
}
:host ::ng-deep .recover-password-code-page__resend-action:hover,
:host ::ng-deep .recover-password-code-page__resend-action:focus-visible {
color: #8a8a8a !important;
}
:host ::ng-deep .recover-password-code-page__resend-action:hover {
transform: translateY(-1px);
}
:host ::ng-deep .recover-password-code-page__resend-action:active {
color: #666666 !important;
transform: translateY(1px);
}
:host ::ng-deep .recover-password-code-page__back-action {
min-height: 40px;
transition:
color 0.15s ease,
transform 0.1s ease;
}
:host ::ng-deep .recover-password-code-page__back-action:hover,
:host ::ng-deep .recover-password-code-page__back-action:focus-visible {
transform: translateY(-1px);
}
:host ::ng-deep .recover-password-code-page__back-action:active {
opacity: 0.75;
transform: translateY(1px);
}

View File

@@ -0,0 +1,313 @@
import { HttpResponse } from '@angular/common/http';
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap, provideRouter, Router } from '@angular/router';
import { of, throwError } from 'rxjs';
import { AuthService } from '../../../../core/services/auth/auth.service';
import { ToastService } from '../../../../core/services/toast.service';
import { RecoverPasswordCodePageComponent } from './recover-password-code-page.component';
describe('RecoverPasswordCodePageComponent', () => {
let validatePasswordResetCode: ReturnType<typeof vi.fn>;
let requestPasswordReset: ReturnType<typeof vi.fn>;
let showDangerToast: ReturnType<typeof vi.fn>;
let showSuccessToast: ReturnType<typeof vi.fn>;
beforeEach(() => {
TestBed.resetTestingModule();
vi.spyOn(console, 'error').mockImplementation(() => undefined);
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
validatePasswordResetCode = vi.fn(() => of(new HttpResponse({
body: {
message: 'Código validado correctamente.',
status: 'validated' as const,
},
status: 200,
})));
requestPasswordReset = vi.fn(() => of(new HttpResponse({
body: {
message: 'Solicitud aceptada.',
status: 'pending' as const,
},
status: 202,
})));
showDangerToast = vi.fn();
showSuccessToast = vi.fn();
});
afterEach(() => {
vi.restoreAllMocks();
});
function authProviders() {
return [
{
provide: AuthService,
useValue: { requestPasswordReset, validatePasswordResetCode },
},
{
provide: ToastService,
useValue: { danger: showDangerToast, success: showSuccessToast },
},
];
}
it('shows the email received from the recovery flow', async () => {
await TestBed.configureTestingModule({
imports: [RecoverPasswordCodePageComponent],
providers: [
provideRouter([]),
...authProviders(),
{
provide: ActivatedRoute,
useValue: {
snapshot: {
queryParamMap: convertToParamMap({ email: 'ada@example.com' }),
},
},
},
],
}).compileComponents();
const fixture = TestBed.createComponent(RecoverPasswordCodePageComponent);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('ada@example.com');
});
it('moves focus to the next input after entering a digit', async () => {
await TestBed.configureTestingModule({
imports: [RecoverPasswordCodePageComponent],
providers: [
provideRouter([]),
...authProviders(),
{
provide: ActivatedRoute,
useValue: {
snapshot: {
queryParamMap: convertToParamMap({ email: 'ada@example.com' }),
},
},
},
],
}).compileComponents();
const fixture = TestBed.createComponent(RecoverPasswordCodePageComponent);
const component = fixture.componentInstance as any;
fixture.detectChanges();
const inputs = fixture.nativeElement.querySelectorAll('input');
inputs[0].focus();
component.updateDigit('digit1', '1');
expect(document.activeElement).toBe(inputs[1]);
});
it('distributes a pasted four-digit code across all inputs', async () => {
await TestBed.configureTestingModule({
imports: [RecoverPasswordCodePageComponent],
providers: [
provideRouter([]),
...authProviders(),
{
provide: ActivatedRoute,
useValue: {
snapshot: {
queryParamMap: convertToParamMap({ email: 'ada@example.com' }),
},
},
},
],
}).compileComponents();
const fixture = TestBed.createComponent(RecoverPasswordCodePageComponent);
const component = fixture.componentInstance as any;
fixture.detectChanges();
const inputs = fixture.nativeElement.querySelectorAll('input');
const pasteEvent = new Event('paste', {
bubbles: true,
cancelable: true,
});
Object.defineProperty(pasteEvent, 'clipboardData', {
value: {
getData: () => '01 23',
},
});
inputs[0].dispatchEvent(pasteEvent);
fixture.detectChanges();
expect(pasteEvent.defaultPrevented).toBe(true);
expect(component.form.getRawValue()).toEqual({
digit1: '0',
digit2: '1',
digit3: '2',
digit4: '3',
});
expect(component.form.valid).toBe(true);
expect(document.activeElement).toBe(inputs[3]);
});
it('accepts only a complete four-digit code and navigates back preserving the email', async () => {
await TestBed.configureTestingModule({
imports: [RecoverPasswordCodePageComponent],
providers: [
provideRouter([]),
...authProviders(),
{
provide: ActivatedRoute,
useValue: {
snapshot: {
queryParamMap: convertToParamMap({ email: 'ada@example.com' }),
},
},
},
],
}).compileComponents();
const fixture = TestBed.createComponent(RecoverPasswordCodePageComponent);
const component = fixture.componentInstance as any;
const router = TestBed.inject(Router);
const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
component.updateDigit('digit1', '1');
component.updateDigit('digit2', 'a2');
component.updateDigit('digit3', '3');
component.updateDigit('digit4', '4');
expect(component.form.getRawValue()).toEqual({
digit1: '1',
digit2: '2',
digit3: '3',
digit4: '4',
});
expect(component.form.valid).toBe(true);
component.goBack();
expect(navigateSpy).toHaveBeenCalledWith(['/recuperar-contrasena'], {
queryParams: { email: 'ada@example.com' },
});
});
it('continues to the reset password page with the email and code', async () => {
await TestBed.configureTestingModule({
imports: [RecoverPasswordCodePageComponent],
providers: [
provideRouter([]),
...authProviders(),
{
provide: ActivatedRoute,
useValue: {
snapshot: {
queryParamMap: convertToParamMap({ email: 'ada@example.com' }),
},
},
},
],
}).compileComponents();
const fixture = TestBed.createComponent(RecoverPasswordCodePageComponent);
const component = fixture.componentInstance as any;
const router = TestBed.inject(Router);
const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
component.form.setValue({
digit1: '1',
digit2: '2',
digit3: '3',
digit4: '4',
});
component.onSubmit();
expect(validatePasswordResetCode).toHaveBeenCalledWith('ada@example.com', '1234');
expect(navigateSpy).toHaveBeenCalledWith(['/recuperar-contrasena/restablecer'], {
queryParams: {
email: 'ada@example.com',
code: '1234',
},
});
});
it('shows the validation error and stays on the code page', async () => {
validatePasswordResetCode.mockReturnValue(throwError(() => ({
error: {
errors: {
codigo: ['El código ingresado es inválido.'],
},
},
})));
await TestBed.configureTestingModule({
imports: [RecoverPasswordCodePageComponent],
providers: [
provideRouter([]),
...authProviders(),
{
provide: ActivatedRoute,
useValue: {
snapshot: {
queryParamMap: convertToParamMap({ email: 'ada@example.com' }),
},
},
},
],
}).compileComponents();
const fixture = TestBed.createComponent(RecoverPasswordCodePageComponent);
const component = fixture.componentInstance as any;
const router = TestBed.inject(Router);
const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
component.form.setValue({
digit1: '9',
digit2: '9',
digit3: '9',
digit4: '9',
});
component.onSubmit();
expect(navigateSpy).not.toHaveBeenCalled();
expect(component.serverError()).toBe('El código ingresado es inválido.');
expect(showDangerToast).toHaveBeenCalledWith('El código ingresado es inválido.');
});
it('requests a new code and clears the previous digits', async () => {
await TestBed.configureTestingModule({
imports: [RecoverPasswordCodePageComponent],
providers: [
provideRouter([]),
...authProviders(),
{
provide: ActivatedRoute,
useValue: {
snapshot: {
queryParamMap: convertToParamMap({ email: 'ada@example.com' }),
},
},
},
],
}).compileComponents();
const fixture = TestBed.createComponent(RecoverPasswordCodePageComponent);
const component = fixture.componentInstance as any;
fixture.detectChanges();
component.form.setValue({
digit1: '1',
digit2: '2',
digit3: '3',
digit4: '4',
});
component.resendCode();
expect(requestPasswordReset).toHaveBeenCalledWith('ada@example.com');
expect(component.form.getRawValue()).toEqual({
digit1: '',
digit2: '',
digit3: '',
digit4: '',
});
expect(showSuccessToast).toHaveBeenCalledWith('Código reenviado correctamente.');
});
});

View File

@@ -0,0 +1,191 @@
import { ChangeDetectionStrategy, Component, inject, signal, viewChildren } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { AuthService } from '../../../../core/services/auth/auth.service';
import { ToastService } from '../../../../core/services/toast.service';
import { ButtonComponent } from '../../../../shared/components/button/button.component';
import { InputComponent } from '../../../../shared/components/input/input.component';
type CodeControlName = 'digit1' | 'digit2' | 'digit3' | 'digit4';
const CODE_CONTROL_NAMES: CodeControlName[] = ['digit1', 'digit2', 'digit3', 'digit4'];
@Component({
selector: 'app-recover-password-code-page',
imports: [ReactiveFormsModule, InputComponent, ButtonComponent],
templateUrl: './recover-password-code-page.component.html',
styleUrl: './recover-password-code-page.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RecoverPasswordCodePageComponent {
private readonly formBuilder = inject(FormBuilder);
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
private readonly authService = inject(AuthService);
private readonly toastService = inject(ToastService);
private readonly submittedState = signal(false);
private readonly isSubmittingState = signal(false);
private readonly isResendingState = signal(false);
private readonly serverErrorState = signal<string | null>(null);
private readonly digitInputs = viewChildren(InputComponent);
protected readonly email = this.route.snapshot.queryParamMap.get('email') ?? '';
protected readonly form = this.formBuilder.nonNullable.group({
digit1: ['', [Validators.required, Validators.pattern(/^\d$/)]],
digit2: ['', [Validators.required, Validators.pattern(/^\d$/)]],
digit3: ['', [Validators.required, Validators.pattern(/^\d$/)]],
digit4: ['', [Validators.required, Validators.pattern(/^\d$/)]],
});
protected readonly submitted = this.submittedState.asReadonly();
protected readonly isSubmitting = this.isSubmittingState.asReadonly();
protected readonly isResending = this.isResendingState.asReadonly();
protected readonly serverError = this.serverErrorState.asReadonly();
protected updateDigit(controlName: CodeControlName, value: string | number): void {
const digit = String(value).replace(/\D/g, '').slice(-1);
this.form.controls[controlName].setValue(digit);
const currentIndex = CODE_CONTROL_NAMES.indexOf(controlName);
if (digit && currentIndex < CODE_CONTROL_NAMES.length - 1) {
this.digitInputs()[currentIndex + 1]?.focus();
}
}
protected pasteCode(event: ClipboardEvent): void {
event.preventDefault();
const pastedText = event.clipboardData?.getData('text') ?? '';
const digits = pastedText.replace(/\D/g, '');
if (digits.length !== CODE_CONTROL_NAMES.length) {
console.warn('Password reset code paste was rejected because it did not contain four digits.', {
digitCount: digits.length,
});
this.showServerError('Pegá un código de cuatro dígitos.');
return;
}
CODE_CONTROL_NAMES.forEach((controlName, index) => {
this.form.controls[controlName].setValue(digits[index]);
});
this.submittedState.set(false);
this.serverErrorState.set(null);
this.digitInputs()[CODE_CONTROL_NAMES.length - 1]?.focus();
}
protected isCodeInvalid(): boolean {
return this.form.invalid && this.submitted();
}
protected onSubmit(): void {
this.submittedState.set(true);
this.serverErrorState.set(null);
if (this.form.invalid || this.isSubmitting()) {
this.form.markAllAsTouched();
return;
}
const code = Object.values(this.form.getRawValue()).join('');
if (!this.email) {
console.warn('Password reset code validation cannot start without an email.');
this.showServerError('No se pudo identificar la solicitud de recuperación.');
return;
}
this.isSubmittingState.set(true);
this.authService.validatePasswordResetCode(this.email, code).subscribe({
next: (response) => {
this.isSubmittingState.set(false);
if (response.status !== 200 || response.body?.status !== 'validated') {
console.warn('Password reset code validation returned an unexpected status.', {
httpStatus: response.status,
attemptStatus: response.body?.status ?? null,
});
this.showServerError('No se pudo validar el código. Intenta nuevamente.');
return;
}
void this.router.navigate(['/recuperar-contrasena/restablecer'], {
queryParams: {
email: this.email,
code,
},
});
},
error: (error: unknown) => {
this.isSubmittingState.set(false);
console.error('Password reset code validation request failed.', error);
this.showServerError(this.resolveErrorMessage(error));
},
});
}
protected resendCode(): void {
if (!this.email || this.isResending()) {
return;
}
this.serverErrorState.set(null);
this.isResendingState.set(true);
this.authService.requestPasswordReset(this.email).subscribe({
next: (response) => {
this.isResendingState.set(false);
if (response.status !== 202 || response.body?.status !== 'pending') {
console.warn('Password reset code resend returned an unexpected status.', {
httpStatus: response.status,
attemptStatus: response.body?.status ?? null,
});
this.showServerError('No se pudo reenviar el código. Intenta nuevamente.');
return;
}
this.submittedState.set(false);
this.form.reset();
this.digitInputs()[0]?.focus();
this.toastService.success('Código reenviado correctamente.');
},
error: (error: unknown) => {
this.isResendingState.set(false);
console.error('Password reset code resend request failed.', error);
this.showServerError(this.resolveErrorMessage(error));
},
});
}
protected goBack(): void {
void this.router.navigate(['/recuperar-contrasena'], {
queryParams: this.email ? { email: this.email } : undefined,
});
}
private showServerError(message: string): void {
this.serverErrorState.set(message);
this.toastService.danger(message);
}
private resolveErrorMessage(error: unknown): string {
const errorPayload =
typeof error === 'object' && error !== null && 'error' in error
? (error as { error?: { errors?: Record<string, string[]>; message?: string } }).error
: undefined;
const codeMessages = errorPayload?.errors?.['codigo'];
const codeError = Array.isArray(codeMessages) ? codeMessages[0] : null;
if (typeof codeError === 'string' && codeError.trim()) {
return codeError;
}
if (typeof errorPayload?.message === 'string' && errorPayload.message.trim()) {
return errorPayload.message;
}
return 'No se pudo validar el código. Intenta nuevamente.';
}
}

View File

@@ -0,0 +1,53 @@
<header class="recover-password-page__header text-center mb-4">
<h1 id="recover-password-title" class="auth-title mb-3">Recuperar contraseña</h1>
<p class="recover-password-page__description mb-0">
Ingresá tu Email para restablecer tu Contraseña.
</p>
</header>
<form
class="recover-password-page__form d-grid gap-3"
novalidate
aria-labelledby="recover-password-title"
[formGroup]="form"
(ngSubmit)="onSubmit()"
>
<div class="d-grid gap-1">
<label class="visually-hidden" for="recover-password-email">Email</label>
<app-input
id="recover-password-email"
type="email"
placeholder="Email"
[value]="form.controls.email.value"
[invalid]="showEmailError()"
(valueChange)="updateEmail($event)"
/>
@if (getEmailError(); as errorMessage) {
<small class="text-danger">{{ errorMessage }}</small>
}
@if (serverError(); as errorMessage) {
<small class="text-danger" role="alert">{{ errorMessage }}</small>
}
</div>
<div class="d-grid gap-2 text-center">
<app-button
type="submit"
hostClass="w-100 d-block"
buttonClass="w-100"
[disabled]="isSubmitting()"
>
{{ isSubmitting() ? 'Enviando...' : 'Recuperar acceso' }}
</app-button>
<app-button
type="button"
variant="borderless"
hostClass="w-100 d-block text-center"
buttonClass="recover-password-page__back-action px-3 py-2"
(click)="goToLogin()"
>
Volver
</app-button>
</div>
</form>

View File

@@ -0,0 +1,17 @@
:host {
display: block;
}
.recover-password-page__description {
color: #a0a0a0;
font-size: 12px;
font-weight: 325;
}
.recover-password-page__form {
width: 100%;
}
.recover-password-page__back-action {
min-height: 40px;
}

View File

@@ -0,0 +1,139 @@
import { HttpResponse } from '@angular/common/http';
import { TestBed } from '@angular/core/testing';
import { provideRouter, Router } from '@angular/router';
import { of, throwError } from 'rxjs';
import { AuthService } from '../../../../core/services/auth/auth.service';
import { ToastService } from '../../../../core/services/toast.service';
import { RecoverPasswordPageComponent } from './recover-password-page.component';
describe('RecoverPasswordPageComponent', () => {
let requestPasswordReset: ReturnType<typeof vi.fn>;
let showDangerToast: ReturnType<typeof vi.fn>;
beforeEach(() => {
TestBed.resetTestingModule();
vi.spyOn(console, 'error').mockImplementation(() => undefined);
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
requestPasswordReset = vi.fn(() => of(new HttpResponse({
body: {
message: 'Solicitud aceptada.',
status: 'pending' as const
},
status: 202
})));
showDangerToast = vi.fn();
});
afterEach(() => {
vi.restoreAllMocks();
});
function authProviders() {
return [
{ provide: AuthService, useValue: { requestPasswordReset } },
{ provide: ToastService, useValue: { danger: showDangerToast } }
];
}
it('validates the email before submitting', async () => {
await TestBed.configureTestingModule({
imports: [RecoverPasswordPageComponent],
providers: [provideRouter([]), ...authProviders()],
}).compileComponents();
const fixture = TestBed.createComponent(RecoverPasswordPageComponent);
const component = fixture.componentInstance as any;
component.form.controls.email.setValue('invalid-email');
component.onSubmit();
expect(component.form.invalid).toBe(true);
expect(component.getEmailError()).toBe('Ingresa un email válido.');
});
it('navigates back to login', async () => {
await TestBed.configureTestingModule({
imports: [RecoverPasswordPageComponent],
providers: [provideRouter([]), ...authProviders()],
}).compileComponents();
const fixture = TestBed.createComponent(RecoverPasswordPageComponent);
const component = fixture.componentInstance as any;
const router = TestBed.inject(Router);
const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
component.goToLogin();
expect(navigateSpy).toHaveBeenCalledWith(['/login']);
});
it('continues to the code page with the entered email', async () => {
await TestBed.configureTestingModule({
imports: [RecoverPasswordPageComponent],
providers: [provideRouter([]), ...authProviders()],
}).compileComponents();
const fixture = TestBed.createComponent(RecoverPasswordPageComponent);
const component = fixture.componentInstance as any;
const router = TestBed.inject(Router);
const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
component.form.controls.email.setValue('ada@example.com');
component.onSubmit();
expect(requestPasswordReset).toHaveBeenCalledWith('ada@example.com');
expect(navigateSpy).toHaveBeenCalledWith(['/recuperar-contrasena/codigo'], {
queryParams: { email: 'ada@example.com' },
});
});
it('stays on the page when the endpoint does not finish as pending', async () => {
requestPasswordReset.mockReturnValue(of(new HttpResponse({
body: null,
status: 200
})));
await TestBed.configureTestingModule({
imports: [RecoverPasswordPageComponent],
providers: [provideRouter([]), ...authProviders()],
}).compileComponents();
const fixture = TestBed.createComponent(RecoverPasswordPageComponent);
const component = fixture.componentInstance as any;
const router = TestBed.inject(Router);
const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
component.form.controls.email.setValue('ada@example.com');
component.onSubmit();
expect(navigateSpy).not.toHaveBeenCalled();
expect(component.serverError()).toBe(
'No se pudo iniciar la recuperación. Intenta nuevamente.',
);
expect(showDangerToast).toHaveBeenCalled();
});
it('shows the API error and does not navigate when the request fails', async () => {
requestPasswordReset.mockReturnValue(throwError(() => ({
error: { message: 'Servicio temporalmente no disponible.' }
})));
await TestBed.configureTestingModule({
imports: [RecoverPasswordPageComponent],
providers: [provideRouter([]), ...authProviders()],
}).compileComponents();
const fixture = TestBed.createComponent(RecoverPasswordPageComponent);
const component = fixture.componentInstance as any;
const router = TestBed.inject(Router);
const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
component.form.controls.email.setValue('ada@example.com');
component.onSubmit();
expect(navigateSpy).not.toHaveBeenCalled();
expect(component.serverError()).toBe('Servicio temporalmente no disponible.');
expect(showDangerToast).toHaveBeenCalledWith('Servicio temporalmente no disponible.');
});
});

View File

@@ -0,0 +1,135 @@
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { AuthService } from '../../../../core/services/auth/auth.service';
import { ToastService } from '../../../../core/services/toast.service';
import { ButtonComponent } from '../../../../shared/components/button/button.component';
import { InputComponent } from '../../../../shared/components/input/input.component';
const EMAIL_MAX_LENGTH = 255;
@Component({
selector: 'app-recover-password-page',
imports: [ReactiveFormsModule, InputComponent, ButtonComponent],
templateUrl: './recover-password-page.component.html',
styleUrl: './recover-password-page.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RecoverPasswordPageComponent {
private readonly formBuilder = inject(FormBuilder);
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
private readonly authService = inject(AuthService);
private readonly toastService = inject(ToastService);
private readonly submittedState = signal(false);
private readonly isSubmittingState = signal(false);
private readonly serverErrorState = signal<string | null>(null);
protected readonly form = this.formBuilder.nonNullable.group({
email: [
this.route.snapshot.queryParamMap.get('email') ?? '',
[Validators.required, Validators.email, Validators.maxLength(EMAIL_MAX_LENGTH)],
],
});
protected readonly submitted = this.submittedState.asReadonly();
protected readonly isSubmitting = this.isSubmittingState.asReadonly();
protected readonly serverError = this.serverErrorState.asReadonly();
protected updateEmail(value: string | number): void {
this.form.controls.email.setValue(String(value));
}
protected showEmailError(): boolean {
const email = this.form.controls.email;
return email.invalid && (email.touched || this.submitted());
}
protected getEmailError(): string | null {
const email = this.form.controls.email;
if (!this.showEmailError()) {
return null;
}
if (email.hasError('required')) {
return 'Este campo es obligatorio.';
}
if (email.hasError('maxlength')) {
return `No puede superar los ${EMAIL_MAX_LENGTH} caracteres.`;
}
if (email.hasError('email')) {
return 'Ingresa un email válido.';
}
return 'El valor ingresado no es válido.';
}
protected onSubmit(): void {
this.submittedState.set(true);
this.serverErrorState.set(null);
if (this.form.invalid || this.isSubmitting()) {
this.form.markAllAsTouched();
return;
}
const email = this.form.controls.email.value;
this.isSubmittingState.set(true);
this.authService.requestPasswordReset(email).subscribe({
next: (response) => {
this.isSubmittingState.set(false);
if (response.status !== 202 || response.body?.status !== 'pending') {
console.warn('Password reset attempt returned an unexpected status.', {
httpStatus: response.status,
attemptStatus: response.body?.status ?? null,
});
this.showServerError('No se pudo iniciar la recuperación. Intenta nuevamente.');
return;
}
void this.router.navigate(['/recuperar-contrasena/codigo'], {
queryParams: { email },
});
},
error: (error: unknown) => {
this.isSubmittingState.set(false);
console.error('Password reset attempt request failed.', error);
this.showServerError(this.resolveErrorMessage(error));
},
});
}
protected goToLogin(): void {
void this.router.navigate(['/login']);
}
private showServerError(message: string): void {
this.serverErrorState.set(message);
this.toastService.danger(message);
}
private resolveErrorMessage(error: unknown): string {
const errorPayload =
typeof error === 'object' && error !== null && 'error' in error
? (error as { error?: { errors?: Record<string, string[]>; message?: string } }).error
: undefined;
const emailMessages = errorPayload?.errors?.['email'];
const emailError = Array.isArray(emailMessages) ? emailMessages[0] : null;
if (typeof emailError === 'string' && emailError.trim()) {
return emailError;
}
if (typeof errorPayload?.message === 'string' && errorPayload.message.trim()) {
return errorPayload.message;
}
return 'No se pudo iniciar la recuperación. Intenta nuevamente.';
}
}

View File

@@ -0,0 +1,56 @@
<header class="reset-password-page__header text-center mb-4">
<h1 id="reset-password-title" class="auth-title">Restablecer contraseña</h1>
</header>
<form
class="reset-password-page__form d-grid gap-3"
novalidate
aria-labelledby="reset-password-title"
[formGroup]="form"
(ngSubmit)="onSubmit()"
>
<div class="d-grid gap-1">
<label class="visually-hidden" for="reset-password-new">Nueva Contraseña</label>
<app-input
id="reset-password-new"
type="password"
placeholder="Nueva Contraseña"
[value]="form.controls.password.value"
[invalid]="showControlError('password')"
(valueChange)="updatePassword('password', $event)"
/>
@if (getControlError('password'); as errorMessage) {
<small class="text-danger">{{ errorMessage }}</small>
}
</div>
<div class="d-grid gap-1">
<label class="visually-hidden" for="reset-password-confirmation">
Repetir Nueva Contraseña
</label>
<app-input
id="reset-password-confirmation"
type="password"
placeholder="Repetir Nueva Contraseña"
[value]="form.controls.password_confirmation.value"
[invalid]="showControlError('password_confirmation')"
(valueChange)="updatePassword('password_confirmation', $event)"
/>
@if (getControlError('password_confirmation'); as errorMessage) {
<small class="text-danger">{{ errorMessage }}</small>
}
</div>
@if (serverError(); as errorMessage) {
<small class="text-danger" role="alert">{{ errorMessage }}</small>
}
<app-button
type="submit"
hostClass="w-100 d-block"
buttonClass="w-100"
[disabled]="isSubmitting()"
>
{{ isSubmitting() ? 'Guardando...' : 'Guardar' }}
</app-button>
</form>

View File

@@ -0,0 +1,7 @@
:host {
display: block;
}
.reset-password-page__form {
width: 100%;
}

View File

@@ -0,0 +1,148 @@
import { HttpResponse } from '@angular/common/http';
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap, provideRouter, Router } from '@angular/router';
import { of, throwError } from 'rxjs';
import { AuthService } from '../../../../core/services/auth/auth.service';
import { ModalService } from '../../../../core/services/modal.service';
import { ToastService } from '../../../../core/services/toast.service';
import { ResetPasswordPageComponent } from './reset-password-page.component';
describe('ResetPasswordPageComponent', () => {
let resetPassword: ReturnType<typeof vi.fn>;
let showDangerToast: ReturnType<typeof vi.fn>;
beforeEach(() => {
TestBed.resetTestingModule();
vi.spyOn(console, 'error').mockImplementation(() => undefined);
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
resetPassword = vi.fn(() => of(new HttpResponse({
body: {
message: 'Contraseña modificada correctamente.',
status: 'used' as const,
},
status: 200,
})));
showDangerToast = vi.fn();
});
afterEach(() => {
vi.restoreAllMocks();
});
function resetProviders(modalService: { openSimple: ReturnType<typeof vi.fn> }) {
return [
provideRouter([]),
{ provide: AuthService, useValue: { resetPassword } },
{ provide: ModalService, useValue: modalService },
{ provide: ToastService, useValue: { danger: showDangerToast } },
{
provide: ActivatedRoute,
useValue: {
snapshot: {
queryParamMap: convertToParamMap({
email: 'ada@example.com',
code: '1234',
}),
},
},
},
];
}
it('rejects passwords that do not match', async () => {
const modalService = {
openSimple: vi.fn(),
};
await TestBed.configureTestingModule({
imports: [ResetPasswordPageComponent],
providers: resetProviders(modalService),
}).compileComponents();
const fixture = TestBed.createComponent(ResetPasswordPageComponent);
const component = fixture.componentInstance as any;
component.form.setValue({
password: 'Secret!123',
password_confirmation: 'Different!123',
});
component.onSubmit();
expect(component.form.hasError('passwordMismatch')).toBe(true);
expect(component.getControlError('password_confirmation')).toBe(
'Las contraseñas no coinciden.',
);
expect(modalService.openSimple).not.toHaveBeenCalled();
expect(resetPassword).not.toHaveBeenCalled();
});
it('shows the success modal and returns to login for matching passwords', async () => {
const modalService = {
openSimple: vi.fn().mockReturnValue(of(undefined)),
};
await TestBed.configureTestingModule({
imports: [ResetPasswordPageComponent],
providers: resetProviders(modalService),
}).compileComponents();
const fixture = TestBed.createComponent(ResetPasswordPageComponent);
const component = fixture.componentInstance as any;
const router = TestBed.inject(Router);
const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
component.form.setValue({
password: 'Secret!123',
password_confirmation: 'Secret!123',
});
component.onSubmit();
expect(component.form.valid).toBe(true);
expect(component.getControlError('password_confirmation')).toBeNull();
expect(resetPassword).toHaveBeenCalledWith({
email: 'ada@example.com',
codigo: '1234',
password: 'Secret!123',
password_confirmation: 'Secret!123',
});
expect(modalService.openSimple).toHaveBeenCalledWith({
content: 'Contraseña modificada correctamente',
buttonLabel: 'Cerrar',
});
expect(navigateSpy).toHaveBeenCalledWith(['/login']);
});
it('shows the API error without opening the success modal', async () => {
resetPassword.mockReturnValue(throwError(() => ({
error: {
errors: {
codigo: ['La solicitud de recuperación es inválida o ya fue utilizada.'],
},
},
})));
const modalService = {
openSimple: vi.fn(),
};
await TestBed.configureTestingModule({
imports: [ResetPasswordPageComponent],
providers: resetProviders(modalService),
}).compileComponents();
const fixture = TestBed.createComponent(ResetPasswordPageComponent);
const component = fixture.componentInstance as any;
component.form.setValue({
password: 'Secret!123',
password_confirmation: 'Secret!123',
});
component.onSubmit();
expect(modalService.openSimple).not.toHaveBeenCalled();
expect(component.serverError()).toBe(
'La solicitud de recuperación es inválida o ya fue utilizada.',
);
expect(showDangerToast).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,193 @@
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import {
AbstractControl,
FormBuilder,
ReactiveFormsModule,
ValidationErrors,
ValidatorFn,
Validators,
} from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { AuthService } from '../../../../core/services/auth/auth.service';
import { ModalService } from '../../../../core/services/modal.service';
import { ToastService } from '../../../../core/services/toast.service';
import { ButtonComponent } from '../../../../shared/components/button/button.component';
import { InputComponent } from '../../../../shared/components/input/input.component';
const PASSWORD_MIN_LENGTH = 8;
const passwordsMatchValidator: ValidatorFn = (
control: AbstractControl,
): ValidationErrors | null => {
const password = control.get('password')?.value;
const passwordConfirmation = control.get('password_confirmation')?.value;
if (!password || !passwordConfirmation) {
return null;
}
return password === passwordConfirmation ? null : { passwordMismatch: true };
};
type PasswordControlName = 'password' | 'password_confirmation';
@Component({
selector: 'app-reset-password-page',
imports: [ReactiveFormsModule, InputComponent, ButtonComponent],
templateUrl: './reset-password-page.component.html',
styleUrl: './reset-password-page.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ResetPasswordPageComponent {
private readonly formBuilder = inject(FormBuilder);
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
private readonly authService = inject(AuthService);
private readonly modalService = inject(ModalService);
private readonly toastService = inject(ToastService);
private readonly submittedState = signal(false);
private readonly isSubmittingState = signal(false);
private readonly serverErrorState = signal<string | null>(null);
private readonly email = this.route.snapshot.queryParamMap.get('email') ?? '';
private readonly code = this.route.snapshot.queryParamMap.get('code') ?? '';
protected readonly form = this.formBuilder.nonNullable.group(
{
password: [
'',
[
Validators.required,
Validators.minLength(PASSWORD_MIN_LENGTH),
Validators.pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*[\W_]).+$/),
],
],
password_confirmation: ['', [Validators.required]],
},
{
validators: [passwordsMatchValidator],
},
);
protected readonly submitted = this.submittedState.asReadonly();
protected readonly isSubmitting = this.isSubmittingState.asReadonly();
protected readonly serverError = this.serverErrorState.asReadonly();
protected updatePassword(controlName: PasswordControlName, value: string | number): void {
this.form.controls[controlName].setValue(String(value));
}
protected showControlError(controlName: PasswordControlName): boolean {
const control = this.form.controls[controlName];
const hasPasswordMismatch =
controlName === 'password_confirmation' &&
this.form.hasError('passwordMismatch') &&
(control.touched || this.submitted());
return (control.invalid && (control.touched || this.submitted())) || hasPasswordMismatch;
}
protected getControlError(controlName: PasswordControlName): string | null {
const control = this.form.controls[controlName];
if (!this.showControlError(controlName)) {
return null;
}
if (control.hasError('required')) {
return 'Este campo es obligatorio.';
}
if (control.hasError('minlength')) {
return `Debe tener al menos ${PASSWORD_MIN_LENGTH} caracteres.`;
}
if (controlName === 'password' && control.hasError('pattern')) {
return 'Debe contener mayúscula, minúscula y un carácter especial.';
}
if (controlName === 'password_confirmation' && this.form.hasError('passwordMismatch')) {
return 'Las contraseñas no coinciden.';
}
return 'El valor ingresado no es válido.';
}
protected onSubmit(): void {
this.submittedState.set(true);
this.serverErrorState.set(null);
if (this.form.invalid || this.isSubmitting()) {
this.form.markAllAsTouched();
return;
}
if (!this.email || !/^\d{4}$/.test(this.code)) {
console.warn('Password reset cannot start without a validated recovery request.');
this.showServerError('La solicitud de recuperación es inválida.');
return;
}
this.isSubmittingState.set(true);
this.authService.resetPassword({
email: this.email,
codigo: this.code,
...this.form.getRawValue(),
}).subscribe({
next: (response) => {
this.isSubmittingState.set(false);
if (response.status !== 200 || response.body?.status !== 'used') {
console.warn('Password reset returned an unexpected status.', {
httpStatus: response.status,
attemptStatus: response.body?.status ?? null,
});
this.showServerError('No se pudo modificar la contraseña. Intenta nuevamente.');
return;
}
this.modalService
.openSimple({
content: 'Contraseña modificada correctamente',
buttonLabel: 'Cerrar',
})
.subscribe(() => {
void this.router.navigate(['/login']);
});
},
error: (error: unknown) => {
this.isSubmittingState.set(false);
console.error('Password reset request failed.', error);
this.showServerError(this.resolveErrorMessage(error));
},
});
}
private showServerError(message: string): void {
this.serverErrorState.set(message);
this.toastService.danger(message);
}
private resolveErrorMessage(error: unknown): string {
const errorPayload =
typeof error === 'object' && error !== null && 'error' in error
? (error as { error?: { errors?: Record<string, string[]>; message?: string } }).error
: undefined;
const errors = errorPayload?.errors;
for (const field of ['codigo', 'password'] as const) {
const messages = errors?.[field];
if (Array.isArray(messages) && typeof messages[0] === 'string' && messages[0].trim()) {
return messages[0];
}
}
if (typeof errorPayload?.message === 'string' && errorPayload.message.trim()) {
return errorPayload.message;
}
return 'No se pudo modificar la contraseña. Intenta nuevamente.';
}
}

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