Compare commits

..

5 Commits

225 changed files with 2616 additions and 13774 deletions

View File

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

25
package-lock.json generated
View File

@@ -19,7 +19,6 @@
"@fortawesome/fontawesome-free": "^7.2.0", "@fortawesome/fontawesome-free": "^7.2.0",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"express": "^5.1.0", "express": "^5.1.0",
"leaflet": "^1.9.4",
"qrcode": "1.5.4", "qrcode": "1.5.4",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tslib": "^2.3.0" "tslib": "^2.3.0"
@@ -29,7 +28,6 @@
"@angular/cli": "^22.0.4", "@angular/cli": "^22.0.4",
"@angular/compiler-cli": "^22.0.0", "@angular/compiler-cli": "^22.0.0",
"@types/express": "^5.0.1", "@types/express": "^5.0.1",
"@types/leaflet": "^1.9.21",
"@types/node": "^20.17.19", "@types/node": "^20.17.19",
"jsdom": "^28.0.0", "jsdom": "^28.0.0",
"prettier": "^3.8.1", "prettier": "^3.8.1",
@@ -3818,13 +3816,6 @@
"@types/send": "*" "@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": { "node_modules/@types/http-errors": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
@@ -3832,16 +3823,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/node": {
"version": "20.19.43", "version": "20.19.43",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
@@ -5955,12 +5936,6 @@
], ],
"license": "MIT" "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": { "node_modules/listr2": {
"version": "10.2.1", "version": "10.2.1",
"resolved": "https://registry.npmjs.org/listr2/-/listr2-10.2.1.tgz", "resolved": "https://registry.npmjs.org/listr2/-/listr2-10.2.1.tgz",

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

View File

@@ -23,8 +23,7 @@ const tenant: Tenant = {
header_bg_color: '#313131', header_bg_color: '#313131',
footer_bg_color: '#313131', footer_bg_color: '#313131',
header_logo: 'https://example.com/header.png', header_logo: 'https://example.com/header.png',
footer_logo: 'https://example.com/footer.png', footer_logo: 'https://example.com/footer.png'
categories: []
}; };
function createTenantServiceStub( function createTenantServiceStub(
@@ -166,55 +165,6 @@ 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 () => { it('redirects authenticated users away from /login', async () => {
const { router } = await renderAppAt('/login', createTenantServiceStub(), createAuthServiceStub(true)); const { router } = await renderAppAt('/login', createTenantServiceStub(), createAuthServiceStub(true));
@@ -236,7 +186,7 @@ describe('app routes', () => {
it('redirects unauthenticated users from /checkout to /login', async () => { it('redirects unauthenticated users from /checkout to /login', async () => {
const { router } = await renderAppAt('/checkout', createTenantServiceStub(), createAuthServiceStub(false)); const { router } = await renderAppAt('/checkout', createTenantServiceStub(), createAuthServiceStub(false));
expect(router.url).toBe('/login?returnUrl=%2Fcheckout'); expect(router.url).toBe('/login');
}); });
it('allows authenticated users to access /checkout', async () => { it('allows authenticated users to access /checkout', async () => {

View File

@@ -24,8 +24,7 @@ const tenant: Tenant = {
header_bg_color: '#313131', header_bg_color: '#313131',
footer_bg_color: '#313131', footer_bg_color: '#313131',
header_logo: 'https://example.com/header.png', 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) { function createTenantServiceStub(status: 'ready' | 'not-found', currentTenant: Tenant | null) {

View File

@@ -1,25 +0,0 @@
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 => {
return () => {
const tenantService = inject(TenantService);
const router = inject(Router);
const tenant = tenantService.tenant();
if (!tenant) {
return router.createUrlTree(['/']);
}
const hasMenu = findMenu(tenant.menues ?? [], menuCode) !== undefined;
if (hasMenu) {
return true;
}
return router.createUrlTree(['/']);
};
};

View File

@@ -1,7 +1,7 @@
<footer class="store-layout__footer mt-auto text-light"> <footer class="store-layout__footer mt-auto text-light">
<div class="container-xl px-3 px-md-4 py-4 py-md-5"> <div class="container-xl px-3 px-md-4 py-4 py-md-5">
<div class="row g-4"> <div class="row g-4">
<div class="col-12"> <div class="col-12 col-md-6 col-xl-5 d-grid gap-3 align-content-start">
<div <div
class="store-layout__brand-slot d-inline-flex align-items-center gap-3" class="store-layout__brand-slot d-inline-flex align-items-center gap-3"
data-testid="store-footer-brand-slot" data-testid="store-footer-brand-slot"
@@ -15,15 +15,13 @@
</div> </div>
} }
</div> </div>
</div>
<div class="col-12 col-md-6 col-xl-5 d-grid gap-3 align-content-start">
<div class="d-grid gap-2"> <div class="d-grid gap-2">
<div class="d-flex align-items-center gap-2 "> <div class="d-flex align-items-center gap-2 text-light-emphasis">
<i class="fa-solid fa-location-dot" aria-hidden="true"></i> <i class="fa-solid fa-location-dot" aria-hidden="true"></i>
<span>Av. San Lorenzo 1542, Rosario</span> <span>Av. San Lorenzo 1542, Rosario</span>
</div> </div>
<div class="d-flex align-items-center gap-2 "> <div class="d-flex align-items-center gap-2 text-light-emphasis">
<i class="fa-solid fa-phone" aria-hidden="true"></i> <i class="fa-solid fa-phone" aria-hidden="true"></i>
<span>54 9 (0341) 6658247</span> <span>54 9 (0341) 6658247</span>
</div> </div>
@@ -33,42 +31,24 @@
</div> </div>
@for (section of footerSections; track section.heading) { @for (section of footerSections; track section.heading) {
<div class="col-12 col-md-6 col-xl-2 d-grid align-content-start"> <div class="col-12 col-md-6 col-xl-2 d-grid gap-3 align-content-start">
<h2 class="store-layout__footer-heading">{{ section.heading }}</h2>
<div class="d-grid gap-2"> <div class="d-grid gap-2">
@for (link of section.links; track link.label) { @for (link of section.links; track link) {
@if (link.routerLink) { <span class="store-layout__muted-text">{{ link }}</span>
<a
class="store-layout__footer-link"
[routerLink]="link.routerLink"
>
{{ link.label }}
</a>
} @else if (link.action === 'logout') {
<button
type="button"
class="store-layout__footer-link store-layout__footer-action"
(click)="logoutClick.emit()"
>
{{ link.label }}
</button>
} @else {
<span class="store-layout__muted-text">{{ link.label }}</span>
}
} }
</div> </div>
</div> </div>
} }
<div class="col-12 col-md-6 col-xl-3 d-grid align-content-start"> <div class="col-12 col-md-6 col-xl-3 d-grid gap-3 align-content-start">
<h2 class="store-layout__footer-heading">Seguinos:</h2>
<div class="d-flex align-items-center gap-3"> <div class="d-flex align-items-center gap-3">
@for (social of socialMedia; track social.code) { @for (social of socialLinks; track social.label) {
<app-social-media <span class="fs-5 text-white">
class="text-white" <i [class]="social.iconClass" aria-hidden="true"></i>
[code]="social.code" <span class="visually-hidden">{{ social.label }}</span>
[icon]="social.icon" </span>
[name]="social.name"
[url]="social.url"
/>
} }
</div> </div>
</div> </div>

View File

@@ -3,7 +3,6 @@
} }
.store-layout__brand-slot { .store-layout__brand-slot {
width: min(100%, 12rem);
min-width: 10rem; min-width: 10rem;
} }
@@ -19,7 +18,6 @@
height: 100%; height: 100%;
max-height: 2.75rem; max-height: 2.75rem;
object-fit: contain; object-fit: contain;
object-position: left center;
} }
.store-layout__brand-logo-skeleton { .store-layout__brand-logo-skeleton {
@@ -57,30 +55,17 @@
color: #ffffff; color: #ffffff;
} }
.store-layout__footer-heading {
margin: 0;
font-size: 0.95rem;
font-weight: 500;
color: #f8f8f8;
}
.store-layout__muted-text { .store-layout__muted-text {
color: rgba(255, 255, 255, 0.72); color: rgba(255, 255, 255, 0.72);
} }
.store-layout__footer-link {
width: fit-content;
color: rgba(255, 255, 255, 0.72);
text-decoration: none;
&:hover,
&:focus-visible {
color: #ffffff;
text-decoration: underline;
}
}
.store-layout__footer-action {
padding: 0;
border: 0;
background: transparent;
font: inherit;
text-align: left;
}
@keyframes store-footer-logo-skeleton { @keyframes store-footer-logo-skeleton {
from { from {
background-position: 200% 0; background-position: 200% 0;

View File

@@ -1,29 +1,24 @@
import { Component, Input, output } from '@angular/core'; import { Component, Input } from '@angular/core';
import { RouterLink } from '@angular/router';
import { SocialMedia } from '../../../services/tenant.interface';
import { SocialMediaComponent } from '../../../../shared/components/social-media/social-media.component';
export type StoreFooterLink = {
label: string;
routerLink?: string;
action?: 'logout';
};
export type StoreFooterSection = { export type StoreFooterSection = {
heading: string; heading: string;
links: StoreFooterLink[]; links: string[];
};
export type StoreSocialLink = {
label: string;
iconClass: string;
}; };
@Component({ @Component({
selector: 'app-store-footer', selector: 'app-store-footer',
imports: [RouterLink, SocialMediaComponent], imports: [],
templateUrl: './store-footer.component.html', templateUrl: './store-footer.component.html',
styleUrl: './store-footer.component.scss', styleUrl: './store-footer.component.scss'
}) })
export class StoreFooterComponent { export class StoreFooterComponent {
@Input({ required: true }) currentYear = new Date().getFullYear(); @Input({ required: true }) currentYear = new Date().getFullYear();
@Input({ required: true }) footerSections: StoreFooterSection[] = []; @Input({ required: true }) footerSections: StoreFooterSection[] = [];
@Input({ required: true }) socialMedia: SocialMedia[] = []; @Input({ required: true }) socialLinks: StoreSocialLink[] = [];
@Input() logoUrl: string | null = null; @Input() logoUrl: string | null = null;
readonly logoutClick = output<void>();
} }

View File

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

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

@@ -1,46 +0,0 @@
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,8 +1,6 @@
<header class="store-layout__header"> <header class="store-layout__header">
<div class="store-layout__header-main"> <div class="store-layout__header-main">
<div <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">
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 <a
routerLink="/" routerLink="/"
class="store-layout__brand-slot d-inline-flex align-items-center gap-3 flex-shrink-0 text-decoration-none" class="store-layout__brand-slot d-inline-flex align-items-center gap-3 flex-shrink-0 text-decoration-none"
@@ -18,57 +16,24 @@
} }
</a> </a>
<div <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">
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>
<div class="store-layout__search-wrapper"> <input
<div class="input-group store-layout__search" role="search"> id="store-search-input"
<label class="visually-hidden" for="store-search-input">Buscar productos</label> type="search"
<input class="form-control store-layout__search-input border-end-0 rounded-start"
id="store-search-input" />
type="search" <button
class="form-control store-layout__search-input border-end-0 rounded-start" type="button"
placeholder="Buscar productos" class="btn store-layout__search-button border-start-0 rounded-end px-3"
autocomplete="off" aria-label="Buscar"
[attr.aria-describedby]="showSearchError() ? 'store-search-error' : null" >
[attr.aria-invalid]="showSearchError()" <i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
[minlength]="minSearchLength" </button>
[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>
@if (ticketsMenu(); as menu) { <div class="d-flex align-items-center justify-content-end gap-1" aria-label="Acciones de usuario">
<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 <app-cart-icon
[quantity]="cartQuantity()" [quantity]="cartQuantity()"
ariaLabel="Carrito de compras" ariaLabel="Carrito de compras"
@@ -89,7 +54,6 @@
<app-user-dropdown <app-user-dropdown
id="store-user-dropdown" id="store-user-dropdown"
[user]="user()" [user]="user()"
[accountNavigationMenus]="accountNavigationMenus()"
(logoutClick)="onLogoutClick()" (logoutClick)="onLogoutClick()"
/> />
} }
@@ -99,28 +63,16 @@
</div> </div>
</div> </div>
<div class="store-layout__categories"> <div>
<div class="container-xl px-3 px-md-4"> <div class="container-xl px-3 px-md-4">
<div class="store-layout__category-menu"> <button
<button type="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"
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-label="Categorias" >
aria-controls="store-category-dropdown" <span>Categorias</span>
[attr.aria-expanded]="isCategoryDropdownOpen()" <i class="fa-solid fa-chevron-down" aria-hidden="true"></i>
(click)="toggleCategoryDropdown()" </button>
>
<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>
</div> </div>
</header> </header>

View File

@@ -9,8 +9,7 @@
} }
.store-layout__brand-slot { .store-layout__brand-slot {
min-width: 150px; min-width: 10rem;
max-width:40px;
} }
.store-layout__brand-logo-shell { .store-layout__brand-logo-shell {
@@ -41,22 +40,11 @@
animation: store-header-logo-skeleton 1.2s ease-in-out infinite; animation: store-header-logo-skeleton 1.2s ease-in-out infinite;
} }
.store-layout__search-wrapper { .store-layout__search {
width: min(100%, 260px); width: min(100%, 260px);
min-width: 0; 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 { .store-layout__search-input {
color: #666666; color: #666666;
border-color: #cccccc; border-color: #cccccc;
@@ -111,19 +99,6 @@
color: #8a8a8a; 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 { @keyframes store-header-logo-skeleton {
from { from {
background-position: 200% 0; background-position: 200% 0;

View File

@@ -4,25 +4,12 @@ import { AuthUser } from '../../../services/auth/auth.interfaces';
import { CartIconComponent } from '../../../../shared/components/cart-icon/cart-icon.component'; import { CartIconComponent } from '../../../../shared/components/cart-icon/cart-icon.component';
import { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.component'; import { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.component';
import { UserDropdownComponent } from './user-dropdown/user-dropdown.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({ @Component({
selector: 'app-store-header', selector: 'app-store-header',
imports: [ imports: [CartIconComponent, IconButtonComponent, RouterLink, UserDropdownComponent],
ButtonComponent,
CartIconComponent,
IconButtonComponent,
RouterLink,
ReactiveFormsModule,
UserDropdownComponent,
CategoryDropdownComponent,
],
templateUrl: './store-header.component.html', templateUrl: './store-header.component.html',
styleUrl: './store-header.component.scss', styleUrl: './store-header.component.scss'
}) })
export class StoreHeaderComponent { export class StoreHeaderComponent {
private readonly elementRef = inject(ElementRef<HTMLElement>); private readonly elementRef = inject(ElementRef<HTMLElement>);
@@ -31,39 +18,27 @@ export class StoreHeaderComponent {
readonly cartQuantity = input<number>(0); readonly cartQuantity = input<number>(0);
readonly user = input<AuthUser | null>(null); readonly user = input<AuthUser | null>(null);
readonly isAuthenticated = input<boolean>(false); 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 cartClick = output<void>();
readonly ticketsClick = output<void>();
readonly loginClick = output<void>(); readonly loginClick = output<void>();
readonly logoutClick = output<void>(); readonly logoutClick = output<void>();
readonly searchSubmit = output<string>();
readonly categorySelect = output<Category>();
protected readonly isUserDropdownOpen = signal(false); 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']) @HostListener('document:click', ['$event'])
protected onDocumentClick(event: MouseEvent): void { protected onDocumentClick(event: MouseEvent): void {
if (!this.isUserDropdownOpen() && !this.isCategoryDropdownOpen()) { if (!this.isUserDropdownOpen()) {
return; return;
} }
const target = event.target; const target = event.target;
if (target instanceof Node && !this.elementRef.nativeElement.contains(target)) { if (target instanceof Node && !this.elementRef.nativeElement.contains(target)) {
this.isUserDropdownOpen.set(false); this.isUserDropdownOpen.set(false);
this.isCategoryDropdownOpen.set(false);
} }
} }
@HostListener('document:keydown.escape') @HostListener('document:keydown.escape')
protected onEscapeKey(): void { protected onEscapeKey(): void {
this.isUserDropdownOpen.set(false); this.isUserDropdownOpen.set(false);
this.isCategoryDropdownOpen.set(false);
} }
protected toggleUserDropdown(): void { protected toggleUserDropdown(): void {
@@ -84,31 +59,4 @@ export class StoreHeaderComponent {
this.isUserDropdownOpen.set(false); this.isUserDropdownOpen.set(false);
this.logoutClick.emit(); 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,16 +5,12 @@
<div class="user-dropdown__divider"></div> <div class="user-dropdown__divider"></div>
@for (menu of accountNavigationMenus(); track menu.code) { <a routerLink="/mi-cuenta/datos-personales" class="user-dropdown__item" role="menuitem" data-testid="user-dropdown-account">
<a Mi cuenta
[routerLink]="menu.route" </a>
class="user-dropdown__item" <a routerLink="/mi-cuenta/compras" class="user-dropdown__item" role="menuitem" data-testid="user-dropdown-purchases">
role="menuitem" Mis compras
[attr.data-testid]="'user-dropdown-' + menu.code" </a>
>
{{ menu.label }}
</a>
}
<button <button
type="button" type="button"
class="user-dropdown__item" class="user-dropdown__item"

View File

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

View File

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

View File

@@ -1,7 +1,6 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'; import { describe, it, expect, beforeEach, vi } from 'vitest';
import { signal } from '@angular/core'; import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { provideRouter, Router } from '@angular/router'; import { provideRouter, Router } from '@angular/router';
import { of } from 'rxjs'; import { of } from 'rxjs';
@@ -9,13 +8,10 @@ import { of } from 'rxjs';
import { Tenant } from '../../services/tenant.interface'; import { Tenant } from '../../services/tenant.interface';
import { TenantService } from '../../services/tenant.service'; import { TenantService } from '../../services/tenant.service';
import { CartService } from '../../services/cart/cart.service'; import { CartService } from '../../services/cart/cart.service';
import { Cart } from '../../services/cart/cart.interface';
import { ToastService } from '../../services/toast.service'; import { ToastService } from '../../services/toast.service';
import { AuthService } from '../../services/auth/auth.service'; import { AuthService } from '../../services/auth/auth.service';
import { AuthUser } from '../../services/auth/auth.interfaces'; import { AuthUser } from '../../services/auth/auth.interfaces';
import { StoreLayoutComponent } from './store-layout.component'; import { StoreLayoutComponent } from './store-layout.component';
import { StoreHeaderComponent } from './store-header/store-header.component';
import { CheckoutService } from '../../services/checkout.service';
const tenant: Tenant = { const tenant: Tenant = {
id: 1, id: 1,
@@ -29,117 +25,14 @@ const tenant: Tenant = {
header_bg_color: '#313131', header_bg_color: '#313131',
footer_bg_color: '#313131', footer_bg_color: '#313131',
header_logo: 'https://example.com/header.png', header_logo: 'https://example.com/header.png',
footer_logo: 'https://example.com/footer.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',
icon: 'instagram',
name: 'Instagram',
url: 'https://instagram.com/test',
},
{
code: 'whatsapp',
icon: 'whatsapp',
name: 'WhatsApp',
url: 'https://wa.me/5493410000000',
},
{
code: 'facebook',
icon: 'facebook',
name: 'Facebook',
url: 'https://facebook.com/test',
},
{
code: 'linkedin',
icon: 'fa-brands fa-linkedin-in',
name: 'LinkedIn',
url: 'https://linkedin.com/company/test',
},
],
}; };
describe('StoreLayoutComponent', () => { 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 () => { beforeEach(async () => {
tenantState = signal<Tenant | null>(tenant); const tenantState = signal(tenant);
cartState = signal<Cart | null>(null); const authUserState = signal<AuthUser | null>(null);
authUserState = signal<AuthUser | null>(null);
const isAuthenticatedState = signal(false); const isAuthenticatedState = signal(false);
checkoutServiceStub = {
startCheckout: vi.fn().mockResolvedValue({ id: 55 }),
};
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [StoreLayoutComponent], imports: [StoreLayoutComponent],
@@ -151,20 +44,17 @@ describe('StoreLayoutComponent', () => {
tenant: tenantState.asReadonly(), tenant: tenantState.asReadonly(),
status: signal<'ready'>('ready').asReadonly(), status: signal<'ready'>('ready').asReadonly(),
getTenant: () => tenantState(), getTenant: () => tenantState(),
bootstrap: vi.fn().mockResolvedValue(undefined), bootstrap: vi.fn().mockResolvedValue(undefined)
}, }
}, },
{ {
provide: CartService, provide: CartService,
useValue: { useValue: {
cart: cartState.asReadonly(), cart: signal(null).asReadonly(),
loadCart: () => of({ id: 1, items: [], subtotal: '0' }), loadCart: () => of({ id: 1, items: [], subtotal: '0' }),
updateItemQuantity: vi updateItemQuantity: vi.fn().mockReturnValue(of({ data: { id: 1, items: [], subtotal: '0' } })),
.fn() removeItem: vi.fn().mockReturnValue(of({ data: { id: 1, items: [], subtotal: '0' } }))
.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, provide: ToastService,
@@ -173,22 +63,18 @@ describe('StoreLayoutComponent', () => {
info: vi.fn(), info: vi.fn(),
danger: vi.fn(), danger: vi.fn(),
success: vi.fn(), success: vi.fn(),
dismiss: vi.fn(), dismiss: vi.fn()
}, }
}, },
{ {
provide: AuthService, provide: AuthService,
useValue: { useValue: {
user: authUserState, user: authUserState,
isAuthenticated: isAuthenticatedState, isAuthenticated: isAuthenticatedState,
logout: vi.fn().mockReturnValue(of(void 0)), logout: vi.fn().mockReturnValue(of(void 0))
}, }
}, }
{ ]
provide: CheckoutService,
useValue: checkoutServiceStub,
},
],
}).compileComponents(); }).compileComponents();
}); });
@@ -213,14 +99,10 @@ describe('StoreLayoutComponent', () => {
expect(compiled.querySelector('[data-testid="store-header-brand-slot"]')).not.toBeNull(); 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('[data-testid="store-footer-brand-slot"]')).not.toBeNull();
expect( expect(
compiled.querySelector( compiled.querySelector(`app-store-header img.store-layout__brand-logo[src="${tenant.header_logo}"]`)
`app-store-header img.store-layout__brand-logo[src="${tenant.header_logo}"]`,
),
).not.toBeNull(); ).not.toBeNull();
expect( expect(
compiled.querySelector( compiled.querySelector(`app-store-footer img.store-layout__brand-logo[src="${tenant.footer_logo}"]`)
`app-store-footer img.store-layout__brand-logo[src="${tenant.footer_logo}"]`,
),
).not.toBeNull(); ).not.toBeNull();
expect(compiled.querySelector('.fa-cart-shopping')).not.toBeNull(); expect(compiled.querySelector('.fa-cart-shopping')).not.toBeNull();
expect(compiled.querySelector('.fa-circle-user')).not.toBeNull(); expect(compiled.querySelector('.fa-circle-user')).not.toBeNull();
@@ -230,127 +112,6 @@ describe('StoreLayoutComponent', () => {
expect(compiled.querySelector('.fa-linkedin-in')).not.toBeNull(); 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', () => { it('redirects to /login when user icon is clicked and user is not logged in', () => {
const authService = TestBed.inject(AuthService); const authService = TestBed.inject(AuthService);
const router = TestBed.inject(Router); const router = TestBed.inject(Router);
@@ -362,9 +123,7 @@ describe('StoreLayoutComponent', () => {
fixture.detectChanges(); fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement; const compiled = fixture.nativeElement as HTMLElement;
const userButton = compiled.querySelector( const userButton = compiled.querySelector('app-icon-button[variant="user"] button') as HTMLButtonElement;
'app-icon-button[variant="user"] button',
) as HTMLButtonElement;
expect(userButton).not.toBeNull(); expect(userButton).not.toBeNull();
userButton.click(); userButton.click();
fixture.detectChanges(); fixture.detectChanges();
@@ -382,33 +141,25 @@ describe('StoreLayoutComponent', () => {
(authService.user as any).set({ (authService.user as any).set({
id: 1, id: 1,
nombre_apellido: 'Ada Lovelace', nombre_apellido: 'Ada Lovelace',
email: 'ada@example.com', email: 'ada@example.com'
}); });
const fixture = TestBed.createComponent(StoreLayoutComponent); const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges(); fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement; const compiled = fixture.nativeElement as HTMLElement;
const userButton = compiled.querySelector( const userButton = compiled.querySelector('app-icon-button[variant="user"] button') as HTMLButtonElement;
'app-icon-button[variant="user"] button',
) as HTMLButtonElement;
expect(userButton).not.toBeNull(); expect(userButton).not.toBeNull();
userButton.click(); userButton.click();
fixture.detectChanges(); fixture.detectChanges();
expect(compiled.querySelector('app-user-dropdown')).not.toBeNull(); expect(compiled.querySelector('app-user-dropdown')).not.toBeNull();
expect(compiled.textContent).toContain('Ada Lovelace'); 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(); expect(router.navigate).not.toHaveBeenCalled();
}); });
it('logs out from the authenticated user dropdown, clears the cart and returns home', () => { it('logs out from the authenticated user dropdown', () => {
const authService = TestBed.inject(AuthService); const authService = TestBed.inject(AuthService);
const cartService = TestBed.inject(CartService);
const router = TestBed.inject(Router); const router = TestBed.inject(Router);
vi.spyOn(router, 'navigate'); vi.spyOn(router, 'navigate');
@@ -416,196 +167,45 @@ describe('StoreLayoutComponent', () => {
(authService.user as any).set({ (authService.user as any).set({
id: 1, id: 1,
nombre_apellido: 'Ada Lovelace', nombre_apellido: 'Ada Lovelace',
email: 'ada@example.com', email: 'ada@example.com'
}); });
const fixture = TestBed.createComponent(StoreLayoutComponent); const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges(); fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement; const compiled = fixture.nativeElement as HTMLElement;
const userButton = compiled.querySelector( const userButton = compiled.querySelector('app-icon-button[variant="user"] button') as HTMLButtonElement;
'app-icon-button[variant="user"] button',
) as HTMLButtonElement;
userButton.click(); userButton.click();
fixture.detectChanges(); fixture.detectChanges();
const logoutButton = compiled.querySelector( const logoutButton = compiled.querySelector('[data-testid="user-dropdown-logout"]') as HTMLButtonElement;
'[data-testid="user-dropdown-logout"]',
) as HTMLButtonElement;
expect(logoutButton).not.toBeNull(); expect(logoutButton).not.toBeNull();
logoutButton.click(); logoutButton.click();
fixture.detectChanges(); fixture.detectChanges();
expect(authService.logout).toHaveBeenCalled(); expect(authService.logout).toHaveBeenCalled();
expect(cartService.clearCart).toHaveBeenCalled(); expect(router.navigate).toHaveBeenCalledWith(['/login']);
expect(router.navigate).toHaveBeenCalledWith(['/']);
}); });
it('provides account actions from the footer', () => { it('redirects to /checkout when cart buy button is clicked', () => {
const authService = TestBed.inject(AuthService);
const cartService = TestBed.inject(CartService);
const router = TestBed.inject(Router); const router = TestBed.inject(Router);
vi.spyOn(router, 'navigate'); vi.spyOn(router, 'navigate');
const fixture = TestBed.createComponent(StoreLayoutComponent); const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges(); fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const footerLinks = Array.from(
compiled.querySelectorAll<HTMLAnchorElement>('app-store-footer a.store-layout__footer-link'),
);
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(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(cartService.clearCart).toHaveBeenCalled();
expect(router.navigate).toHaveBeenCalledWith(['/']);
});
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();
(fixture.componentInstance as any).isCartOpen.set(true); (fixture.componentInstance as any).isCartOpen.set(true);
fixture.detectChanges(); fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement; const compiled = fixture.nativeElement as HTMLElement;
const buyButton = Array.from(compiled.querySelectorAll('app-button button')).find( const buyButton = Array.from(compiled.querySelectorAll('app-button button'))
(button) => button.textContent?.trim() === 'Comprar', .find((button) => button.textContent?.trim() === 'Comprar') as HTMLButtonElement | undefined;
) as HTMLButtonElement | undefined;
expect(buyButton).toBeDefined(); expect(buyButton).toBeDefined();
buyButton!.click(); buyButton!.click();
await Promise.resolve();
fixture.detectChanges(); fixture.detectChanges();
expect(checkoutServiceStub.startCheckout).toHaveBeenCalledWith('test', { expect(router.navigate).toHaveBeenCalledWith(['/checkout']);
cart_id: 1,
});
expect(router.navigate).toHaveBeenCalledWith(['/checkout'], {
queryParams: { purchase: 55 },
});
expect((fixture.componentInstance as any).isCartOpen()).toBe(false); 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

@@ -2,39 +2,26 @@ import { Component, computed, inject, OnInit, signal } from '@angular/core';
import { Router, RouterOutlet } from '@angular/router'; import { Router, RouterOutlet } from '@angular/router';
import { TenantService } from '../../services/tenant.service'; import { TenantService } from '../../services/tenant.service';
import { CartService } from '../../services/cart/cart.service'; import { CartService } from '../../services/cart/cart.service';
import { StoreFooterComponent, StoreFooterSection } from './store-footer/store-footer.component'; import { StoreFooterComponent, StoreFooterSection, StoreSocialLink } from './store-footer/store-footer.component';
import { StoreHeaderComponent } from './store-header/store-header.component'; import { StoreHeaderComponent } from './store-header/store-header.component';
import { CartComponent, CartItemMock } from '../../../shared/components/cart/cart.component'; import { CartComponent, CartItemMock } from '../../../shared/components/cart/cart.component';
import { ButtonComponent } from '../../../shared/components/button/button.component'; import { ButtonComponent } from '../../../shared/components/button/button.component';
import { CartItem } from '../../services/cart/cart.interface'; import { CartItem } from '../../services/cart/cart.interface';
import { AuthService } from '../../services/auth/auth.service'; 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({ @Component({
selector: 'app-store-layout', selector: 'app-store-layout',
imports: [ imports: [RouterOutlet, StoreHeaderComponent, StoreFooterComponent, CartComponent, ButtonComponent],
RouterOutlet,
StoreHeaderComponent,
StoreFooterComponent,
CartComponent,
ButtonComponent,
],
templateUrl: './store-layout.component.html', templateUrl: './store-layout.component.html',
styleUrl: './store-layout.component.scss', styleUrl: './store-layout.component.scss'
}) })
export class StoreLayoutComponent implements OnInit { export class StoreLayoutComponent implements OnInit {
private readonly tenantService = inject(TenantService); private readonly tenantService = inject(TenantService);
private readonly cartService = inject(CartService); private readonly cartService = inject(CartService);
private readonly authService = inject(AuthService); private readonly authService = inject(AuthService);
private readonly checkoutService = inject(CheckoutService);
private readonly toastService = inject(ToastService);
private readonly router = inject(Router); private readonly router = inject(Router);
protected readonly isCartOpen = signal(false); protected readonly isCartOpen = signal(false);
protected readonly isCreatingPurchase = signal(false);
protected readonly cartSubtotal = computed(() => { protected readonly cartSubtotal = computed(() => {
const cart = this.cartService.cart(); const cart = this.cartService.cart();
@@ -70,14 +57,14 @@ export class StoreLayoutComponent implements OnInit {
} }
return { return {
cartItemId: item.id, productVariantId: item.product_variant_id,
imageUrl: item.product?.imagen ?? null, imageUrl: item.product?.imagen ?? null,
product, product,
originalPrice: null, originalPrice: null,
discountedPrice: parseFloat(item.precio_unitario), discountedPrice: parseFloat(item.precio_unitario),
discountPercentage: null, discountPercentage: null,
attributes, attributes,
quantity: item.cantidad, quantity: item.cantidad
}; };
} }
@@ -85,24 +72,6 @@ export class StoreLayoutComponent implements OnInit {
protected readonly tenant = this.tenantService.tenant; protected readonly tenant = this.tenantService.tenant;
protected readonly user = this.authService.user; protected readonly user = this.authService.user;
protected readonly isAuthenticated = this.authService.isAuthenticated; 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(() => { protected readonly cartQuantity = computed(() => {
const cart = this.cartService.cart(); const cart = this.cartService.cart();
@@ -112,7 +81,7 @@ export class StoreLayoutComponent implements OnInit {
ngOnInit(): void { ngOnInit(): void {
this.cartService.loadCart().subscribe({ this.cartService.loadCart().subscribe({
error: (err) => console.error('Error loading cart', err), error: (err) => console.error('Error loading cart', err)
}); });
} }
@@ -120,112 +89,46 @@ export class StoreLayoutComponent implements OnInit {
void this.router.navigate(['/login']); void this.router.navigate(['/login']);
} }
protected onSearch(term: string): void { protected onLogoutClick(): 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({ this.authService.logout().subscribe({
next: () => { next: () => void this.router.navigate(['/login']),
this.cartService.clearCart(); error: (err) => console.error('Error logging out', err)
this.isCartOpen.set(false);
if (!isLeavingCheckout) {
void this.router.navigate(['/']);
}
},
error: (err) => console.error('Error logging out', err),
}); });
} }
protected async onCheckoutClick(): Promise<void> { protected onCheckoutClick(): void {
if (this.isCreatingPurchase()) { this.isCartOpen.set(false);
return; void this.router.navigate(['/checkout']);
}
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 = computed<StoreFooterSection[]>(() => {
const helpSubmenues = findMenu(this.tenant()?.menues ?? [], 'help')?.submenues ?? [];
return [ protected readonly footerSections: StoreFooterSection[] = [
{ {
heading: 'Cuenta', heading: 'Cuenta',
links: [ links: ['Mi cuenta', 'Mis compras', 'Cerrar sesion']
...this.accountNavigationMenus().map((menu) => ({ },
label: menu.label, {
routerLink: menu.route, heading: 'Ayuda',
})), links: ['Contacto', 'Ayuda', 'Preguntas frecuentes']
{ label: 'Cerrar sesion', action: 'logout' }, }
], ];
},
{ protected readonly socialLinks: StoreSocialLink[] = [
heading: 'Ayuda', {
links: helpSubmenues.map((menu) => ({ label: 'Instagram',
label: menu.label, iconClass: 'fa-brands fa-instagram'
routerLink: menu.route, },
})), {
}, label: 'WhatsApp',
]; iconClass: 'fa-brands fa-whatsapp'
}); },
{
label: 'Facebook',
iconClass: 'fa-brands fa-facebook'
},
{
label: 'LinkedIn',
iconClass: 'fa-brands fa-linkedin-in'
}
];
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -37,25 +37,3 @@ export interface RegisterResponse {
message: string; message: string;
data: AuthUser; 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

@@ -6,7 +6,6 @@ import { HttpTestingController, provideHttpClientTesting } from '@angular/common
import { environment } from '../../../../environments/environment'; import { environment } from '../../../../environments/environment';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { CookieService } from '../cookie/cookie.service'; import { CookieService } from '../cookie/cookie.service';
import { TenantService } from '../tenant.service';
describe('AuthService', () => { describe('AuthService', () => {
let cookieStore: Record<string, string> = {}; let cookieStore: Record<string, string> = {};
@@ -19,12 +18,8 @@ describe('AuthService', () => {
function createCookieServiceStub() { function createCookieServiceStub() {
return { return {
get: (name: string) => cookieStore[name] || null, get: (name: string) => cookieStore[name] || null,
set: (name: string, value: string) => { set: (name: string, value: string) => { cookieStore[name] = value; },
cookieStore[name] = value; delete: (name: string) => { delete cookieStore[name]; }
},
delete: (name: string) => {
delete cookieStore[name];
}
}; };
} }
@@ -35,10 +30,6 @@ describe('AuthService', () => {
provideHttpClientTesting(), provideHttpClientTesting(),
AuthService, AuthService,
{ provide: CookieService, useValue: createCookieServiceStub() }, { provide: CookieService, useValue: createCookieServiceStub() },
{
provide: TenantService,
useValue: { getTenant: () => ({ codigo: 'tenant-test' }) }
},
TransferState TransferState
] ]
}); });
@@ -52,12 +43,6 @@ describe('AuthService', () => {
const request = httpController.expectOne(`${environment.url}login`); const request = httpController.expectOne(`${environment.url}login`);
expect(request.request.method).toBe('POST'); 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({ request.flush({
message: 'Sesion iniciada correctamente.', message: 'Sesion iniciada correctamente.',
token: 'plain-text-token', token: 'plain-text-token',
@@ -110,48 +95,7 @@ describe('AuthService', () => {
httpController.verify(); httpController.verify();
}); });
it('sends tenant and credentials when completing Google login', () => { it('clears the session when bootstrap receives 401 from /me', async () => {
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'; cookieStore['shopit.auth.token'] = 'expired-token';
TestBed.configureTestingModule({ TestBed.configureTestingModule({
@@ -188,10 +132,6 @@ describe('AuthService', () => {
provideHttpClientTesting(), provideHttpClientTesting(),
AuthService, AuthService,
{ provide: CookieService, useValue: createCookieServiceStub() }, { provide: CookieService, useValue: createCookieServiceStub() },
{
provide: TenantService,
useValue: { getTenant: () => ({ codigo: 'tenant-test' }) }
},
TransferState TransferState
] ]
}); });
@@ -212,7 +152,6 @@ describe('AuthService', () => {
const request = httpController.expectOne(`${environment.url}register`); const request = httpController.expectOne(`${environment.url}register`);
expect(request.request.method).toBe('POST'); expect(request.request.method).toBe('POST');
expect(request.request.body.tenant_codigo).toBe('tenant-test');
request.flush({ request.flush({
message: 'Usuario registrado correctamente.', message: 'Usuario registrado correctamente.',
data: { data: {
@@ -229,132 +168,13 @@ describe('AuthService', () => {
httpController.verify(); httpController.verify();
}); });
it('requests a password reset and exposes the final HTTP status', () => { it('clears local session even when logout request fails', () => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [ providers: [
provideHttpClient(), provideHttpClient(),
provideHttpClientTesting(), provideHttpClientTesting(),
AuthService, AuthService,
{ provide: CookieService, useValue: createCookieServiceStub() }, { 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: [
provideHttpClient(),
provideHttpClientTesting(),
AuthService,
{ provide: CookieService, useValue: createCookieServiceStub() },
{
provide: TenantService,
useValue: { getTenant: () => ({ codigo: 'tenant-test' }) }
},
TransferState TransferState
] ]
}); });
@@ -382,9 +202,9 @@ describe('AuthService', () => {
expect(logoutRequest.request.method).toBe('POST'); expect(logoutRequest.request.method).toBe('POST');
logoutRequest.flush({ message: 'Unauthorized' }, { status: 401, statusText: 'Unauthorized' }); logoutRequest.flush({ message: 'Unauthorized' }, { status: 401, statusText: 'Unauthorized' });
expect(service.token()).toBe('plain-text-token'); expect(service.token()).toBeNull();
expect(service.user()?.email).toBe('ada@example.com'); expect(service.user()).toBeNull();
expect(cookieStore['shopit.auth.token']).toBe('plain-text-token'); expect(cookieStore['shopit.auth.token']).toBeUndefined();
httpController.verify(); httpController.verify();
}); });

View File

@@ -1,5 +1,4 @@
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 { isPlatformBrowser, isPlatformServer } from '@angular/common';
import { computed, inject, Injectable, PLATFORM_ID, signal, TransferState, makeStateKey } from '@angular/core'; import { computed, inject, Injectable, PLATFORM_ID, signal, TransferState, makeStateKey } from '@angular/core';
import { firstValueFrom, map, Observable, of, tap } from 'rxjs'; import { firstValueFrom, map, Observable, of, tap } from 'rxjs';
@@ -11,14 +10,9 @@ import {
LoginResponse, LoginResponse,
RegisterPayload, RegisterPayload,
RegisterResponse, RegisterResponse,
ResetPasswordPayload, UpdateProfilePayload
ResetPasswordAttemptResponse,
ResetPasswordResponse,
UpdateProfilePayload,
ValidateResetPasswordAttemptResponse
} from './auth.interfaces'; } from './auth.interfaces';
import { CookieService } from '../cookie/cookie.service'; import { CookieService } from '../cookie/cookie.service';
import { TenantService } from '../tenant.service';
const AUTH_TOKEN_COOKIE_KEY = 'shopit.auth.token'; const AUTH_TOKEN_COOKIE_KEY = 'shopit.auth.token';
const AUTH_USER_SSR_STATE_KEY = makeStateKey<AuthUser>('shopit.auth.user'); const AUTH_USER_SSR_STATE_KEY = makeStateKey<AuthUser>('shopit.auth.user');
@@ -28,11 +22,9 @@ const AUTH_USER_SSR_STATE_KEY = makeStateKey<AuthUser>('shopit.auth.user');
}) })
export class AuthService { export class AuthService {
private readonly http = inject(HttpClient); private readonly http = inject(HttpClient);
private readonly document = inject(DOCUMENT);
private readonly platformId = inject(PLATFORM_ID); private readonly platformId = inject(PLATFORM_ID);
private readonly cookieService = inject(CookieService); private readonly cookieService = inject(CookieService);
private readonly transferState = inject(TransferState); private readonly transferState = inject(TransferState);
private readonly tenantService = inject(TenantService);
private readonly userState = signal<AuthUser | null>(null); private readonly userState = signal<AuthUser | null>(null);
private readonly tokenState = signal<string | null>(null); private readonly tokenState = signal<string | null>(null);
@@ -42,96 +34,20 @@ export class AuthService {
readonly isAuthenticated = computed(() => this.tokenState() !== null); readonly isAuthenticated = computed(() => this.tokenState() !== null);
login(payload: LoginPayload): Observable<AuthUser> { login(payload: LoginPayload): Observable<AuthUser> {
const tenant = this.tenantService.getTenant(); return this.http.post<LoginResponse>(`${environment.url}login`, payload).pipe(
if (!tenant) { tap((response) => this.applyAuthenticatedState(response.token, response.user)),
throw new Error('No se pudo resolver el tenant activo.'); map((response) => response.user)
} );
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> { register(payload: RegisterPayload): Observable<RegisterResponse> {
const tenantCode = this.tenantService.getTenant()?.codigo; return this.http.post<RegisterResponse>(`${environment.url}register`, payload);
return this.http.post<RegisterResponse>(`${environment.url}register`, {
...payload,
...(tenantCode ? { tenant_codigo: tenantCode } : {})
});
}
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) {
throw new Error('No se pudo resolver el tenant activo.');
}
const apiUrl = new URL(environment.url);
const authorizationUrl = new URL('/auth/google/redirect', apiUrl.origin);
authorizationUrl.searchParams.set('tenant', tenant.codigo);
authorizationUrl.searchParams.set('return_url', this.document.location.origin);
this.document.location.assign(authorizationUrl.toString());
}
completeGoogleLogin(oauthCode: string): Observable<AuthUser> {
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> { 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> { logout(): Observable<void> {
@@ -140,7 +56,12 @@ export class AuthService {
return of(void 0); 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({
next: () => this.clearSession(),
error: () => this.clearSession()
})
);
} }
async bootstrap(): Promise<void> { async bootstrap(): Promise<void> {
@@ -159,18 +80,12 @@ export class AuthService {
try { try {
const user = await firstValueFrom(this.loadCurrentUser()); const user = await firstValueFrom(this.loadCurrentUser());
if (isPlatformServer(this.platformId)) { if (isPlatformServer(this.platformId)) {
this.transferState.set(AUTH_USER_SSR_STATE_KEY, user); this.transferState.set(AUTH_USER_SSR_STATE_KEY, user);
} }
} catch (error) { } catch (error) {
// An expired token is an expected state, including while rendering on the if (this.isUnauthorizedError(error)) {
// server. Do not let it reject the application initializer: Angular's SSR
// pipeline can otherwise attempt to serialize HttpErrorResponse internals
// as HTTP headers.
if (error instanceof HttpErrorResponse && error.status === 401) {
this.clearSession(); this.clearSession();
return; return;
} }
@@ -179,7 +94,9 @@ export class AuthService {
} }
loadCurrentUser(): Observable<AuthUser> { 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 { hydrateSession(): void {
@@ -198,4 +115,8 @@ export class AuthService {
this.userState.set(user); this.userState.set(user);
this.cookieService.set(AUTH_TOKEN_COOKIE_KEY, token); this.cookieService.set(AUTH_TOKEN_COOKIE_KEY, token);
} }
private isUnauthorizedError(error: unknown): error is HttpErrorResponse {
return error instanceof HttpErrorResponse && error.status === 401;
}
} }

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,3 @@
import { ApiPaginatedResponse } from '../api-paginated-response.interface';
export interface Product { export interface Product {
id: number; id: number;
category_id: number; category_id: number;
@@ -31,83 +29,22 @@ export interface ProductAttribute {
options: ProductAttributeOption[]; options: ProductAttributeOption[];
} }
export type InventoryPolicy = 'tracked' | 'unlimited'; export interface ProductVariant {
export interface CatalogItemVariant {
id: number; id: number;
stock_tecnico: number | null; cantidad_maxima: number;
minimum_use_date?: string | null; definitions: Record<string, string>;
maximum_use_date?: string | null;
effective_minimum_use_date?: string | null;
effective_maximum_use_date?: string | null;
values: Record<string, string>;
}
export interface SelectedCatalogItemVariant extends CatalogItemVariant {
images: string[]; images: string[];
} }
export interface CatalogItemDetail { export interface ProductVariantMap {
id: number; variant_id: number;
category_id: number | null; cantidad_maxima: number;
brand_id: number | null; attributes: Record<string, string>;
slug: string; }
nombre: string;
descripcion: string | null; export interface ProductDetail extends Product {
precio: string;
category: string | null;
brand: string | null;
inventory_policy: InventoryPolicy;
has_tickets: boolean;
minimum_use_date: string | null;
maximum_use_date: string | null;
attributes: ProductAttribute[]; attributes: ProductAttribute[];
variants: CatalogItemVariant[]; variants_map: ProductVariantMap[];
selected_variant?: SelectedCatalogItemVariant; variant: ProductVariant | null;
stock_tecnico?: number | null;
images?: string[];
} }
export type CatalogProductLayout = 'row' | 'column_with_image' | 'column_with_cart';
export type CatalogGroupLayout = 'paginated' | 'simple' | 'simple_vertical' | 'carousel';
export interface CatalogFeaturedItemVariant {
id: number;
stock_tecnico: number | null;
values: Record<string, string>;
}
export interface CatalogFeaturedItem {
id: number;
nombre: string;
descripcion?: string | null;
precio: number | string;
image?: string | null;
stock_tecnico?: number | null;
variants?: CatalogFeaturedItemVariant[];
}
export type CatalogFeaturedItems =
| ApiPaginatedResponse<CatalogFeaturedItem[]>
| CatalogFeaturedItem[];
export interface CatalogFeaturedGroup {
id: number;
title: string;
layout: CatalogProductLayout;
group_layout: CatalogGroupLayout;
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

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

View File

@@ -4,25 +4,14 @@ import { firstValueFrom } from 'rxjs';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
export interface UpdatePurchaseCustomerPayload { export interface CreatePurchasePayload {
cart_id: number;
dni: string; dni: string;
telefono: string; telefono: string;
nombre_apellido: string; nombre_apellido: string;
email: string; email: string;
} }
export type StartCheckoutPayload =
| {
cart_id: number;
}
| {
direct_item: {
catalog_item_id: number;
variant_id: number | null;
cantidad: number;
};
};
export interface PurchaseStatusResponse { export interface PurchaseStatusResponse {
status: string | null; status: string | null;
} }
@@ -35,7 +24,7 @@ export interface PurchaseSummaryResponse extends PurchaseStatusResponse {
export interface PurchaseDetailAttributeResponse { export interface PurchaseDetailAttributeResponse {
name: string; name: string;
value: string | number | boolean | null; value: string | null;
} }
export interface PurchaseDetailItemResponse { export interface PurchaseDetailItemResponse {
@@ -43,15 +32,16 @@ export interface PurchaseDetailItemResponse {
quantity: number; quantity: number;
unit_price: string; unit_price: string;
line_total: string; line_total: string;
source_catalog_item_id: number | null; product: {
source_variant_id: number | null; id: number;
item_details: {
nombre: string; nombre: string;
descripcion: string | null;
slug: string; slug: string;
imagen: string | null; imagen: string | null;
} | null;
variant: {
id: number;
attributes: PurchaseDetailAttributeResponse[]; attributes: PurchaseDetailAttributeResponse[];
}; } | null;
} }
export interface PurchaseDetailResponse extends PurchaseStatusResponse { export interface PurchaseDetailResponse extends PurchaseStatusResponse {
@@ -62,36 +52,28 @@ export interface PurchaseDetailResponse extends PurchaseStatusResponse {
created_at: string | null; created_at: string | null;
payment_method: string | null; payment_method: string | null;
dni: string | null; dni: string | null;
transfer_payer_dni: string | null; payer_dni: string | null;
telefono: string | null; telefono: string | null;
nombre_apellido: string | null; nombre_apellido: string | null;
email: string | null; email: string | null;
items_source: 'purchase'; items_source: 'purchase' | 'cart' | null;
items: PurchaseDetailItemResponse[]; items: PurchaseDetailItemResponse[];
tickets_count?: number;
has_generated_tickets?: boolean;
subtotal: string; subtotal: string;
total: string; total: string;
} }
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root'
}) })
export class CheckoutService { export class CheckoutService {
private readonly http = inject(HttpClient); private readonly http = inject(HttpClient);
async startCheckout( async createPurchase(tenantCode: string, payload: CreatePurchasePayload): Promise<{ id: number }> {
tenantCode: string,
payload: StartCheckoutPayload,
): Promise<PurchaseDetailResponse> {
const response = await firstValueFrom( const response = await firstValueFrom(
this.http.post<{ data?: PurchaseDetailResponse } | PurchaseDetailResponse>( this.http.post<{ data?: { id: number }; id?: number }>(`${environment.url}tenants/${tenantCode}/compras`, payload)
`${environment.url}tenants/${tenantCode}/compras/start-checkout`,
payload,
),
); );
const purchase = this.extractResponseData<PurchaseDetailResponse>(response); const purchase = this.extractResponseData<{ id: number }>(response);
if (!purchase?.id) { if (!purchase?.id) {
throw new Error('Error al crear la compra.'); throw new Error('Error al crear la compra.');
@@ -104,17 +86,13 @@ export class CheckoutService {
tenantCode: string, tenantCode: string,
purchaseId: number, purchaseId: number,
method: 'qr' | 'transfer' | 'telepagos', method: 'qr' | 'transfer' | 'telepagos',
payerDni?: string, payerDni?: string
): Promise<any> { ): Promise<any> {
const payload: any = { method };
if (payerDni) {
payload.transfer_payer_dni = payerDni;
}
const response = await firstValueFrom( const response = await firstValueFrom(
this.http.post<any>( this.http.post<any>(`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/payment-intent`, {
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/payment-intent`, method,
payload, ...(method === 'transfer' ? { payer_dni: payerDni } : {})
), })
); );
if (!response) { if (!response) {
throw new Error('Error al generar la intención de pago.'); throw new Error('Error al generar la intención de pago.');
@@ -122,72 +100,9 @@ export class CheckoutService {
return response; 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> { async completePurchase(tenantCode: string, purchaseId: number): Promise<PurchaseStatusResponse> {
const response = await firstValueFrom( const response = await firstValueFrom(
this.http.post<{ data?: PurchaseStatusResponse } | PurchaseStatusResponse>( this.http.post<{ data?: PurchaseStatusResponse } | PurchaseStatusResponse>(`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/complete`, {})
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/complete`,
{},
),
); );
const purchase = this.extractResponseData<PurchaseStatusResponse>(response); const purchase = this.extractResponseData<PurchaseStatusResponse>(response);
@@ -197,68 +112,27 @@ export class CheckoutService {
} }
return { return {
status: purchase.status ?? null, status: purchase.status ?? null
}; };
} }
async submitPurchaseForReview( async getPurchases(tenantCode: string, status?: string): Promise<{ data: PurchaseSummaryResponse[] }> {
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`; let url = `${environment.url}tenants/${tenantCode}/compras`;
if (status) { if (status) {
url += `?status=${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) { if (!response) {
throw new Error('Error al obtener las compras.'); throw new Error('Error al obtener las compras.');
} }
return response; return response;
} }
async getPurchase( async getPurchase(tenantCode: string, purchaseId: string | number): Promise<PurchaseDetailResponse> {
tenantCode: string,
purchaseId: string | number,
): Promise<PurchaseDetailResponse> {
const response = await firstValueFrom( const response = await firstValueFrom(
this.http.get<{ data?: PurchaseDetailResponse } | PurchaseDetailResponse>( this.http.get<{ data?: PurchaseDetailResponse } | PurchaseDetailResponse>(`${environment.url}tenants/${tenantCode}/compras/${purchaseId}`)
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}`,
),
); );
const purchase = this.extractResponseData<PurchaseDetailResponse>(response); const purchase = this.extractResponseData<PurchaseDetailResponse>(response);

View File

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

View File

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

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

View File

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

View File

@@ -9,44 +9,6 @@ export interface BankAccount {
cvu: string; cvu: string;
} }
export interface HeroConfig {
background_image?: string | null;
title_html?: string | null;
description_html?: string | null;
button_text?: string | null;
button_href?: string | null;
}
export interface EventConfig {
title?: string | null;
location?: string | null;
dates?: string[] | null;
}
export interface Menu {
id: number;
code: string;
label: string;
parent_menu_code: string | null;
content_type: 'static' | 'dynamic';
route: string;
static_content?: unknown;
submenues: Menu[];
}
export interface SocialMedia {
code: string;
icon: string;
name: string;
url: string;
}
export interface Category {
id: number;
nombre: string;
subcategories: Category[];
}
export interface Tenant { export interface Tenant {
id: number; id: number;
codigo: string; codigo: string;
@@ -62,15 +24,7 @@ export interface Tenant {
footer_logo: string; footer_logo: string;
selected_bank_account_id?: number | null; selected_bank_account_id?: number | null;
selected_bank_account?: BankAccount | null; selected_bank_account?: BankAccount | null;
hero_config?: HeroConfig | null;
event_config?: EventConfig | null;
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>; export type TenantBootstrapResponse = ApiResponse<Tenant>;

View File

@@ -20,20 +20,7 @@ const tenant: Tenant = {
header_bg_color: '#313131', header_bg_color: '#313131',
footer_bg_color: '#313131', footer_bg_color: '#313131',
header_logo: 'https://example.com/header.png', 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 = { const tenantResponse: TenantBootstrapResponse = {

View File

@@ -3,7 +3,9 @@
<div class="card-body"> <div class="card-body">
<span class="eyebrow">Componentes reutilizables</span> <span class="eyebrow">Componentes reutilizables</span>
<h1>Button + Input</h1> <h1>Button + Input</h1>
<p class="lead mb-4">Pagina dedicada a probar variantes de los componentes compartidos.</p> <p class="lead mb-4">
Pagina dedicada a probar variantes de los componentes compartidos.
</p>
<div class="button-showcase"> <div class="button-showcase">
<div class="button-group"> <div class="button-group">
@@ -24,7 +26,9 @@
<app-button variant="secondary" [disabled]="true">Secondary</app-button> <app-button variant="secondary" [disabled]="true">Secondary</app-button>
<app-button variant="danger" [disabled]="true">Danger</app-button> <app-button variant="danger" [disabled]="true">Danger</app-button>
<app-button variant="danger-secondary" [disabled]="true"> Danger secondary </app-button> <app-button variant="danger-secondary" [disabled]="true">
Danger secondary
</app-button>
<app-button variant="cancel" [disabled]="true">Cancel</app-button> <app-button variant="cancel" [disabled]="true">Cancel</app-button>
</div> </div>
</div> </div>
@@ -56,8 +60,13 @@
<div class="button-group"> <div class="button-group">
<h2>Toasts Persistentes (No se van)</h2> <h2>Toasts Persistentes (No se van)</h2>
<div class="button-grid"> <div class="button-grid">
<app-button (click)="triggerPersistentToast('info')"> Info Persistente </app-button> <app-button (click)="triggerPersistentToast('info')">
<app-button variant="secondary" (click)="triggerPersistentToast('success')"> Info Persistente
</app-button>
<app-button
variant="secondary"
(click)="triggerPersistentToast('success')"
>
Success Persistente Success Persistente
</app-button> </app-button>
<app-button variant="danger" (click)="triggerPersistentToast('danger')"> <app-button variant="danger" (click)="triggerPersistentToast('danger')">
@@ -82,7 +91,9 @@
<app-button variant="danger-secondary" (click)="openWideModal()"> <app-button variant="danger-secondary" (click)="openWideModal()">
Abrir modal ancho Abrir modal ancho
</app-button> </app-button>
<app-button (click)="openSimpleModal()"> Abrir simple modal </app-button> <app-button (click)="openSimpleModal()">
Abrir simple modal
</app-button>
</div> </div>
<div class="modal-showcase__result" data-testid="modal-last-result"> <div class="modal-showcase__result" data-testid="modal-last-result">
{{ lastModalResult }} {{ lastModalResult }}
@@ -95,9 +106,9 @@
<div class="icon-button-showcase"> <div class="icon-button-showcase">
<h2>Icon Buttons</h2> <h2>Icon Buttons</h2>
<p class="text-muted mb-4"> <p class="text-muted mb-4">
Botones de icono pequenos con color base <code>#666666</code>, deshabilitados con Botones de icono pequenos con color base <code>#666666</code>,
<code>#A0A0A0</code> y estados hover/active con color de marca (a excepcion de trash que deshabilitados con <code>#A0A0A0</code> y estados hover/active con color
usa danger). de marca (a excepcion de trash que usa danger).
</p> </p>
<div class="icon-button-showcase__grid"> <div class="icon-button-showcase__grid">
@@ -144,7 +155,9 @@
<div class="icon-button-showcase mt-4"> <div class="icon-button-showcase mt-4">
<h2>Cart Icons</h2> <h2>Cart Icons</h2>
<p class="text-muted mb-4">Icono de carrito con badge opcional indicando cantidad.</p> <p class="text-muted mb-4">
Icono de carrito con badge opcional indicando cantidad.
</p>
<div class="icon-button-showcase__grid"> <div class="icon-button-showcase__grid">
<div class="icon-button-showcase__row"> <div class="icon-button-showcase__row">
@@ -187,7 +200,12 @@
<div class="input-field"> <div class="input-field">
<label for="number-input">Numero</label> <label for="number-input">Numero</label>
<app-input id="number-input" type="number" placeholder="0" [(value)]="numberValue" /> <app-input
id="number-input"
type="number"
placeholder="0"
[(value)]="numberValue"
/>
</div> </div>
<div class="input-field"> <div class="input-field">
@@ -202,7 +220,12 @@
<div class="input-field"> <div class="input-field">
<label for="user-input">Usuario</label> <label for="user-input">Usuario</label>
<app-input id="user-input" type="user" placeholder="Nombre de usuario" [(value)]="user" /> <app-input
id="user-input"
type="user"
placeholder="Nombre de usuario"
[(value)]="user"
/>
</div> </div>
<div class="input-field"> <div class="input-field">
@@ -227,12 +250,22 @@
<div class="input-field"> <div class="input-field">
<label for="amount-input">Moneda</label> <label for="amount-input">Moneda</label>
<app-input id="amount-input" type="currency" placeholder="0" [(value)]="amount" /> <app-input
id="amount-input"
type="currency"
placeholder="0"
[(value)]="amount"
/>
</div> </div>
<div class="input-field"> <div class="input-field">
<label for="percent-input">Porcentaje</label> <label for="percent-input">Porcentaje</label>
<app-input id="percent-input" type="percent" placeholder="0" [(value)]="percent" /> <app-input
id="percent-input"
type="percent"
placeholder="0"
[(value)]="percent"
/>
</div> </div>
<div class="input-field"> <div class="input-field">
@@ -280,7 +313,9 @@
/> />
</div> </div>
<app-button variant="secondary" (click)="clearFile()"> Limpiar archivo </app-button> <app-button variant="secondary" (click)="clearFile()">
Limpiar archivo
</app-button>
</div> </div>
</div> </div>
@@ -289,7 +324,8 @@
<div class="paginator-showcase"> <div class="paginator-showcase">
<h2>Paginator</h2> <h2>Paginator</h2>
<p class="text-muted mb-0"> <p class="text-muted mb-0">
Componente shared para navegar por paginas con primera, anterior, siguiente y ultima. Componente shared para navegar por paginas con primera, anterior,
siguiente y ultima.
</p> </p>
<app-paginator <app-paginator
[page]="currentPage" [page]="currentPage"
@@ -303,11 +339,11 @@
<div class="stepper-showcase"> <div class="stepper-showcase">
<h2>Form Stepper</h2> <h2>Form Stepper</h2>
<p class="text-muted mb-4"> <p class="text-muted mb-4">
Stepper vertical reutilizable. Cada paso maneja sus propios botones de navegacion. No se Stepper vertical reutilizable. Cada paso maneja sus propios botones de navegacion. No se puede avanzar si el paso no es valido.
puede avanzar si el paso no es valido.
</p> </p>
<app-stepper #demoStepper> <app-stepper #demoStepper>
<!-- Paso 1: Siempre valido --> <!-- Paso 1: Siempre valido -->
<app-step label="Informacion Personal" [isValid]="true"> <app-step label="Informacion Personal" [isValid]="true">
<div class="row g-3"> <div class="row g-3">
@@ -317,12 +353,7 @@
</div> </div>
<div class="col-md-6 input-field"> <div class="col-md-6 input-field">
<label for="stepper-email">Email</label> <label for="stepper-email">Email</label>
<app-input <app-input id="stepper-email" type="email" placeholder="mail@empresa.com" [(value)]="email" />
id="stepper-email"
type="email"
placeholder="mail@empresa.com"
[(value)]="email"
/>
</div> </div>
</div> </div>
<div class="d-flex justify-content-end mt-4"> <div class="d-flex justify-content-end mt-4">
@@ -337,12 +368,7 @@
<div class="row g-3"> <div class="row g-3">
<div class="col-md-6 input-field"> <div class="col-md-6 input-field">
<label for="stepper-password">Contraseña</label> <label for="stepper-password">Contraseña</label>
<app-input <app-input id="stepper-password" type="password-toggle" placeholder="********" [(value)]="password" />
id="stepper-password"
type="password-toggle"
placeholder="********"
[(value)]="password"
/>
</div> </div>
<div class="col-md-6 input-field"> <div class="col-md-6 input-field">
<label for="stepper-amount">Presupuesto mensual</label> <label for="stepper-amount">Presupuesto mensual</label>
@@ -351,7 +377,9 @@
</div> </div>
<div class="stepper-validity-control mt-3 d-flex align-items-center gap-3"> <div class="stepper-validity-control mt-3 d-flex align-items-center gap-3">
<span class="text-muted small"> Simular validez del paso: </span> <span class="text-muted small">
Simular validez del paso:
</span>
<app-button <app-button
[variant]="testStep2Valid ? 'secondary' : 'primary'" [variant]="testStep2Valid ? 'secondary' : 'primary'"
(click)="testStep2Valid = !testStep2Valid" (click)="testStep2Valid = !testStep2Valid"
@@ -390,49 +418,12 @@
</app-button> </app-button>
</div> </div>
</app-step> </app-step>
</app-stepper> </app-stepper>
</div> </div>
<hr class="section-divider" /> <hr class="section-divider" />
<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.
</p>
<app-accordion>
<app-accordion-item title="¿Cómo realizo una compra?" [open]="true">
Elegí el producto, seleccioná sus variantes y agregalo al carrito. Desde allí podés
revisar el pedido y continuar al checkout.
</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.
</app-accordion-item>
<app-accordion-item title="Item deshabilitado" [disabled]="true">
Este contenido no puede abrirse.
</app-accordion-item>
</app-accordion>
<h3 class="h5 mt-5 mb-3">Variante múltiple y flush</h3>
<app-accordion [allowMultiple]="true" [flush]="true">
<app-accordion-item title="Envíos">
La tienda informa los métodos y costos de envío disponibles para cada pedido.
</app-accordion-item>
<app-accordion-item title="Cambios y devoluciones">
Las condiciones de cambio y devolución son definidas por cada comercio.
</app-accordion-item>
</app-accordion>
</div>
<hr class="section-divider" />
<div class="cart-showcase"> <div class="cart-showcase">
<div class="cart-showcase__header"> <div class="cart-showcase__header">
<div> <div>
@@ -463,6 +454,7 @@
</div> </div>
} }
</div> </div>
</div> </div>
</section> </section>
@@ -612,73 +604,20 @@
</div> </div>
</section> </section>
<section class="component-demo card shadow-sm mt-4" data-testid="main-carousel">
<div class="card-body">
<span class="eyebrow">Reusable Component</span>
<h2 class="mb-3">Main Carousel</h2>
<p class="text-muted mb-4">
Carrusel panorámico compuesto únicamente por imágenes, con autoslide e indicadores.
</p>
<app-main-carousel
[images]="mainCarouselImages"
[imageAlts]="mainCarouselImageAlts"
[autoSlideInterval]="4000"
ariaLabel="Promociones de la tienda"
/>
</div>
</section>
<section class="component-demo card shadow-sm mt-4" data-testid="products-carousel">
<div class="card-body">
<span class="eyebrow">Reusable Component</span>
<h2 class="mb-3">Carousel de productos</h2>
<p class="text-muted mb-4">
El carousel controla únicamente el desplazamiento. Cada ítem se define externamente con un
template y en este ejemplo renderiza productos con imágenes mockeadas.
</p>
<ng-template #productCarouselItem let-product let-index="index">
<div class="carousel-product" [attr.data-carousel-index]="index">
<span class="carousel-product__mock-id">ITEM {{ product.id }}</span>
<app-product-column-with-image
[imageUrl]="product.imageUrl"
[title]="product.title"
[originalPrice]="product.originalPrice"
[discount]="product.discount"
[transferPrice]="product.transferPrice"
(buy)="onProductBuy(product.title)"
/>
</div>
</ng-template>
<app-carousel
[items]="carouselProducts"
[itemTemplate]="productCarouselItem"
[trackBy]="trackCarouselProduct"
[gap]="16"
[circular]="true"
ariaLabel="Productos con imágenes de prueba"
/>
</div>
</section>
<section class="component-demo card shadow-sm mt-4"> <section class="component-demo card shadow-sm mt-4">
<div class="card-body"> <div class="card-body">
<span class="eyebrow">Reusable Component</span> <span class="eyebrow">Reusable Component</span>
<h2 class="mb-4">Tarjetas de Producto (Column With Image)</h2> <h2 class="mb-4">Tarjetas de Producto (ProductCard)</h2>
<p class="text-muted mb-4"> <p class="text-muted mb-4">
Las tarjetas se adaptan al tamano del contenedor padre. A continuacion se presentan dentro Las tarjetas se adaptan al tamano del contenedor padre. A continuacion se
de una grilla de Bootstrap. presentan dentro de una grilla de Bootstrap.
</p> </p>
<div class="row"> <div class="row">
@for (product of testProducts; track product.title) { @for (product of testProducts; track product.title) {
<div class="col-12 col-md-6 col-lg-4 mb-4 d-flex align-items-stretch"> <div class="col-12 col-md-6 col-lg-4 mb-4 d-flex align-items-stretch">
<app-product-column-with-image <app-product-card
[imageUrl]="product.imageUrl" [imageUrl]="product.imageUrl"
[title]="product.title" [title]="product.title"
[originalPrice]="product.originalPrice" [originalPrice]="product.originalPrice"
@@ -692,75 +631,16 @@
</div> </div>
</section> </section>
<section class="component-demo card shadow-sm mt-4" data-testid="location-map-demo"> <app-store-section
<div class="card-body"> title="PRODUCTOS"
<span class="eyebrow">Reusable Component</span> class="component-demo store-section-demo mt-5"
<h2 class="mb-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>
<h2 class="mb-4">Tarjetas de Producto (Row Layout)</h2>
<p class="text-muted mb-4">Diseño horizontal que ocupa el 100% del ancho disponible.</p>
<div class="row">
<div class="col-12 mb-4 d-flex align-items-stretch">
<app-product-row-card
[title]="testRowProduct.title"
[description]="testRowProduct.description"
[price]="testRowProduct.price"
[variants]="testRowProduct.variants"
(buy)="onProductBuy(testRowProduct.title)"
(addToCart)="triggerToast('success')"
/>
</div>
</div>
</div>
</section>
<section class="component-demo card shadow-sm mt-4">
<div class="card-body">
<span class="eyebrow">Reusable Component</span>
<h2 class="mb-4">Tarjetas de Producto (Vertical With Cart Layout)</h2>
<p class="text-muted mb-4">
Diseño vertical con selector de cantidad y acciones de compra y carrito.
</p>
<div class="row justify-content-center justify-content-md-start">
<div class="col-12 col-sm-9 col-md-6 col-lg-5 col-xl-4 mb-4 d-flex align-items-stretch">
<app-product-vertical-with-cart-card
[title]="testVerticalWithCartProduct.title"
[description]="testVerticalWithCartProduct.description"
[price]="testVerticalWithCartProduct.price"
(buy)="onProductBuy(testVerticalWithCartProduct.title)"
(addToCart)="triggerToast('success')"
/>
</div>
</div>
</div>
</section>
<app-store-section title="PRODUCTOS" class="component-demo store-section-demo mt-5">
<div class="row"> <div class="row">
@for (product of testProducts; track product.title) { @for (product of testProducts; track product.title) {
<div <div
class="col-12 col-md-6 col-lg-4 mb-4 d-flex align-items-stretch store-section-demo__item" class="col-12 col-md-6 col-lg-4 mb-4 d-flex align-items-stretch store-section-demo__item"
> >
<app-product-column-with-image <app-product-card
[imageUrl]="product.imageUrl" [imageUrl]="product.imageUrl"
[title]="product.title" [title]="product.title"
[originalPrice]="product.originalPrice" [originalPrice]="product.originalPrice"
@@ -773,3 +653,4 @@
</div> </div>
</app-store-section> </app-store-section>
</main> </main>

View File

@@ -19,32 +19,6 @@
width: min(100%, 64rem); width: min(100%, 64rem);
} }
.carousel-product {
position: relative;
width: clamp(15rem, 30vw, 18rem);
height: 100%;
&__mock-id {
position: absolute;
top: 0.75rem;
left: 0.75rem;
z-index: 2;
padding: 0.35rem 0.55rem;
color: #fff;
font-size: 0.7rem;
font-weight: 700;
line-height: 1;
letter-spacing: 0.08em;
background: rgb(20 20 20 / 82%);
border-radius: 999px;
}
app-product-column-with-image {
display: block;
height: 100%;
}
}
.eyebrow { .eyebrow {
display: inline-block; display: inline-block;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
@@ -156,7 +130,7 @@ h1 {
} }
.store-section-demo__item { .store-section-demo__item {
app-product-column-with-image { app-product-card {
width: 100%; width: 100%;
} }
} }
@@ -181,6 +155,7 @@ h1 {
gap: 0.75rem; gap: 0.75rem;
} }
.file-field { .file-field {
display: grid; display: grid;
gap: 0.75rem; gap: 0.75rem;
@@ -193,10 +168,6 @@ h1 {
} }
@media (max-width: 767.98px) { @media (max-width: 767.98px) {
.carousel-product {
width: min(18rem, calc(100vw - 5rem));
}
.input-grid { .input-grid {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
} }
@@ -235,15 +206,11 @@ h1 {
padding: 1rem; padding: 1rem;
overflow: hidden; overflow: hidden;
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.05); box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.05);
transition: transition: transform 0.2s ease, box-shadow 0.2s ease;
transform 0.2s ease,
box-shadow 0.2s ease;
&:hover { &:hover {
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.05), 0 4px 12px rgba(0, 0, 0, 0.08);
inset 0 2px 4px rgba(0, 0, 0, 0.05),
0 4px 12px rgba(0, 0, 0, 0.08);
} }
} }
@@ -284,9 +251,7 @@ h1 {
border: 1px solid #dee2e6; border: 1px solid #dee2e6;
overflow: hidden; overflow: hidden;
background-color: #ffffff; background-color: #ffffff;
transition: transition: transform 0.2s ease, box-shadow 0.2s ease;
transform 0.2s ease,
box-shadow 0.2s ease;
&:hover { &:hover {
transform: translateY(-3px); transform: translateY(-3px);

View File

@@ -1,6 +1,9 @@
import { signal } from '@angular/core'; import { signal } from '@angular/core';
import { TestBed, getTestBed } from '@angular/core/testing'; import { TestBed, getTestBed } from '@angular/core/testing';
import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing'; import {
BrowserTestingModule,
platformBrowserTesting
} from '@angular/platform-browser/testing';
import { of } from 'rxjs'; import { of } from 'rxjs';
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
@@ -22,8 +25,7 @@ const tenant: Tenant = {
header_bg_color: '#313131', header_bg_color: '#313131',
footer_bg_color: '#313131', footer_bg_color: '#313131',
header_logo: 'https://example.com/header.png', header_logo: 'https://example.com/header.png',
footer_logo: 'https://example.com/footer.png', footer_logo: 'https://example.com/footer.png'
categories: [],
}; };
function createTenantServiceStub(currentTenant: Tenant | null) { function createTenantServiceStub(currentTenant: Tenant | null) {
@@ -34,7 +36,7 @@ function createTenantServiceStub(currentTenant: Tenant | null) {
tenant: tenantState.asReadonly(), tenant: tenantState.asReadonly(),
status: statusState.asReadonly(), status: statusState.asReadonly(),
getTenant: () => tenantState(), getTenant: () => tenantState(),
bootstrap: vi.fn().mockResolvedValue(undefined), bootstrap: vi.fn().mockResolvedValue(undefined)
}; };
} }
@@ -42,7 +44,7 @@ function createToastServiceStub() {
return { return {
success: vi.fn(), success: vi.fn(),
danger: vi.fn(), danger: vi.fn(),
info: vi.fn(), info: vi.fn()
}; };
} }
@@ -50,14 +52,17 @@ function createModalServiceStub() {
return { return {
open: vi.fn(), open: vi.fn(),
openConfirm: vi.fn().mockReturnValue(of(true)), openConfirm: vi.fn().mockReturnValue(of(true)),
openConfirmDelete: vi.fn().mockReturnValue(of(false)), openConfirmDelete: vi.fn().mockReturnValue(of(false))
}; };
} }
describe('ReutilizablesTestPageComponent', () => { describe('ReutilizablesTestPageComponent', () => {
beforeAll(() => { beforeAll(() => {
try { try {
getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting()); getTestBed().initTestEnvironment(
BrowserTestingModule,
platformBrowserTesting()
);
} catch { } catch {
// Test environment may already be initialized by another setup entrypoint. // Test environment may already be initialized by another setup entrypoint.
} }
@@ -74,17 +79,17 @@ describe('ReutilizablesTestPageComponent', () => {
providers: [ providers: [
{ {
provide: TenantService, provide: TenantService,
useValue: createTenantServiceStub(tenant), useValue: createTenantServiceStub(tenant)
}, },
{ {
provide: ToastService, provide: ToastService,
useValue: createToastServiceStub(), useValue: createToastServiceStub()
}, },
{ {
provide: ModalService, provide: ModalService,
useValue: modalServiceStub, useValue: modalServiceStub
}, }
], ]
}).compileComponents(); }).compileComponents();
const fixture = TestBed.createComponent(ReutilizablesTestPageComponent); const fixture = TestBed.createComponent(ReutilizablesTestPageComponent);
@@ -96,12 +101,12 @@ describe('ReutilizablesTestPageComponent', () => {
expect(sectionTitle?.textContent).toContain('Configuracion Tenant'); expect(sectionTitle?.textContent).toContain('Configuracion Tenant');
const headerImg = element.querySelector( const headerImg = element.querySelector(
'.tenant-logo-card img[alt="Header Logo"]', '.tenant-logo-card img[alt="Header Logo"]'
) as HTMLImageElement; ) as HTMLImageElement;
expect(headerImg?.src).toBe(tenant.header_logo); expect(headerImg?.src).toBe(tenant.header_logo);
const footerImg = element.querySelector( const footerImg = element.querySelector(
'.tenant-logo-card img[alt="Footer Logo"]', '.tenant-logo-card img[alt="Footer Logo"]'
) as HTMLImageElement; ) as HTMLImageElement;
expect(footerImg?.src).toBe(tenant.footer_logo); expect(footerImg?.src).toBe(tenant.footer_logo);
@@ -118,17 +123,17 @@ describe('ReutilizablesTestPageComponent', () => {
providers: [ providers: [
{ {
provide: TenantService, provide: TenantService,
useValue: createTenantServiceStub(tenant), useValue: createTenantServiceStub(tenant)
}, },
{ {
provide: ToastService, provide: ToastService,
useValue: createToastServiceStub(), useValue: createToastServiceStub()
}, },
{ {
provide: ModalService, provide: ModalService,
useValue: modalServiceStub, useValue: modalServiceStub
}, }
], ]
}).compileComponents(); }).compileComponents();
const fixture = TestBed.createComponent(ReutilizablesTestPageComponent); const fixture = TestBed.createComponent(ReutilizablesTestPageComponent);
@@ -137,117 +142,9 @@ describe('ReutilizablesTestPageComponent', () => {
const element = fixture.nativeElement as HTMLElement; const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('app-paginator')).not.toBeNull(); expect(element.querySelector('app-paginator')).not.toBeNull();
expect(element.querySelector('[data-testid="paginator-status"]')?.textContent?.trim()).toBe(
'1/8',
);
});
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],
providers: [
{
provide: TenantService,
useValue: createTenantServiceStub(tenant),
},
{
provide: ToastService,
useValue: createToastServiceStub(),
},
{
provide: ModalService,
useValue: createModalServiceStub(),
},
],
}).compileComponents();
const fixture = TestBed.createComponent(ReutilizablesTestPageComponent);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const carouselDemo = element.querySelector('[data-testid="products-carousel"]');
const carouselItems = carouselDemo?.querySelectorAll('[data-carousel-index]');
const productImages = carouselDemo?.querySelectorAll('app-product-column-with-image img');
expect(carouselDemo?.querySelector('app-carousel')).not.toBeNull();
expect(carouselItems).toHaveLength(6);
expect(productImages).toHaveLength(6);
expect(productImages?.[0].getAttribute('src')).toContain('/images/carousel-mochila-roja.webp');
expect(productImages?.[1].getAttribute('src')).toContain(
'/images/carousel-auriculares-amarillos.webp',
);
expect( expect(
new Set(Array.from(productImages ?? [], (image) => image.getAttribute('src'))).size, element.querySelector('[data-testid="paginator-status"]')?.textContent?.trim()
).toBe(6); ).toBe('1/8');
});
it('renders the main carousel demo with remote images', 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 element = fixture.nativeElement as HTMLElement;
const carouselDemo = element.querySelector('[data-testid="main-carousel"]');
const images = carouselDemo?.querySelectorAll('.main-carousel__image');
const indicators = carouselDemo?.querySelectorAll('.main-carousel__indicator');
expect(carouselDemo?.querySelector('app-main-carousel')).not.toBeNull();
expect(images).toHaveLength(3);
expect(indicators).toHaveLength(3);
expect(images?.[0].getAttribute('src')).toContain('images.unsplash.com');
fixture.destroy();
}); });
it('renders the modal showcase and opens the confirm demos from the buttons', async () => { it('renders the modal showcase and opens the confirm demos from the buttons', async () => {
@@ -257,34 +154,36 @@ describe('ReutilizablesTestPageComponent', () => {
providers: [ providers: [
{ {
provide: TenantService, provide: TenantService,
useValue: createTenantServiceStub(tenant), useValue: createTenantServiceStub(tenant)
}, },
{ {
provide: ToastService, provide: ToastService,
useValue: createToastServiceStub(), useValue: createToastServiceStub()
}, },
{ {
provide: ModalService, provide: ModalService,
useValue: modalServiceStub, useValue: modalServiceStub
}, }
], ]
}).compileComponents(); }).compileComponents();
const fixture = TestBed.createComponent(ReutilizablesTestPageComponent); const fixture = TestBed.createComponent(ReutilizablesTestPageComponent);
fixture.detectChanges(); fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement; const element = fixture.nativeElement as HTMLElement;
const buttons = Array.from(element.querySelectorAll('.button-group app-button button')); const buttons = Array.from(
element.querySelectorAll('.button-group app-button button')
);
const confirmButton = buttons.find((button) => const confirmButton = buttons.find((button) =>
button.textContent?.includes('Abrir confirm modal'), button.textContent?.includes('Abrir confirm modal')
) as HTMLButtonElement; ) as HTMLButtonElement;
const deleteButton = buttons.find((button) => const deleteButton = buttons.find((button) =>
button.textContent?.includes('Abrir confirm delete'), button.textContent?.includes('Abrir confirm delete')
) as HTMLButtonElement; ) as HTMLButtonElement;
expect(element.querySelector('[data-testid="modal-last-result"]')?.textContent).toContain( expect(
'Todavia no se abrio ningun modal.', element.querySelector('[data-testid="modal-last-result"]')?.textContent
); ).toContain('Todavia no se abrio ningun modal.');
confirmButton.click(); confirmButton.click();
fixture.detectChanges(); fixture.detectChanges();
@@ -294,17 +193,17 @@ describe('ReutilizablesTestPageComponent', () => {
expect(modalServiceStub.openConfirm).toHaveBeenCalledWith({ expect(modalServiceStub.openConfirm).toHaveBeenCalledWith({
title: 'Confirmar accion', title: 'Confirmar accion',
content: 'Caso base para verificar apertura, cierre y devolucion de resultado.', content: 'Caso base para verificar apertura, cierre y devolucion de resultado.',
confirmLabel: 'Confirmar', confirmLabel: 'Confirmar'
}); });
expect(modalServiceStub.openConfirmDelete).toHaveBeenCalledWith({ expect(modalServiceStub.openConfirmDelete).toHaveBeenCalledWith({
title: 'Eliminar producto', title: 'Eliminar producto',
content: content:
'Esta accion eliminara el producto del catalogo. Podras volver a crearlo manualmente.', 'Esta accion eliminara el producto del catalogo. Podras volver a crearlo manualmente.',
confirmLabel: 'Eliminar', confirmLabel: 'Eliminar'
}); });
expect(element.querySelector('[data-testid="modal-last-result"]')?.textContent).toContain( expect(
'Resultado: false', element.querySelector('[data-testid="modal-last-result"]')?.textContent
); ).toContain('Resultado: false');
}); });
it('updates the visible result when the confirm modal resolves to true', async () => { it('updates the visible result when the confirm modal resolves to true', async () => {
@@ -314,17 +213,17 @@ describe('ReutilizablesTestPageComponent', () => {
providers: [ providers: [
{ {
provide: TenantService, provide: TenantService,
useValue: createTenantServiceStub(tenant), useValue: createTenantServiceStub(tenant)
}, },
{ {
provide: ToastService, provide: ToastService,
useValue: createToastServiceStub(), useValue: createToastServiceStub()
}, },
{ {
provide: ModalService, provide: ModalService,
useValue: modalServiceStub, useValue: modalServiceStub
}, }
], ]
}).compileComponents(); }).compileComponents();
const fixture = TestBed.createComponent(ReutilizablesTestPageComponent); const fixture = TestBed.createComponent(ReutilizablesTestPageComponent);
@@ -332,14 +231,16 @@ describe('ReutilizablesTestPageComponent', () => {
const element = fixture.nativeElement as HTMLElement; const element = fixture.nativeElement as HTMLElement;
const confirmButton = Array.from( const confirmButton = Array.from(
element.querySelectorAll('.button-group app-button button'), element.querySelectorAll('.button-group app-button button')
).find((button) => button.textContent?.includes('Abrir confirm modal')) as HTMLButtonElement; ).find((button) =>
button.textContent?.includes('Abrir confirm modal')
) as HTMLButtonElement;
confirmButton.click(); confirmButton.click();
fixture.detectChanges(); fixture.detectChanges();
expect(element.querySelector('[data-testid="modal-last-result"]')?.textContent).toContain( expect(
'Resultado: true', element.querySelector('[data-testid="modal-last-result"]')?.textContent
); ).toContain('Resultado: true');
}); });
}); });

View File

@@ -1,36 +1,20 @@
import { Component, inject } from '@angular/core'; import { Component, inject } from '@angular/core';
import { ButtonComponent } from '../../../../shared/components/button/button.component'; import { ButtonComponent } from '../../../../shared/components/button/button.component';
import { CartComponent, CartItemMock } from '../../../../shared/components/cart/cart.component'; import {
CartComponent,
CartItemMock
} from '../../../../shared/components/cart/cart.component';
import { CartIconComponent } from '../../../../shared/components/cart-icon/cart-icon.component'; import { CartIconComponent } from '../../../../shared/components/cart-icon/cart-icon.component';
import { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.component'; import { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.component';
import { InputComponent } from '../../../../shared/components/input/input.component'; import { InputComponent } from '../../../../shared/components/input/input.component';
import { PaginatorComponent } from '../../../../shared/components/paginator/paginator.component'; import { PaginatorComponent } from '../../../../shared/components/paginator/paginator.component';
import { ProductColumnWithImageComponent } from '../../../../shared/components/product-column-with-image/product-column-with-image.component'; import { ProductCardComponent } from '../../../../shared/components/product-card/product-card.component';
import { ProductRowCardComponent } from '../../../../shared/components/product-row-card/product-row-card.component';
import { ProductVerticalWithCartCardComponent } from '../../../../shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component';
import { StoreSectionComponent } from '../../../../shared/components/store-section/store-section.component'; import { StoreSectionComponent } from '../../../../shared/components/store-section/store-section.component';
import { ModalService } from '../../../../core/services/modal.service'; import { ModalService } from '../../../../core/services/modal.service';
import { TenantService } from '../../../../core/services/tenant.service'; import { TenantService } from '../../../../core/services/tenant.service';
import { ToastService } from '../../../../core/services/toast.service'; import { ToastService } from '../../../../core/services/toast.service';
import { StepperComponent } from '../../../../shared/components/stepper/stepper.component'; import { StepperComponent } from '../../../../shared/components/stepper/stepper.component';
import { StepComponent } from '../../../../shared/components/stepper/step.component'; import { StepComponent } from '../../../../shared/components/stepper/step.component';
import { CarouselComponent } from '../../../../shared/components/carousel/carousel.component';
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;
title: string;
originalPrice: number;
discount: number | null;
transferPrice: number;
imageUrl: string;
}
@Component({ @Component({
selector: 'app-reutilizables-test-page', selector: 'app-reutilizables-test-page',
@@ -39,22 +23,15 @@ interface CarouselProductMock {
CartComponent, CartComponent,
InputComponent, InputComponent,
PaginatorComponent, PaginatorComponent,
ProductColumnWithImageComponent, ProductCardComponent,
ProductRowCardComponent,
ProductVerticalWithCartCardComponent,
StoreSectionComponent, StoreSectionComponent,
IconButtonComponent, IconButtonComponent,
CartIconComponent, CartIconComponent,
StepperComponent, StepperComponent,
StepComponent, StepComponent
CarouselComponent,
MainCarouselComponent,
AccordionComponent,
AccordionItemComponent,
LocationMapComponent,
], ],
templateUrl: './reutilizables-test-page.component.html', templateUrl: './reutilizables-test-page.component.html',
styleUrl: './reutilizables-test-page.component.scss', styleUrl: './reutilizables-test-page.component.scss'
}) })
export class ReutilizablesTestPageComponent { export class ReutilizablesTestPageComponent {
private readonly tenantService = inject(TenantService); private readonly tenantService = inject(TenantService);
@@ -84,39 +61,6 @@ export class ReutilizablesTestPageComponent {
protected lastModalResult = 'Todavia no se abrio ningun modal.'; protected lastModalResult = 'Todavia no se abrio ningun modal.';
protected readonly paginatorTotalPages = 8; protected readonly paginatorTotalPages = 8;
protected readonly cartBackgroundColor = '#ffffff'; 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',
'https://images.unsplash.com/photo-1483985988355-763728e1935b?auto=format&fit=crop&w=1600&q=80',
];
protected readonly mainCarouselImageAlts = [
'Interior de una tienda de indumentaria',
'Colección de moda en exteriores',
'Percheros con prendas de distintos colores',
];
protected readonly testProducts = [ protected readonly testProducts = [
{ {
@@ -124,97 +68,24 @@ export class ReutilizablesTestPageComponent {
originalPrice: 95000, originalPrice: 95000,
discount: 20, discount: 20,
transferPrice: 74800, transferPrice: 74800,
imageUrl: '/images/pantalon-scuba.png', imageUrl: '/images/pantalon-scuba.png'
}, },
{ {
title: 'Zapatillas de running azul', title: 'Zapatillas de running azul',
originalPrice: 120000, originalPrice: 120000,
discount: 15, discount: 15,
transferPrice: 98000, transferPrice: 98000,
imageUrl: '/images/zapatillas-running.png', imageUrl: '/images/zapatillas-running.png'
}, },
{ {
title: 'Remera basica blanca (Sin Descuento)', title: 'Remera basica blanca (Sin Descuento)',
originalPrice: 32000, originalPrice: 32000,
discount: null, discount: null,
transferPrice: 30000, transferPrice: 30000,
imageUrl: null, imageUrl: null
}, }
]; ];
protected readonly carouselProducts: CarouselProductMock[] = [
{
id: 1,
title: '01 · Mochila roja',
originalPrice: 85000,
discount: 20,
transferPrice: 68000,
imageUrl: '/images/carousel-mochila-roja.webp',
},
{
id: 2,
title: '02 · Auriculares amarillos',
originalPrice: 120000,
discount: 15,
transferPrice: 102000,
imageUrl: '/images/carousel-auriculares-amarillos.webp',
},
{
id: 3,
title: '03 · Reloj verde',
originalPrice: 96000,
discount: 10,
transferPrice: 86400,
imageUrl: '/images/carousel-reloj-verde.webp',
},
{
id: 4,
title: '04 · Cámara violeta',
originalPrice: 135000,
discount: null,
transferPrice: 128000,
imageUrl: '/images/carousel-camara-violeta.webp',
},
{
id: 5,
title: '05 · Zapatillas naranjas',
originalPrice: 110000,
discount: 25,
transferPrice: 82500,
imageUrl: '/images/carousel-zapatillas-naranjas.webp',
},
{
id: 6,
title: '06 · Campera azul',
originalPrice: 142000,
discount: 12,
transferPrice: 121000,
imageUrl: '/images/carousel-campera-azul.webp',
},
];
protected readonly trackCarouselProduct = (
_index: number,
product: CarouselProductMock,
): number => product.id;
protected readonly testRowProduct = {
title: 'ENTRADA GENERAL',
description:
'Acceso total al predio. No incluye acceso a estacionamiento. Niños menores de 5 años ingresan gratis.',
price: 10000,
variants: [
{ label: '10 de Octubre', value: '10-oct' },
{ label: '11 de Octubre', value: '11-oct' },
],
};
protected readonly testVerticalWithCartProduct = {
title: 'PANCHO',
description: 'Pancho con aderezo a elección.',
price: 11500,
};
protected readonly cartMockItems: CartItemMock[] = [ protected readonly cartMockItems: CartItemMock[] = [
{ {
imageUrl: null, imageUrl: null,
@@ -225,8 +96,8 @@ export class ReutilizablesTestPageComponent {
quantity: 1, quantity: 1,
attributes: [ attributes: [
{ label: 'Color', value: 'Beige' }, { label: 'Color', value: 'Beige' },
{ label: 'Talle', value: 'S' }, { label: 'Talle', value: 'S' }
], ]
}, },
{ {
imageUrl: null, imageUrl: null,
@@ -237,8 +108,8 @@ export class ReutilizablesTestPageComponent {
quantity: 1, quantity: 1,
attributes: [ attributes: [
{ label: 'Color', value: 'Beige' }, { label: 'Color', value: 'Beige' },
{ label: 'Talle', value: 'S' }, { label: 'Talle', value: 'S' }
], ]
}, },
{ {
imageUrl: null, imageUrl: null,
@@ -249,8 +120,8 @@ export class ReutilizablesTestPageComponent {
quantity: 1, quantity: 1,
attributes: [ attributes: [
{ label: 'Color', value: 'Negro' }, { label: 'Color', value: 'Negro' },
{ label: 'Talle', value: 'XL' }, { label: 'Talle', value: 'XL' }
], ]
}, },
{ {
imageUrl: null, imageUrl: null,
@@ -261,9 +132,9 @@ export class ReutilizablesTestPageComponent {
quantity: 2, quantity: 2,
attributes: [ attributes: [
{ label: 'Color', value: 'Gris' }, { label: 'Color', value: 'Gris' },
{ label: 'Talle', value: 'M' }, { label: 'Talle', value: 'M' }
], ]
}, }
]; ];
protected onProductBuy(productTitle: string): void { protected onProductBuy(productTitle: string): void {
@@ -291,32 +162,45 @@ export class ReutilizablesTestPageComponent {
protected triggerToast(type: 'success' | 'danger' | 'info'): void { protected triggerToast(type: 'success' | 'danger' | 'info'): void {
if (type === 'success') { if (type === 'success') {
this.toastService.success('Operacion exitosa. El producto se ha guardado correctamente.'); this.toastService.success(
'Operacion exitosa. El producto se ha guardado correctamente.'
);
} else if (type === 'danger') { } else if (type === 'danger') {
this.toastService.danger('Alerta de error. Hubo un problema al procesar la solicitud.'); this.toastService.danger(
'Alerta de error. Hubo un problema al procesar la solicitud.'
);
} else if (type === 'info') { } else if (type === 'info') {
this.toastService.info('Informacion del sistema: Hay una nueva actualizacion disponible.'); this.toastService.info(
'Informacion del sistema: Hay una nueva actualizacion disponible.'
);
} }
} }
protected triggerPersistentToast(type: 'success' | 'danger' | 'info'): void { protected triggerPersistentToast(type: 'success' | 'danger' | 'info'): void {
if (type === 'success') { if (type === 'success') {
this.toastService.success('Exito persistente. Esta alerta no se cerrara sola.', 0); this.toastService.success(
'Exito persistente. Esta alerta no se cerrara sola.',
0
);
} else if (type === 'danger') { } else if (type === 'danger') {
this.toastService.danger( this.toastService.danger(
'Error persistente. Por favor, atienda este problema y cierrelo manualmente.', 'Error persistente. Por favor, atienda este problema y cierrelo manualmente.',
0, 0
); );
} else if (type === 'info') { } else if (type === 'info') {
this.toastService.info('Informacion persistente: Mantendremos este aviso en pantalla.', 0); this.toastService.info(
'Informacion persistente: Mantendremos este aviso en pantalla.',
0
);
} }
} }
protected openBasicModal(): void { protected openBasicModal(): void {
this.openConfirmModal({ this.openConfirmModal({
title: 'Confirmar accion', title: 'Confirmar accion',
content: 'Caso base para verificar apertura, cierre y devolucion de resultado.', content:
confirmLabel: 'Confirmar', 'Caso base para verificar apertura, cierre y devolucion de resultado.',
confirmLabel: 'Confirmar'
}); });
} }
@@ -325,17 +209,18 @@ export class ReutilizablesTestPageComponent {
title: 'Eliminar producto', title: 'Eliminar producto',
content: content:
'Esta accion eliminara el producto del catalogo. Podras volver a crearlo manualmente.', 'Esta accion eliminara el producto del catalogo. Podras volver a crearlo manualmente.',
confirmLabel: 'Eliminar', confirmLabel: 'Eliminar'
}); });
} }
protected openLockedModal(): void { protected openLockedModal(): void {
this.openConfirmModal({ this.openConfirmModal({
title: 'Modal bloqueado', title: 'Modal bloqueado',
content: 'Este modal no se cierra tocando el backdrop ni con la tecla Escape.', content:
'Este modal no se cierra tocando el backdrop ni con la tecla Escape.',
confirmLabel: 'Entendido', confirmLabel: 'Entendido',
closeOnBackdrop: false, closeOnBackdrop: false,
closeOnEscape: false, closeOnEscape: false
}); });
} }
@@ -344,20 +229,18 @@ export class ReutilizablesTestPageComponent {
title: 'Modal ancho', title: 'Modal ancho',
content: 'Demuestra una variante visual mas amplia para contenido mas pesado.', content: 'Demuestra una variante visual mas amplia para contenido mas pesado.',
confirmLabel: 'Seguir', confirmLabel: 'Seguir',
size: 'xl', size: 'xl'
}); });
} }
protected openSimpleModal(): void { protected openSimpleModal(): void {
this.modalService this.modalService.openSimple({
.openSimple({ title: 'Mensaje del sistema',
title: 'Mensaje del sistema', content: 'Este es un mensaje simple del sistema que no requiere confirmación.',
content: 'Este es un mensaje simple del sistema que no requiere confirmación.', buttonLabel: 'Entendido'
buttonLabel: 'Entendido', }).subscribe(() => {
}) this.lastModalResult = 'Simple modal cerrado';
.subscribe(() => { });
this.lastModalResult = 'Simple modal cerrado';
});
} }
private openConfirmModal(config: Parameters<ModalService['openConfirm']>[0]): void { private openConfirmModal(config: Parameters<ModalService['openConfirm']>[0]): void {
@@ -366,9 +249,13 @@ export class ReutilizablesTestPageComponent {
}); });
} }
private openConfirmDelete(config: Parameters<ModalService['openConfirmDelete']>[0]): void { private openConfirmDelete(
config: Parameters<ModalService['openConfirmDelete']>[0]
): void {
this.modalService.openConfirmDelete(config).subscribe((confirmed) => { this.modalService.openConfirmDelete(config).subscribe((confirmed) => {
this.lastModalResult = `Resultado: ${confirmed}`; this.lastModalResult = `Resultado: ${confirmed}`;
}); });
} }
} }

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
.sidebar-layout { .account-layout {
display: flex; display: flex;
margin: 0 auto; margin: 0 auto;
gap: 100px; gap: 100px;

View File

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

View File

@@ -1,11 +1,8 @@
<div class="sidebar-container"> <div class="sidebar-container">
<h2 class="sidebar-title">{{ menu()?.label }}</h2> <h2 class="sidebar-title">MI CUENTA</h2>
<div class="divider"></div> <div class="divider"></div>
<nav class="sidebar-nav" aria-label="Secciones de mi cuenta"> <nav class="sidebar-nav">
@for (menu of submenues(); track menu.code) { <a routerLink="/mi-cuenta/datos-personales" routerLinkActive="active" class="nav-link">Datos Personales</a>
<a [routerLink]="menu.route" routerLinkActive="active" class="nav-link"> <a routerLink="/mi-cuenta/compras" routerLinkActive="active" class="nav-link">Mis compras</a>
{{ menu.label }} </nav>
</a> </div>
}
</nav>
</div>

View File

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

View File

@@ -1,85 +0,0 @@
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,9 +1,6 @@
import { Component, computed, inject } from '@angular/core'; import { Component } from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router'; import { RouterLink, RouterLinkActive } from '@angular/router';
import { findMenu } from '../../../../../../core/services/menu.utils';
import { TenantService } from '../../../../../../core/services/tenant.service';
@Component({ @Component({
selector: 'app-account-sidebar', selector: 'app-account-sidebar',
standalone: true, standalone: true,
@@ -11,11 +8,4 @@ import { TenantService } from '../../../../../../core/services/tenant.service';
templateUrl: './account-sidebar.html', templateUrl: './account-sidebar.html',
styleUrl: './account-sidebar.scss', 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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,109 +0,0 @@
<section class="tickets-page">
<h1 class="tickets-page__title">MIS TICKETS</h1>
<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>
}
@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">
<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) {
<app-ticket
[title]="ticket.name"
[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)"
/>
}
@if (inactiveTickets().length) {
<section class="tickets-list__inactive" aria-labelledby="inactive-tickets-title">
<header class="tickets-list__header tickets-list__header--inactive">
<h2 id="inactive-tickets-title">TICKETS INACTIVOS</h2>
</header>
@for (ticket of inactiveTickets(); track ticket.id) {
<app-ticket
[title]="ticket.name"
[ticketId]="ticket.id"
[date]="formatDate(ticket)"
[disabled]="true"
[expired]="ticket.is_expired"
/>
}
</section>
}
} @else {
<p class="tickets-list__empty">Aún no tenés tickets disponibles.</p>
}
</div>
</section>

View File

@@ -1,231 +0,0 @@
.tickets-page {
width: 100%;
max-width: 720px;
}
.tickets-page__title {
margin: 0 0 20px;
color: #a0a0a0;
font-size: 15px;
font-weight: 700;
}
.tickets-list {
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;
align-items: center;
justify-content: space-between;
padding-bottom: 12px;
border-bottom: 1px solid #dddddd;
}
.tickets-list__header--inactive {
justify-content: flex-start;
}
.tickets-list__header--inactive h2 {
margin: 0;
color: #a0a0a0;
font-size: 14px;
font-weight: 700;
}
.tickets-list__inactive {
margin-top: 44px;
}
.tickets-list__select-all {
display: flex;
align-items: center;
gap: 16px;
margin: 0;
color: #a0a0a0;
cursor: pointer;
font-size: 14px;
font-weight: 700;
}
.tickets-list__select-all input {
width: 21px;
height: 21px;
margin: 0;
accent-color: var(--tenant-primary, #009933);
}
.tickets-list__bulk-actions {
display: flex;
gap: 18px;
}
.tickets-list__bulk-action {
display: flex;
flex-direction: column;
align-items: center;
color: #a0a0a0;
font-size: 11px;
}
.tickets-list app-ticket {
border-bottom: 1px solid #dddddd;
}
.tickets-list__empty {
padding: 32px 0;
color: #666666;
text-align: center;
}
.tickets-list__skeleton-header {
display: flex;
min-height: 58px;
align-items: center;
justify-content: space-between;
padding-bottom: 12px;
border-bottom: 1px solid #dddddd;
}
.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

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

View File

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

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

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

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

@@ -1,165 +0,0 @@
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,17 +1,7 @@
@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">
<div <div class="checkout-page__stepper-col ">
class="checkout-page__stepper-col" <app-stepper #stepper>
[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-step label="Datos" [isValid]="isStep1Valid()">
<app-checkout-data-step <app-checkout-data-step
[form]="form" [form]="form"
@@ -22,53 +12,21 @@
<app-step label="Pago"> <app-step label="Pago">
<app-checkout-payment-step <app-checkout-payment-step
[paymentMethods]="paymentMethods" [purchaseId]="createdPurchaseId()"
[selectedPaymentMethod]="selectedPaymentMethod()"
[copiedTransferField]="copiedTransferField()"
[transferAccount]="transferAccount()"
[transferDni]="transferDni()"
[isGeneratingIntent]="isPaymentLoading()"
[qrData]="qrData()"
[qrPaymentStatus]="qrPaymentStatus()"
[isCheckingQrPayment]="isCheckingQrPayment()"
[qrPaymentAmount]="cartTotal()"
[whatsappUrl]="whatsappUrl()"
[transferValidationStatus]="transferValidationStatus()"
(paymentMethodChange)="selectPaymentMethod($event)"
(copyTransferValue)="copyTransferValue($event.field, $event.value)"
(cancelStep)="onCancel()" (cancelStep)="onCancel()"
(generateTransferIntent)="generateTransferIntent($event)"
(complete)="onComplete()" (complete)="onComplete()"
(retryQrPolling)="retryQrPolling()"
/> />
</app-step> </app-step>
</app-stepper> </app-stepper>
</div> </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"> <div class="checkout-page__cart-col">
<app-cart <app-cart
title="COMPRA"
[items]="mappedCartItems()" [items]="mappedCartItems()"
[subtotal]="cartSubtotal()" [subtotal]="cartSubtotal()"
[discount]="cartDiscount()" [discount]="cartDiscount()"
[total]="cartTotal()" [total]="cartTotal()"
[allowEditing]="
createdPurchase()?.status === 'created' || createdPurchase()?.status === 'pending_payment'
"
[allowRemove]="false"
[persistQuantityChanges]="false"
[editing]="isEditingItems()"
[editingDisabled]="isUpdatingItem() || isPreparingItemEdit()"
backgroundColor="transparent" backgroundColor="transparent"
(editingChange)="onEditingItemsChange($event)"
(itemQuantityChange)="onPurchaseItemQuantityChange($event)"
/> />
</div> </div>
</div> </div>
}

View File

@@ -12,57 +12,13 @@
min-width: 0; min-width: 0;
border-radius: 4px; border-radius: 4px;
min-height: 420px; 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 { &__cart-col {
max-height: 80vh; max-height: 80vh;
overflow: hidden; overflow: hidden;
display: flex; display: flex;
flex-direction: column; 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,492 +0,0 @@
import { signal } from '@angular/core';
import { getTestBed, TestBed } from '@angular/core/testing';
import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
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>;
isUpdating: ReturnType<typeof signal<boolean>>;
loadCart: ReturnType<typeof vi.fn>;
clearCart: ReturnType<typeof vi.fn>;
};
let routerStub: { navigate: ReturnType<typeof vi.fn> };
let routeQueryParamMap: ReturnType<typeof convertToParamMap>;
let authUserState: ReturnType<typeof signal>;
beforeAll(() => {
try {
getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
} catch {
// Test environment may already be initialized by another setup entrypoint.
}
});
beforeEach(async () => {
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({
id: 10,
tenant_codigo: 'tenant-test',
status: 'active',
items: [],
subtotal: '0.00',
}),
isUpdating: signal(false),
loadCart: vi.fn().mockReturnValue(of({})),
clearCart: vi.fn(),
};
cartServiceStub.clearCart.mockImplementation(() => {
cartServiceStub.cart.set({
id: null,
tenant_codigo: 'tenant-test',
status: 'active',
items: [],
subtotal: '0.00',
});
});
routerStub = { navigate: vi.fn() };
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: authUserState } },
{
provide: ActivatedRoute,
useValue: {
snapshot: {
get queryParamMap() {
return routeQueryParamMap;
},
},
},
},
{ provide: Router, useValue: routerStub },
],
})
.overrideComponent(CheckoutPageComponent, { set: { template: '' } })
.compileComponents();
});
afterEach(() => {
vi.useRealTimers();
TestBed.resetTestingModule();
});
function createComponent(): any {
const fixture = TestBed.createComponent(CheckoutPageComponent);
fixture.detectChanges();
fixture.componentInstance['createdPurchaseId'].set(25);
routerStub.navigate.mockClear();
return { fixture, component: fixture.componentInstance as any };
}
it('polls QR after five seconds and navigates only when payment is paid', async () => {
checkoutServiceStub.getPurchase
.mockResolvedValueOnce({ status: 'pending_payment' })
.mockResolvedValueOnce({ status: 'paid' });
const { component } = createComponent();
await component.selectPaymentMethod('qr');
expect(checkoutServiceStub.getPurchase).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(5_000);
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(1);
expect(routerStub.navigate).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(5_000);
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(2);
expect(cartServiceStub.clearCart).not.toHaveBeenCalled();
expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25]);
expect(checkoutServiceStub.generatePaymentIntent).toHaveBeenCalledTimes(1);
});
it('shows the QR verification state while each status request is in progress', async () => {
let resolvePurchase!: (purchase: { status: string }) => void;
checkoutServiceStub.getPurchase.mockReturnValue(
new Promise((resolve) => {
resolvePurchase = resolve;
}),
);
const { fixture, component } = createComponent();
const statusRequest = component.checkQrPayment(component.qrPollingRunId);
expect(component.isCheckingQrPayment()).toBe(true);
resolvePurchase({ status: 'pending_payment' });
await statusRequest;
expect(component.isCheckingQrPayment()).toBe(false);
fixture.destroy();
});
it('stops QR polling after 45 seconds and can restart it', async () => {
const { component } = createComponent();
await component.selectPaymentMethod('qr');
await vi.advanceTimersByTimeAsync(45_000);
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(10);
});
it('cancels QR polling when payment method changes or component is destroyed', async () => {
const first = createComponent();
await first.component.selectPaymentMethod('qr');
await first.component.selectPaymentMethod('transfer');
await vi.advanceTimersByTimeAsync(10_000);
expect(checkoutServiceStub.getPurchase).not.toHaveBeenCalled();
const second = createComponent();
await second.component.selectPaymentMethod('qr');
second.fixture.destroy();
await vi.advanceTimersByTimeAsync(10_000);
expect(checkoutServiceStub.getPurchase).not.toHaveBeenCalled();
});
it.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.submitPurchaseForReview).toHaveBeenCalledWith('tenant-test', 25);
expect(cartServiceStub.clearCart).not.toHaveBeenCalled();
expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25]);
});
it('shows a retryable state when transfer validation fails', async () => {
vi.spyOn(console, 'error').mockImplementation(() => undefined);
checkoutServiceStub.submitPurchaseForReview.mockRejectedValue(new Error('network error'));
const { component } = createComponent();
component.selectedPaymentMethod.set('transfer');
await component.onComplete();
expect(component.transferValidationStatus()).toBe('error');
expect(cartServiceStub.clearCart).not.toHaveBeenCalled();
expect(routerStub.navigate).not.toHaveBeenCalled();
});
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

@@ -1,40 +1,19 @@
import { import { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal, ViewChild } from '@angular/core';
ChangeDetectionStrategy,
Component,
computed,
inject,
OnDestroy,
OnInit,
signal,
ViewChild,
} from '@angular/core';
import { FormBuilder, Validators } from '@angular/forms'; import { FormBuilder, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router'; import { Router } from '@angular/router';
import { firstValueFrom, startWith } from 'rxjs'; import { startWith } from 'rxjs';
import { TenantService } from '../../../../core/services/tenant.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 { CartService } from '../../../../core/services/cart/cart.service';
import { BankAccount } from '../../../../core/services/tenant.interface'; import { TenantService } from '../../../../core/services/tenant.service';
import { CheckoutService, CreatePurchasePayload } from '../../../../core/services/checkout.service';
import { AuthService } from '../../../../core/services/auth/auth.service';
import { CartItem } from '../../../../core/services/cart/cart.interface';
import { CartComponent, CartItemMock } from '../../../../shared/components/cart/cart.component'; import { CartComponent, CartItemMock } from '../../../../shared/components/cart/cart.component';
import { StepComponent } from '../../../../shared/components/stepper/step.component'; import { StepComponent } from '../../../../shared/components/stepper/step.component';
import { StepperComponent } from '../../../../shared/components/stepper/stepper.component'; import { StepperComponent } from '../../../../shared/components/stepper/stepper.component';
import { CheckoutDataStepComponent } from './checkout-data-step.component'; import { CheckoutDataStepComponent } from './checkout-data-step.component';
import { CheckoutPaymentStepComponent } from './checkout-payment-step.component'; import { CheckoutPaymentStepComponent } from './checkout-payment-step.component';
import { import { CheckoutForm } from './checkout-page.models';
CheckoutForm,
PaymentMethod,
PaymentMethodOption,
QrPaymentStatus,
TransferAccount,
TransferField,
TransferValidationStatus,
} from './checkout-page.models';
@Component({ @Component({
selector: 'app-checkout-page', selector: 'app-checkout-page',
@@ -44,28 +23,19 @@ import {
StepperComponent, StepperComponent,
StepComponent, StepComponent,
CheckoutDataStepComponent, CheckoutDataStepComponent,
CheckoutPaymentStepComponent, CheckoutPaymentStepComponent
], ],
templateUrl: './checkout-page.component.html', templateUrl: './checkout-page.component.html',
styleUrl: './checkout-page.component.scss', styleUrl: './checkout-page.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class CheckoutPageComponent implements OnInit, OnDestroy { export class CheckoutPageComponent implements OnInit {
private readonly formBuilder = inject(FormBuilder); private readonly formBuilder = inject(FormBuilder);
private readonly route = inject(ActivatedRoute); private readonly cartService = inject(CartService);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly tenantService = inject(TenantService); private readonly tenantService = inject(TenantService);
private readonly checkoutService = inject(CheckoutService); private readonly checkoutService = inject(CheckoutService);
private readonly authService = inject(AuthService); private readonly authService = inject(AuthService);
private readonly cartService = inject(CartService);
private readonly qrPollingIntervalMs = 5_000;
private readonly qrPollingMaxAttempts = 9;
private qrPollingTimeoutId: ReturnType<typeof setTimeout> | null = null;
private qrPollingAttempts = 0;
private qrPollingRunId = 0;
private paymentMethodRequestId = 0;
private navigationStarted = false;
@ViewChild(StepperComponent) stepper!: StepperComponent; @ViewChild(StepperComponent) stepper!: StepperComponent;
@@ -73,58 +43,27 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
nombre: ['', [Validators.required]], nombre: ['', [Validators.required]],
email: ['', [Validators.required, Validators.email]], email: ['', [Validators.required, Validators.email]],
dni: ['', [Validators.required]], dni: ['', [Validators.required]],
telefono: ['', [Validators.required]], telefono: ['', [Validators.required]]
}); });
protected readonly createdPurchase = signal<PurchaseDetailResponse | null>(null);
protected readonly isLoadingPurchase = signal(true);
protected readonly checkoutStepIndex = signal(0);
protected readonly cartSubtotal = computed(() => { protected readonly cartSubtotal = computed(() => {
const purchase = this.createdPurchase(); const cart = this.cartService.cart();
return purchase ? parseFloat(purchase.subtotal) : 0; return cart ? parseFloat(cart.subtotal) : 0;
}); });
protected readonly cartDiscount = computed(() => 0); protected readonly cartDiscount = computed(() => 0);
protected readonly cartTotal = computed(() => { protected readonly cartTotal = computed(() => this.cartSubtotal() - this.cartDiscount());
const purchase = this.createdPurchase();
return purchase ? parseFloat(purchase.total) : this.cartSubtotal() - this.cartDiscount();
});
protected readonly mappedCartItems = computed<CartItemMock[]>(() => { protected readonly mappedCartItems = computed<CartItemMock[]>(() => {
const purchase = this.createdPurchase(); const cart = this.cartService.cart();
return purchase ? purchase.items.map((item) => this.mapPurchaseItemToMock(item)) : []; if (!cart || !cart.items) return [];
return cart.items.map((item) => this.mapCartItemToMock(item));
}); });
protected readonly isStep1Valid = signal(this.form.valid); protected readonly isStep1Valid = signal(this.form.valid);
protected readonly paymentMethods: ReadonlyArray<PaymentMethodOption> = [ protected readonly isCreatingPurchase = signal(false);
{ id: 'qr', label: 'QR' },
{ id: 'transfer', label: 'Transferencia' },
{ id: 'telepagos', label: 'TelePagos' },
];
protected readonly selectedPaymentMethod = signal<PaymentMethod>('qr');
protected readonly copiedTransferField = signal<TransferField | null>(null);
protected readonly transferAccount = signal<TransferAccount | null>(null);
protected readonly transferDni = signal<string>('');
protected readonly 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 createdPurchaseId = signal<number | null>(null);
protected readonly isGeneratingIntent = signal(false);
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() { constructor() {
this.form.statusChanges this.form.statusChanges
@@ -137,7 +76,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
nombre: user.nombre_apellido, nombre: user.nombre_apellido,
email: user.email, email: user.email,
dni: user.dni ?? '', dni: user.dni ?? '',
telefono: user.telefono ?? '', telefono: user.telefono ?? ''
}); });
this.form.controls.nombre.disable(); this.form.controls.nombre.disable();
this.form.controls.email.disable(); this.form.controls.email.disable();
@@ -145,474 +84,93 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
} }
ngOnInit(): void { ngOnInit(): void {
const purchaseId = Number(this.route.snapshot.queryParamMap.get('purchase')); this.cartService.loadCart().subscribe();
if (!Number.isInteger(purchaseId) || purchaseId <= 0) { }
void this.router.navigate(['/']);
return; 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() };
});
} }
void this.loadPurchase(purchaseId);
}
ngOnDestroy(): void {
this.stopQrPolling();
}
private mapPurchaseItemToMock(item: PurchaseDetailItemResponse): CartItemMock {
return { return {
cartItemId: item.id, productVariantId: item.product_variant_id,
imageUrl: item.item_details.imagen, imageUrl: item.product?.imagen ?? null,
product: item.item_details.nombre, product,
originalPrice: null, originalPrice: null,
discountedPrice: parseFloat(item.unit_price), discountedPrice: parseFloat(item.precio_unitario),
discountPercentage: null, discountPercentage: null,
attributes: item.item_details.attributes.map((attribute) => ({ attributes,
label: attribute.name, quantity: item.cantidad
value: attribute.value === null ? '' : String(attribute.value),
})),
quantity: item.quantity,
}; };
} }
protected async onEditingItemsChange(editing: boolean): Promise<void> { protected async onStep1Continue(): Promise<void> {
if (this.isUpdatingItem() || this.isPreparingItemEdit()) { if (this.form.invalid) return;
return;
}
if (!editing) { const tenant = this.tenantService.tenant();
this.isEditingItems.set(false); if (!tenant) return;
if (this.stepper?.currentStepIndex() === 1) { this.isCreatingPurchase.set(true);
void this.selectPaymentMethod(this.selectedPaymentMethod());
try {
const cart = this.cartService.cart();
if (!cart?.id) {
throw new Error('No hay un carrito activo para finalizar la compra.');
} }
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 formValue = this.form.getRawValue();
const purchase = await this.checkoutService.updateCustomerData(tenant.codigo, purchaseId, { const payload: CreatePurchasePayload = {
cart_id: cart.id,
dni: formValue.dni, dni: formValue.dni,
telefono: formValue.telefono, telefono: formValue.telefono,
email: formValue.email, email: formValue.email,
nombre_apellido: formValue.nombre, nombre_apellido: formValue.nombre,
}); };
this.createdPurchase.set(purchase);
const response = await this.checkoutService.createPurchase(tenant.codigo, payload);
this.createdPurchaseId.set(response.id);
this.stepper.next(); this.stepper.next();
// Auto trigger intent for default option
void this.selectPaymentMethod(this.selectedPaymentMethod());
} catch (error) { } catch (error) {
console.error('Failed to create purchase:', error); console.error('Failed to create purchase:', error);
// Here we could show an alert or toast // Here we could show an alert or toast
} finally { } finally {
this.isUpdatingPurchase.set(false); this.isCreatingPurchase.set(false);
} }
} }
protected async onCancel(): Promise<void> { protected onCancel(): void {
if (await this.canDeactivate()) { void this.router.navigate(['/']);
void this.router.navigate(['/']);
}
}
public async canDeactivate(): Promise<boolean> {
this.stopQrPolling();
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 || this.isEditingItems()) {
return;
}
this.stopQrPolling();
this.qrPaymentStatus.set('idle');
this.transferValidationStatus.set('idle');
this.selectedPaymentMethod.set(method);
const requestId = ++this.paymentMethodRequestId;
if (method === 'qr') {
this.qrData.set(null);
}
const purchaseId = this.createdPurchaseId();
const tenant = this.tenantService.tenant();
if (!purchaseId || !tenant || method === 'telepagos') {
return;
}
if (method === 'transfer') {
this.transferAccount.set(null);
return;
}
this.isGeneratingIntent.set(true);
try {
const response = await this.checkoutService.generatePaymentIntent(
tenant.codigo,
purchaseId,
method,
);
if (method === 'qr' && response.qr_data?.qr_code) {
if (requestId !== this.paymentMethodRequestId || this.selectedPaymentMethod() !== 'qr') {
return;
}
this.qrData.set(response.qr_data.qr_code);
this.markPurchasePendingPayment();
this.startQrPolling();
}
} catch (error) {
console.error('Failed to generate payment intent:', error);
} finally {
this.isGeneratingIntent.set(false);
}
}
protected async generateTransferIntent(dni: string): Promise<void> {
if (this.isEditingItems()) {
return;
}
const purchaseId = this.createdPurchaseId();
const tenant = this.tenantService.tenant();
if (!purchaseId || !tenant) {
return;
}
this.transferDni.set(dni);
this.transferValidationStatus.set('idle');
this.isGeneratingIntent.set(true);
try {
const response = await this.checkoutService.generatePaymentIntent(
tenant.codigo,
purchaseId,
'transfer',
dni,
);
if (response.transfer_data) {
this.markPurchasePendingPayment();
this.transferAccount.set({
titular: response.transfer_data.titular,
entidad: response.transfer_data.entidad,
cvu: response.transfer_data.cvu,
alias: response.transfer_data.alias,
});
}
} catch (error) {
console.error('Failed to generate transfer payment intent:', error);
} finally {
this.isGeneratingIntent.set(false);
}
}
protected async copyTransferValue(field: TransferField, value: string): Promise<void> {
if (!globalThis.navigator?.clipboard?.writeText) return;
try {
await globalThis.navigator.clipboard.writeText(value);
this.copiedTransferField.set(field);
setTimeout(() => {
if (this.copiedTransferField() === field) {
this.copiedTransferField.set(null);
}
}, 1800);
} catch {
this.copiedTransferField.set(null);
}
} }
protected async onComplete(): Promise<void> { protected async onComplete(): Promise<void> {
const purchaseId = this.createdPurchaseId(); const purchaseId = this.createdPurchaseId();
const tenant = this.tenantService.tenant(); const tenant = this.tenantService.tenant();
if ( if (!purchaseId || !tenant) {
!purchaseId ||
!tenant ||
this.navigationStarted ||
this.isEditingItems() ||
this.transferValidationStatus() === 'checking'
) {
return;
}
this.transferValidationStatus.set('checking');
try {
const purchase = await this.checkoutService.submitPurchaseForReview(
tenant.codigo,
purchaseId,
);
if (purchase.status === 'paid') {
this.navigateToPurchaseStatus(purchaseId);
return;
}
this.transferValidationStatus.set('error');
} catch (error) {
console.error('Failed to submit purchase for review:', error);
this.transferValidationStatus.set('error');
}
}
protected retryQrPolling(): void {
if (this.selectedPaymentMethod() === 'qr' && this.qrData()) {
this.startQrPolling();
}
}
private startQrPolling(): void {
this.stopQrPolling();
this.qrPollingAttempts = 0;
this.qrPaymentStatus.set('waiting');
const runId = this.qrPollingRunId;
this.scheduleQrPoll(runId);
}
private scheduleQrPoll(runId: number): void {
this.qrPollingTimeoutId = setTimeout(() => {
this.qrPollingTimeoutId = null;
void this.checkQrPayment(runId);
}, this.qrPollingIntervalMs);
}
private async checkQrPayment(runId: number): Promise<void> {
if (runId !== this.qrPollingRunId) {
return;
}
const purchaseId = this.createdPurchaseId();
const tenant = this.tenantService.tenant();
if (!purchaseId || !tenant || this.selectedPaymentMethod() !== 'qr') {
this.stopQrPolling();
return;
}
this.qrPollingAttempts += 1;
this.isCheckingQrPayment.set(true);
try {
const purchase = await this.checkoutService.getPurchase(tenant.codigo, purchaseId);
if (runId !== this.qrPollingRunId) {
return;
}
if (purchase.status === 'paid') {
this.handleConfirmedPayment(purchaseId);
return;
}
if (purchase.status === 'rejected' || purchase.status === 'cancelled') {
this.stopQrPolling();
this.qrPaymentStatus.set('failed');
return;
}
} catch (error) {
console.error('Failed to validate QR payment:', error);
} finally {
if (runId === this.qrPollingRunId) {
this.isCheckingQrPayment.set(false);
}
}
if (runId !== this.qrPollingRunId) {
return;
}
if (this.qrPollingAttempts >= this.qrPollingMaxAttempts) {
this.stopQrPolling();
this.qrPaymentStatus.set('timed_out');
return;
}
this.scheduleQrPoll(runId);
}
private stopQrPolling(): void {
this.qrPollingRunId += 1;
this.isCheckingQrPayment.set(false);
if (this.qrPollingTimeoutId !== null) {
clearTimeout(this.qrPollingTimeoutId);
this.qrPollingTimeoutId = null;
}
}
private markPurchasePendingPayment(): void {
this.createdPurchase.update((purchase) =>
purchase
? {
...purchase,
status: 'pending_payment',
}
: purchase,
);
}
private handleConfirmedPayment(purchaseId: number): void {
this.navigateToPurchaseStatus(purchaseId);
}
private navigateToPurchaseStatus(purchaseId: number): void {
if (this.navigationStarted) {
return;
}
this.navigationStarted = true;
this.stopQrPolling();
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; return;
} }
try { try {
const purchase = await this.checkoutService.getPurchase(tenant.codigo, purchaseId); await this.checkoutService.completePurchase(tenant.codigo, purchaseId);
this.cartService.clearCart();
if ( void this.router.navigate(['/checkout/status', purchaseId]);
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) { } catch (error) {
console.error('Failed to load purchase:', error); console.error('Failed to complete purchase:', error);
void this.router.navigate(['/']);
} }
} }
} }

View File

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

View File

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

View File

@@ -1,27 +1,27 @@
<div class="checkout-payment"> <div class="checkout-payment">
<div class="checkout-payment__content"> <div class="checkout-payment__content">
<section class="payment-methods" aria-labelledby="payment-methods-title"> <section class="payment-methods" aria-labelledby="payment-methods-title">
<h2 id="payment-methods-title" class="payment-methods__title"> <h2 id="payment-methods-title" class="payment-methods__title">Selecciona el metodo de pago</h2>
Selecciona el metodo de pago
</h2>
<div class="payment-methods__list" role="radiogroup" aria-label="Metodo de pago"> <div class="payment-methods__list" role="radiogroup" aria-label="Metodo de pago">
@for (method of paymentMethods(); track method.id) { @for (method of payment.paymentMethods; track method.id) {
<label class="payment-method" [class.is-selected]="selectedPaymentMethod() === method.id"> <label
class="payment-method"
[class.is-selected]="payment.selectedMethod() === method.id"
>
<input <input
class="payment-method__radio" class="payment-method__radio"
type="radio" type="radio"
name="payment-method" name="payment-method"
[value]="method.id" [value]="method.id"
[checked]="selectedPaymentMethod() === method.id" [checked]="payment.selectedMethod() === method.id"
(change)="selectPaymentMethod(method.id)" (change)="selectPaymentMethod(method.id)"
/> />
<span class="payment-method__label"> <span class="payment-method__label">
@if (method.id === 'telepagos') { @if (method.id === 'telepagos') {
<span class="telepagos-logo" aria-label="TelePagos"> <span class="telepagos-logo" aria-label="TelePagos">
<span class="telepagos-logo__tele">tele</span <span class="telepagos-logo__tele">tele</span><span class="telepagos-logo__pagos">pagos</span>
><span class="telepagos-logo__pagos">pagos</span>
</span> </span>
} @else { } @else {
{{ method.label }} {{ method.label }}
@@ -35,33 +35,47 @@
</section> </section>
<section class="payment-panel d-flex justify-content-end" aria-live="polite"> <section class="payment-panel d-flex justify-content-end" aria-live="polite">
@if (isGeneratingIntent()) { @if (payment.isLoading() && payment.selectedMethod() !== 'transfer') {
<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> <h3 class="payment-panel__title">Cargando información de pago...</h3>
</div> </div>
} @else if (selectedPaymentMethod() === 'qr') { } @else if (payment.selectedMethod() === 'qr') {
<app-checkout-payment-qr <app-qr-payment-content [qrData]="payment.qrData()" />
[qrData]="qrData()" } @else if (payment.selectedMethod() === 'transfer') {
[paymentStatus]="qrPaymentStatus()" <app-transfer-payment-content
[isCheckingPayment]="isCheckingQrPayment()" [transferAccount]="payment.transferAccount()"
[paymentAmount]="qrPaymentAmount()" [copiedTransferField]="payment.copiedTransferField()"
[whatsappUrl]="whatsappUrl()" [isLoading]="payment.isLoading()"
/> [requestError]="payment.transferRequestError()"
} @else if (selectedPaymentMethod() === 'transfer') { [initialPayerDni]="payment.payerDni()"
<app-checkout-payment-transfer
[transferAccount]="transferAccount()"
[transferDni]="transferDni()"
[copiedTransferField]="copiedTransferField()"
[validationStatus]="transferValidationStatus()"
[paymentAmount]="qrPaymentAmount()"
[whatsappUrl]="whatsappUrl()"
(copyTransferValue)="requestCopy($event.field, $event.value)" (copyTransferValue)="requestCopy($event.field, $event.value)"
(submitDni)="generateTransferIntent.emit($event)" (requestTransferData)="requestTransfer($event)"
(completePurchase)="complete.emit()"
/> />
} @else { } @else {
<app-checkout-payment-telepagos /> <app-telepagos-payment-content />
} }
</section> </section>
</div> </div>
<div class="checkout-payment__actions">
<app-button
type="button"
variant="cancel"
hostClass="checkout-payment__action-btn"
buttonClass="w-100"
[disabled]="true"
>
Cancelar
</app-button>
<app-button
type="button"
hostClass="checkout-payment__action-btn"
buttonClass="w-100"
[disabled]="!payment.canComplete()"
(click)="complete.emit()"
>
Finalizar compra
</app-button>
</div>
</div> </div>

View File

@@ -1,3 +1,5 @@
@use './components/payment-methods/payment-panel' as payment-panel;
.checkout-payment { .checkout-payment {
display: grid; display: grid;
gap: 1rem; gap: 1rem;
@@ -23,8 +25,8 @@
&__action-btn { &__action-btn {
width: 100%; width: 100%;
flex: 1 1 320px; flex: 1 1 290px;
max-width: 320px; max-width: 290px;
display: block; display: block;
} }
} }
@@ -110,29 +112,5 @@
.payment-panel { .payment-panel {
min-width: 0; min-width: 0;
&__card { @include payment-panel.card;
width: 100%;
max-width: 320px;
min-height: 269px;
padding: 1.5rem 1.75rem;
border-radius: 5px;
background: #ffffff;
color: #666666;
box-shadow: 0 0 0 1px #f1f1f1;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
&__title {
max-width: 18rem;
margin: 0;
color: #8a8a8a;
font-size: 13px;
font-weight: 700;
line-height: 1.35;
}
} }

View File

@@ -0,0 +1,102 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import { CheckoutPaymentFacade } from './checkout-payment.facade';
import { CheckoutPaymentStepComponent } from './checkout-payment-step.component';
import { PaymentMethod, PaymentMethodOption, TransferAccount } from './checkout-page.models';
describe('CheckoutPaymentStepComponent', () => {
const paymentMethods: ReadonlyArray<PaymentMethodOption> = [
{ id: 'qr', label: 'QR' },
{ id: 'transfer', label: 'Transferencia' },
{ id: 'telepagos', label: 'TelePagos' }
];
const transferAccount: TransferAccount = {
titular: 'Titular Test',
entidad: 'Entidad Test',
cvu: '0000000000000000000000',
alias: 'alias.test'
};
let fixture: ComponentFixture<CheckoutPaymentStepComponent>;
let payment: {
paymentMethods: ReadonlyArray<PaymentMethodOption>;
selectedMethod: ReturnType<typeof signal<PaymentMethod>>;
copiedTransferField: ReturnType<typeof signal<'cvu' | 'alias' | null>>;
transferAccount: ReturnType<typeof signal<TransferAccount | null>>;
payerDni: ReturnType<typeof signal<string>>;
transferRequestError: ReturnType<typeof signal<string | null>>;
qrData: ReturnType<typeof signal<string | null>>;
isLoading: ReturnType<typeof signal<boolean>>;
canComplete: ReturnType<typeof signal<boolean>>;
setPurchaseId: ReturnType<typeof vi.fn>;
selectMethod: ReturnType<typeof vi.fn>;
copyTransferValue: ReturnType<typeof vi.fn>;
requestTransferData: ReturnType<typeof vi.fn>;
};
beforeEach(async () => {
payment = {
paymentMethods,
selectedMethod: signal<PaymentMethod>('qr'),
copiedTransferField: signal<'cvu' | 'alias' | null>(null),
transferAccount: signal<TransferAccount | null>(transferAccount),
payerDni: signal('12345678'),
transferRequestError: signal<string | null>(null),
qrData: signal<string | null>('qr-test'),
isLoading: signal(false),
canComplete: signal(true),
setPurchaseId: vi.fn(),
selectMethod: vi.fn(),
copyTransferValue: vi.fn(),
requestTransferData: vi.fn()
};
await TestBed.configureTestingModule({
imports: [CheckoutPaymentStepComponent]
})
.overrideComponent(CheckoutPaymentStepComponent, {
set: { providers: [{ provide: CheckoutPaymentFacade, useValue: payment }] }
})
.compileComponents();
fixture = TestBed.createComponent(CheckoutPaymentStepComponent);
});
it.each([
['qr', 'app-qr-payment-content'],
['transfer', 'app-transfer-payment-content'],
['telepagos', 'app-telepagos-payment-content']
] as const)('renders the %s payment content', (method: PaymentMethod, selector: string) => {
payment.selectedMethod.set(method);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector(selector)).not.toBeNull();
});
it('delegates method selection to the payment facade', () => {
fixture.detectChanges();
const transferRadio = fixture.nativeElement.querySelector('input[value="transfer"]') as HTMLInputElement;
transferRadio.dispatchEvent(new Event('change'));
expect(payment.selectMethod).toHaveBeenCalledWith('transfer');
});
it('shows only the loading state while generating a QR intent', () => {
payment.selectedMethod.set('qr');
payment.isLoading.set(true);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Cargando información de pago...');
expect(fixture.nativeElement.querySelector('app-qr-payment-content')).toBeNull();
});
it('delegates transfer copy requests to the payment facade', () => {
payment.selectedMethod.set('transfer');
fixture.detectChanges();
fixture.nativeElement.querySelector('app-icon-button').click();
expect(payment.copyTransferValue).toHaveBeenCalledWith('cvu', transferAccount.cvu);
});
});

View File

@@ -1,50 +1,46 @@
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; import { ChangeDetectionStrategy, Component, effect, inject, input, output } from '@angular/core';
import { CheckoutPaymentQrComponent } from './components/checkout-payment-qr/checkout-payment-qr.component'; import { ButtonComponent } from '../../../../shared/components/button/button.component';
import { CheckoutPaymentTelepagosComponent } from './components/checkout-payment-telepagos/checkout-payment-telepagos.component'; import { QrPaymentContentComponent } from './components/payment-methods/qr-payment-content.component';
import { CheckoutPaymentTransferComponent } from './components/checkout-payment-transfer/checkout-payment-transfer.component'; import { TelepagosPaymentContentComponent } from './components/payment-methods/telepagos-payment-content.component';
import { import { TransferPaymentContentComponent } from './components/payment-methods/transfer-payment-content.component';
PaymentMethod, import { CheckoutPaymentFacade } from './checkout-payment.facade';
PaymentMethodOption, import { PaymentMethod, TransferField } from './checkout-page.models';
QrPaymentStatus,
TransferAccount,
TransferField,
TransferValidationStatus,
} from './checkout-page.models';
@Component({ @Component({
selector: 'app-checkout-payment-step', selector: 'app-checkout-payment-step',
standalone: true, standalone: true,
imports: [CheckoutPaymentQrComponent, CheckoutPaymentTelepagosComponent, CheckoutPaymentTransferComponent], imports: [
ButtonComponent,
QrPaymentContentComponent,
TelepagosPaymentContentComponent,
TransferPaymentContentComponent
],
templateUrl: './checkout-payment-step.component.html', templateUrl: './checkout-payment-step.component.html',
styleUrl: './checkout-payment-step.component.scss', styleUrl: './checkout-payment-step.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush,
providers: [CheckoutPaymentFacade]
}) })
export class CheckoutPaymentStepComponent { export class CheckoutPaymentStepComponent {
readonly paymentMethods = input.required<ReadonlyArray<PaymentMethodOption>>(); protected readonly payment = inject(CheckoutPaymentFacade);
readonly selectedPaymentMethod = input.required<PaymentMethod>(); readonly purchaseId = input<number | null>(null);
readonly copiedTransferField = input<TransferField | null>(null);
readonly transferAccount = input<TransferAccount | null>(null);
readonly transferDni = input<string>('');
readonly isGeneratingIntent = input<boolean>(false);
readonly qrData = input<string | null>(null);
readonly qrPaymentStatus = input<QrPaymentStatus>('idle');
readonly isCheckingQrPayment = input<boolean>(false);
readonly qrPaymentAmount = input<number>(0);
readonly whatsappUrl = input<string | null>(null);
readonly transferValidationStatus = input<TransferValidationStatus>('idle');
readonly paymentMethodChange = output<PaymentMethod>();
readonly copyTransferValue = output<{ field: TransferField; value: string }>();
readonly cancelStep = output<void>(); readonly cancelStep = output<void>();
readonly complete = output<void>(); readonly complete = output<void>();
readonly generateTransferIntent = output<string>();
constructor() {
effect(() => this.payment.setPurchaseId(this.purchaseId()));
}
protected selectPaymentMethod(method: PaymentMethod): void { protected selectPaymentMethod(method: PaymentMethod): void {
this.paymentMethodChange.emit(method); void this.payment.selectMethod(method);
} }
protected requestCopy(field: TransferField, value: string): void { protected requestCopy(field: TransferField, value: string): void {
this.copyTransferValue.emit({ field, value }); void this.payment.copyTransferValue(field, value);
}
protected requestTransfer(dni: string): void {
void this.payment.requestTransferData(dni);
} }
} }

View File

@@ -0,0 +1,150 @@
import { computed, effect, inject, Injectable, signal, untracked } from '@angular/core';
import { CartService } from '../../../../core/services/cart/cart.service';
import { CheckoutService } from '../../../../core/services/checkout.service';
import { TenantService } from '../../../../core/services/tenant.service';
import { ToastService } from '../../../../core/services/toast.service';
import { PaymentMethod, PaymentMethodOption, TransferAccount, TransferField } from './checkout-page.models';
@Injectable()
export class CheckoutPaymentFacade {
private readonly cartService = inject(CartService);
private readonly checkoutService = inject(CheckoutService);
private readonly tenantService = inject(TenantService);
private readonly toastService = inject(ToastService);
private readonly purchaseId = signal<number | null>(null);
private readonly isGeneratingIntent = signal(false);
readonly paymentMethods: ReadonlyArray<PaymentMethodOption> = [
{ id: 'qr', label: 'QR' },
{ id: 'transfer', label: 'Transferencia' },
{ id: 'telepagos', label: 'TelePagos' }
];
readonly selectedMethod = signal<PaymentMethod>('qr');
readonly copiedTransferField = signal<TransferField | null>(null);
readonly transferAccount = signal<TransferAccount | null>(null);
readonly payerDni = signal('');
readonly transferRequestError = signal<string | null>(null);
readonly qrData = signal<string | null>(null);
readonly isLoading = computed(() => this.isGeneratingIntent() || this.cartService.isUpdating());
readonly canComplete = computed(() =>
!this.isLoading()
&& (this.selectedMethod() !== 'transfer' || this.transferAccount() !== null)
);
constructor() {
effect(() => {
const cart = this.cartService.cart();
const purchaseId = this.purchaseId();
untracked(() => {
if (cart && purchaseId) void this.refreshSelectedMethod();
});
});
}
setPurchaseId(purchaseId: number | null): void {
this.purchaseId.set(purchaseId);
}
async selectMethod(method: PaymentMethod): Promise<void> {
this.selectedMethod.set(method);
await this.refreshSelectedMethod();
}
async requestTransferData(dni: string): Promise<void> {
const context = this.resolvePaymentContext();
if (!context) return;
this.isGeneratingIntent.set(true);
this.transferRequestError.set(null);
try {
const response = await this.checkoutService.generatePaymentIntent(
context.tenantCode,
context.purchaseId,
'transfer',
dni
);
if (!response.transfer_data) {
throw new Error('No pudimos obtener los datos de transferencia.');
}
this.payerDni.set(dni);
this.transferAccount.set({
titular: response.transfer_data.titular,
entidad: response.transfer_data.entidad,
cvu: response.transfer_data.cvu,
alias: response.transfer_data.alias
});
} catch (error) {
const message = this.resolveErrorMessage(
error,
'No pudimos obtener los datos de transferencia. Intentá nuevamente.'
);
this.transferRequestError.set(message);
this.toastService.danger(message);
} finally {
this.isGeneratingIntent.set(false);
}
}
async copyTransferValue(field: TransferField, value: string): Promise<void> {
if (!globalThis.navigator?.clipboard?.writeText) return;
try {
await globalThis.navigator.clipboard.writeText(value);
this.copiedTransferField.set(field);
setTimeout(() => {
if (this.copiedTransferField() === field) this.copiedTransferField.set(null);
}, 1800);
} catch {
this.copiedTransferField.set(null);
}
}
private async refreshSelectedMethod(): Promise<void> {
const method = this.selectedMethod();
if (method === 'transfer') {
if (this.transferAccount() && this.payerDni()) {
await this.requestTransferData(this.payerDni());
}
return;
}
if (method === 'telepagos') return;
const context = this.resolvePaymentContext();
if (!context) return;
this.isGeneratingIntent.set(true);
try {
const response = await this.checkoutService.generatePaymentIntent(
context.tenantCode,
context.purchaseId,
method
);
if (response.qr_data?.qr_code) this.qrData.set(response.qr_data.qr_code);
} catch (error) {
this.toastService.danger(this.resolveErrorMessage(
error,
'No pudimos obtener la información de pago. Intentá nuevamente.'
));
} finally {
this.isGeneratingIntent.set(false);
}
}
private resolvePaymentContext(): { tenantCode: string; purchaseId: number } | null {
const tenant = this.tenantService.tenant();
const purchaseId = this.purchaseId();
return tenant && purchaseId ? { tenantCode: tenant.codigo, purchaseId } : null;
}
private resolveErrorMessage(error: unknown, fallback: string): string {
const backendMessage = (error as { error?: { message?: unknown } } | null)?.error?.message;
if (typeof backendMessage === 'string') return backendMessage;
return error instanceof Error ? error.message : fallback;
}
}

View File

@@ -1,34 +0,0 @@
<div class="payment-panel__card">
@if (paymentStatus() === 'timed_out') {
<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

@@ -1,154 +0,0 @@
.payment-panel__card {
width: 100%;
max-width: 320px;
min-height: 269px;
padding: 1.5rem 1.75rem;
border-radius: 5px;
background: #ffffff;
color: #666666;
box-shadow: 0 0 0 1px #f1f1f1;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.payment-panel__title {
max-width: 18rem;
margin: 0;
color: #8a8a8a;
font-size: 13px;
font-weight: 700;
line-height: 1.35;
}
.payment-panel__divider {
width: 100%;
max-width: 220px;
height: 1px;
margin: 1.2rem 0 1.45rem;
background: #dddddd;
}
.qr-code {
position: relative;
width: min(170px, 100%);
aspect-ratio: 1;
border: 8px solid #ffffff;
background-color: #ffffff;
background-image:
linear-gradient(
90deg,
rgba(17, 17, 17, 0.95) 0 14%,
transparent 14% 28%,
rgba(17, 17, 17, 0.95) 28% 42%,
transparent 42% 56%,
rgba(17, 17, 17, 0.95) 56% 70%,
transparent 70% 84%,
rgba(17, 17, 17, 0.95) 84% 100%
),
linear-gradient(
rgba(17, 17, 17, 0.95) 0 14%,
transparent 14% 28%,
rgba(17, 17, 17, 0.95) 28% 42%,
transparent 42% 56%,
rgba(17, 17, 17, 0.95) 56% 70%,
transparent 70% 84%,
rgba(17, 17, 17, 0.95) 84% 100%
);
background-size: 18px 18px;
background-position:
0 0,
9px 9px;
display: flex;
justify-content: center;
align-items: center;
qrcode {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
::ng-deep canvas,
::ng-deep img {
display: block !important;
width: 100% !important;
height: 100% !important;
max-width: 100%;
max-height: 100%;
}
}
}
.qr-code__finder {
position: absolute;
width: 38px;
height: 38px;
border: 5px solid #111111;
background: #ffffff;
box-shadow:
inset 0 0 0 8px #ffffff,
inset 0 0 0 14px #111111;
&--tl {
top: 10px;
left: 10px;
}
&--tr {
top: 10px;
right: 10px;
}
&--bl {
bottom: 10px;
left: 10px;
}
}
.payment-status {
margin: 1rem 0 0;
color: #6f6f6f;
font-size: 0.8rem;
font-weight: 500;
&--warning {
margin-bottom: 0.75rem;
color: #9a6a12;
}
}
.payment-verification {
position: absolute;
inset: 0;
z-index: 2;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.45rem;
background: rgba(255, 255, 255, 0.82);
&__spinner {
width: 30px;
height: 30px;
border: 4px solid rgba(17, 17, 17, 0.2);
border-top-color: #111111;
border-radius: 50%;
animation: payment-verification-spin 0.75s linear infinite;
}
&__message {
color: #111111;
font-size: 0.72rem;
font-weight: 700;
}
}
@keyframes payment-verification-spin {
to {
transform: rotate(360deg);
}
}

View File

@@ -1,51 +0,0 @@
import { getTestBed, TestBed } from '@angular/core/testing';
import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
import { beforeAll, describe, expect, it } from 'vitest';
import { CheckoutPaymentQrComponent } from './checkout-payment-qr.component';
describe('CheckoutPaymentQrComponent', () => {
beforeAll(() => {
try {
getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
} catch {
// Test environment may already be initialized by another setup entrypoint.
}
});
it('renders the animated payment verification overlay over the QR', async () => {
await TestBed.configureTestingModule({
imports: [CheckoutPaymentQrComponent],
}).compileComponents();
const fixture = TestBed.createComponent(CheckoutPaymentQrComponent);
fixture.componentRef.setInput('isCheckingPayment', true);
fixture.detectChanges();
const overlay = fixture.nativeElement.querySelector('.payment-verification') as HTMLElement;
expect(overlay).not.toBeNull();
expect(overlay.textContent).toContain('Verificando pago');
expect(overlay.querySelector('.payment-verification__spinner')).not.toBeNull();
});
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,20 +0,0 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { QRCodeComponent } from '../../../../../../shared/components/qrcode/qrcode.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: [PaymentVerificationErrorComponent, QRCodeComponent],
templateUrl: './checkout-payment-qr.component.html',
styleUrl: './checkout-payment-qr.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CheckoutPaymentQrComponent {
readonly qrData = input<string | null>(null);
readonly paymentStatus = input<QrPaymentStatus>('idle');
readonly isCheckingPayment = input<boolean>(false);
readonly paymentAmount = input<number>(0);
readonly whatsappUrl = input<string | null>(null);
}

View File

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

View File

@@ -1,106 +0,0 @@
<div class="payment-panel__card">
@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>
@if (transferAccount() && !isEditing()) {
<div class="payment-panel__divider"></div>
<div class="payment-panel__account">
<p class="payment-panel__eyebrow">DATOS DE CUENTA:</p>
<div class="payment-detail">
<span class="payment-detail__label">Titular:</span>
<span class="payment-detail__value">{{ transferAccount()?.titular }}</span>
</div>
<div class="payment-detail">
<span class="payment-detail__label">Entidad:</span>
<span class="payment-detail__value">{{ transferAccount()?.entidad }}</span>
</div>
<div class="payment-detail payment-detail--copy">
<span class="payment-detail__label">CVU:</span>
<span class="payment-detail__value">{{ transferAccount()?.cvu }}</span>
<app-icon-button
variant="copy"
ariaLabel="Copiar CVU"
(click)="requestCopy('cvu', transferAccount()!.cvu)"
/>
@if (copiedTransferField() === 'cvu') {
<span class="payment-detail__feedback" style="grid-column: 1 / -1; text-align: center"
>¡Copiado!</span
>
}
</div>
<div class="payment-detail payment-detail--copy">
<span class="payment-detail__label">Alias:</span>
<span class="payment-detail__value">{{ transferAccount()?.alias }}</span>
<app-icon-button
variant="copy"
ariaLabel="Copiar Alias"
(click)="requestCopy('alias', transferAccount()!.alias)"
/>
@if (copiedTransferField() === 'alias') {
<span class="payment-detail__feedback" style="grid-column: 1 / -1; text-align: center"
>¡Copiado!</span
>
}
</div>
</div>
<div class="mt-4">
<app-button
type="button"
variant="primary"
hostClass="w-100"
buttonClass="w-100"
[disabled]="validationStatus() === 'checking'"
(click)="completePurchase.emit()"
>
{{ validationStatus() === 'checking' ? 'Validando pago...' : 'Ya transferí' }}
</app-button>
</div>
}
}
</div>

View File

@@ -1,117 +0,0 @@
.payment-panel__card {
width: 100%;
max-width: 320px;
min-height: 400px;
padding: 1.5rem 1.75rem;
border-radius: 5px;
background: #ffffff;
color: #666666;
box-shadow: 0 0 0 1px #f1f1f1;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.payment-panel__title {
max-width: 18rem;
margin: 0;
color: #8a8a8a;
font-size: 13px;
font-weight: 700;
line-height: 1.35;
}
.payment-panel__divider {
width: 100%;
max-width: 220px;
height: 1px;
margin: 1.2rem 0 1.45rem;
background: #dddddd;
}
.payment-panel__account {
width: 100%;
display: grid;
gap: 0.75rem;
color: #7a7a7a;
font-size: 12px;
}
.payment-panel__eyebrow {
margin: 0;
color: #838383;
font-size: 12px;
font-weight: 325;
letter-spacing: 0.06em;
}
.payment-detail {
display: grid;
grid-template-columns: auto auto;
justify-content: center;
gap: 0.35rem;
align-items: center;
font-size: 12px;
&--copy {
grid-template-columns: auto auto auto;
}
&__label {
color: #8a8a8a;
font-weight: 325;
}
&__value {
color: #5e5e5e;
font-weight: 500;
}
&__feedback {
display: block;
margin-top: -0.25rem;
color: var(--tenant-primary, #6376f3);
font-size: 0.78rem;
font-weight: 700;
}
}
.dni-form-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
flex: 1;
width: 100%;
padding: 1rem 0;
}
.dni-form-title {
color: #8a8a8a;
font-size: 13px;
font-weight: 700;
margin: 0 0 1.25rem 0;
text-align: center;
}
.dni-form {
width: 100%;
margin: 0;
app-input {
display: block;
height: 100%;
margin-bottom: 0;
::ng-deep .input-control,
::ng-deep input.form-control {
height: 100%;
}
}
::ng-deep .btn {
min-width: 0 !important;
height: 100%;
}
}

View File

@@ -1,78 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
signal,
OnInit,
effect,
untracked,
} from '@angular/core';
import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
import { IconButtonComponent } from '../../../../../../shared/components/icon-button/icon-button.component';
import { ButtonComponent } from '../../../../../../shared/components/button/button.component';
import { InputComponent } from '../../../../../../shared/components/input/input.component';
import {
TransferAccount,
TransferField,
TransferValidationStatus,
} from '../../checkout-page.models';
import { PaymentVerificationErrorComponent } from '../payment-verification-error/payment-verification-error.component';
@Component({
selector: 'app-checkout-payment-transfer',
standalone: true,
imports: [
IconButtonComponent,
ButtonComponent,
InputComponent,
ReactiveFormsModule,
PaymentVerificationErrorComponent,
],
templateUrl: './checkout-payment-transfer.component.html',
styleUrl: './checkout-payment-transfer.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CheckoutPaymentTransferComponent implements OnInit {
readonly transferAccount = input<TransferAccount | null>(null);
readonly transferDni = input<string>('');
readonly copiedTransferField = input<TransferField | null>(null);
readonly validationStatus = input<TransferValidationStatus>('idle');
readonly paymentAmount = input<number>(0);
readonly whatsappUrl = input<string | null>(null);
readonly copyTransferValue = output<{ field: TransferField; value: string }>();
readonly submitDni = output<string>();
readonly completePurchase = output<void>();
protected readonly dniControl = new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.pattern(/^\d{7,8}$/)],
});
protected readonly isEditing = signal<boolean>(false);
protected readonly isDniDisabled = computed(() => !!this.transferAccount() && !this.isEditing());
ngOnInit(): void {
const dni = this.transferDni();
if (dni) {
this.dniControl.setValue(dni);
}
}
protected requestCopy(field: TransferField, value: string): void {
this.copyTransferValue.emit({ field, value });
}
protected onSubmitDni(): void {
if (this.dniControl.valid) {
this.isEditing.set(false);
this.submitDni.emit(this.dniControl.value);
}
}
protected enableEditing(): void {
this.isEditing.set(true);
}
}

View File

@@ -0,0 +1,41 @@
@mixin host {
:host {
display: block;
width: 100%;
max-width: 290px;
}
}
@mixin card {
&__card {
width: 100%;
max-width: 290px;
min-height: 269px;
padding: 1.5rem 1.75rem;
border-radius: 5px;
background: #ffffff;
color: #666666;
box-shadow: 0 0 0 1px #f1f1f1;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
&__title {
max-width: 18rem;
margin: 0;
color: #8a8a8a;
font-size: 13px;
font-weight: 700;
line-height: 1.35;
}
&__divider {
width: 100%;
max-width: 220px;
height: 1px;
margin: 1.2rem 0 1.45rem;
background: #dddddd;
}
}

View File

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

View File

@@ -0,0 +1,81 @@
@use 'payment-panel' as payment-panel;
@include payment-panel.host;
.payment-panel {
@include payment-panel.card;
}
.qr-code {
position: relative;
width: min(170px, 100%);
aspect-ratio: 1;
border: 8px solid #ffffff;
background-color: #ffffff;
background-image:
linear-gradient(
90deg,
rgba(17, 17, 17, 0.95) 0 14%,
transparent 14% 28%,
rgba(17, 17, 17, 0.95) 28% 42%,
transparent 42% 56%,
rgba(17, 17, 17, 0.95) 56% 70%,
transparent 70% 84%,
rgba(17, 17, 17, 0.95) 84% 100%
),
linear-gradient(
rgba(17, 17, 17, 0.95) 0 14%,
transparent 14% 28%,
rgba(17, 17, 17, 0.95) 28% 42%,
transparent 42% 56%,
rgba(17, 17, 17, 0.95) 56% 70%,
transparent 70% 84%,
rgba(17, 17, 17, 0.95) 84% 100%
);
background-size: 18px 18px;
background-position: 0 0, 9px 9px;
display: flex;
justify-content: center;
align-items: center;
qrcode {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
::ng-deep canvas,
::ng-deep img {
display: block !important;
width: 100% !important;
height: 100% !important;
max-width: 100%;
max-height: 100%;
}
}
}
.qr-code__finder {
position: absolute;
width: 38px;
height: 38px;
border: 5px solid #111111;
background: #ffffff;
box-shadow: inset 0 0 0 8px #ffffff, inset 0 0 0 14px #111111;
&--tl {
top: 10px;
left: 10px;
}
&--tr {
top: 10px;
right: 10px;
}
&--bl {
bottom: 10px;
left: 10px;
}
}

View File

@@ -0,0 +1,30 @@
import { TestBed } from '@angular/core/testing';
import { QrPaymentContentComponent } from './qr-payment-content.component';
describe('QrPaymentContentComponent', () => {
it('shows the QR placeholder when there is no QR data', async () => {
await TestBed.configureTestingModule({
imports: [QrPaymentContentComponent]
}).compileComponents();
const fixture = TestBed.createComponent(QrPaymentContentComponent);
fixture.detectChanges();
expect(fixture.nativeElement.querySelectorAll('.qr-code__finder')).toHaveLength(3);
expect(fixture.nativeElement.querySelector('qrcode')).toBeNull();
});
it('renders the QR component when data is provided', async () => {
await TestBed.configureTestingModule({
imports: [QrPaymentContentComponent]
}).compileComponents();
const fixture = TestBed.createComponent(QrPaymentContentComponent);
fixture.componentRef.setInput('qrData', 'payment-data');
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('qrcode')).not.toBeNull();
expect(fixture.nativeElement.querySelector('.qr-code__finder')).toBeNull();
});
});

View File

@@ -0,0 +1,15 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { QRCodeComponent } from '../../../../../../shared/components/qrcode/qrcode.component';
@Component({
selector: 'app-qr-payment-content',
standalone: true,
imports: [QRCodeComponent],
templateUrl: './qr-payment-content.component.html',
styleUrl: './qr-payment-content.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class QrPaymentContentComponent {
readonly qrData = input<string | null>(null);
}

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