5 Commits

282 changed files with 3043 additions and 19148 deletions

2
.vscode/launch.json vendored
View File

@@ -7,7 +7,7 @@
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: start",
"url": "http://localhost:4300/"
"url": "http://localhost:4200/"
},
{
"name": "ng test",

View File

@@ -30,15 +30,15 @@
"input": "public"
}
],
"styles": ["node_modules/leaflet/dist/leaflet.css", "src/styles.scss"],
"styles": [
"src/styles.scss"
],
"server": "src/main.server.ts",
"outputMode": "server",
"security": {
"allowedHosts": [
"localhost",
"localhost:4300",
"127.0.0.1",
"127.0.0.1:4300"
"127.0.0.1"
]
},
"ssr": {
@@ -69,30 +69,13 @@
"local": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true,
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.local.ts"
}
]
},
"homo": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.homo.ts"
}
]
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"options": {
"port": 4300
},
"configurations": {
"production": {
"buildTarget": "shopit-front:build:production"
@@ -102,12 +85,9 @@
},
"local": {
"buildTarget": "shopit-front:build:local"
},
"homo": {
"buildTarget": "shopit-front:build:homo"
}
},
"defaultConfiguration": "local"
"defaultConfiguration": "development"
},
"test": {
"builder": "@angular/build:unit-test"
@@ -115,4 +95,4 @@
}
}
}
}
}

25
package-lock.json generated
View File

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

View File

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

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 333 KiB

View File

@@ -1,28 +0,0 @@
import { HttpRequest } from '@angular/common/http';
import { isStoreCatalogRequest } from './app.config';
describe('isStoreCatalogRequest', () => {
it.each([
'http://localhost:8000/api/tenants/demo/catalog',
'http://localhost:8000/api/tenants/demo/catalog?currency=ARS',
'http://localhost:8000/api/tenants/demo/catalog/featured-groups/7/items?page=2',
'http://localhost:8000/api/tenants/demo/catalog-items?q=remera&page=1',
'http://localhost:8000/api/tenants/demo/catalog-items/42?variant_id=3',
])('includes GET %s in the hydration transfer cache', (url) => {
expect(isStoreCatalogRequest(new HttpRequest('GET', url))).toBe(true);
});
it.each([
['POST', 'http://localhost:8000/api/tenants/demo/catalog-items'],
['GET', 'http://localhost:8000/api/tenants/demo/productos'],
['GET', 'http://localhost:8000/api/tenants/demo/categories/7'],
['GET', 'http://localhost:8000/api/tenants/demo/catalog-items/42/variant-options'],
['GET', 'http://localhost:8000/api/tenants/demo/catalogue'],
])('excludes %s %s from the hydration transfer cache', (method, url) => {
const request =
method === 'GET' ? new HttpRequest('GET', url) : new HttpRequest('POST', url, null);
expect(isStoreCatalogRequest(request)).toBe(false);
});
});

View File

@@ -10,15 +10,12 @@ import { provideClientHydration, withHttpTransferCacheOptions } from '@angular/p
import { routes } from './app.routes';
import { authBootstrap } from './core/services/auth/auth-bootstrap';
import { authInterceptor } from './core/services/auth/auth.interceptor';
import { globalLoadingInterceptor } from './core/services/global-loading/global-loading.interceptor';
import { tenantBootstrap } from './core/services/tenant-bootstrap';
export function isStoreCatalogRequest(request: HttpRequest<unknown>): boolean {
function isStoreCatalogRequest(request: HttpRequest<unknown>): boolean {
return (
request.method === 'GET' &&
/\/api\/tenants\/[^/?#]+\/(?:catalog(?:\/featured-groups\/\d+\/items)?|catalog-items(?:\/\d+)?)\/?(?:[?#]|$)/.test(
request.urlWithParams,
)
/\/api\/tenants\/[^/]+\/productos(?:\/\d+)?(?:\?|$)/.test(request.urlWithParams)
);
}
@@ -32,10 +29,7 @@ export const appConfig: ApplicationConfig = {
filter: isStoreCatalogRequest,
}),
),
provideHttpClient(
withFetch(),
withInterceptors([authInterceptor, globalLoadingInterceptor])
),
provideHttpClient(withFetch(), withInterceptors([authInterceptor])),
provideAppInitializer(authBootstrap),
provideAppInitializer(tenantBootstrap),
],

View File

@@ -2,7 +2,6 @@
<router-outlet />
<app-toast-container />
<app-modal-host />
<app-global-loading />
} @else if (status() === 'not-found') {
<section class="tenant-status">
<div class="tenant-status__card">

View File

@@ -23,8 +23,7 @@ const tenant: Tenant = {
header_bg_color: '#313131',
footer_bg_color: '#313131',
header_logo: 'https://example.com/header.png',
footer_logo: 'https://example.com/footer.png',
categories: []
footer_logo: 'https://example.com/footer.png'
};
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 () => {
const { router } = await renderAppAt('/login', createTenantServiceStub(), createAuthServiceStub(true));
@@ -233,23 +183,10 @@ describe('app routes', () => {
expect(compiled.textContent).toContain('No encontramos una tienda para este dominio');
});
it('renders the tenant not found screen at / without redirecting to itself', async () => {
const { fixture, router } = await renderAppAt(
'/',
createTenantServiceStub('not-found', null),
createAuthServiceStub()
);
const compiled = fixture.nativeElement as HTMLElement;
expect(router.url).toBe('/');
expect(compiled.querySelector('.tenant-status')).not.toBeNull();
expect(compiled.textContent).toContain('No encontramos una tienda para este dominio');
});
it('redirects unauthenticated users from /checkout to /login', async () => {
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 () => {

View File

@@ -17,7 +17,7 @@
.tenant-status__card {
width: min(100%, 34rem);
padding: 2rem;
border: 1px solid var(--border-color);
border: 1px solid rgba(32, 32, 32, 0.1);
border-radius: 1.5rem;
background-color: rgba(255, 255, 255, 0.92);
box-shadow: 0 1.25rem 3rem rgba(0, 0, 0, 0.1);
@@ -55,7 +55,7 @@
.tenant-status__spinner {
width: 3rem;
height: 3rem;
border: 0.25rem solid var(--border-color);
border: 0.25rem solid rgba(32, 32, 32, 0.08);
border-top-color: var(--tenant-primary);
border-radius: 999px;
animation: tenant-status-spin 0.8s linear infinite;

View File

@@ -17,8 +17,6 @@ const tenant: Tenant = {
codigo: 'test',
nombre: 'Test Tenant',
dominio: 'localhost',
site_title: 'Test Store',
favicon: 'https://example.com/favicon.png',
primary_color: '#6376F3',
secondary_color: '#A0A0A0',
danger_color: '#FF8888',
@@ -26,8 +24,7 @@ const tenant: Tenant = {
header_bg_color: '#313131',
footer_bg_color: '#313131',
header_logo: 'https://example.com/header.png',
footer_logo: 'https://example.com/footer.png',
categories: []
footer_logo: 'https://example.com/footer.png'
};
function createTenantServiceStub(status: 'ready' | 'not-found', currentTenant: Tenant | null) {
@@ -92,9 +89,6 @@ describe('App', () => {
expect(fixture.nativeElement.style.getPropertyValue('--tenant-danger-rgb')).toBe(
'255, 136, 136'
);
expect(document.title).toBe(tenant.site_title);
expect(document.head.querySelector<HTMLLinkElement>('link[rel~="icon"]')?.getAttribute('href'))
.toBe(tenant.favicon);
});
it('renders the tenant not found screen when the tenant is missing', async () => {
@@ -113,8 +107,5 @@ describe('App', () => {
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('No encontramos una tienda para este dominio');
expect(document.title).toBe('ShopitFront');
expect(document.head.querySelector<HTMLLinkElement>('link[rel~="icon"]')?.getAttribute('href'))
.toBe('favicon.ico');
});
});

View File

@@ -1,11 +1,8 @@
import { DOCUMENT } from '@angular/common';
import { Component, computed, effect, inject } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { Component, computed, inject } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { TenantService } from './core/services/tenant.service';
import { DEFAULT_TENANT_BRANDING } from './core/services/tenant-ssr-cache.store';
import { GlobalLoadingComponent } from './shared/components/global-loading/global-loading.component';
import { ModalHostComponent } from './shared/components/modal-host/modal-host.component';
import { ToastContainerComponent } from './shared/components/toast-container/toast-container.component';
@@ -26,12 +23,7 @@ function hexToRgb(hex: string): string {
@Component({
selector: 'app-root',
imports: [
RouterOutlet,
ToastContainerComponent,
ModalHostComponent,
GlobalLoadingComponent
],
imports: [RouterOutlet, ToastContainerComponent, ModalHostComponent],
templateUrl: './app.html',
styleUrl: './app.scss',
host: {
@@ -59,28 +51,6 @@ function hexToRgb(hex: string): string {
})
export class App {
private readonly tenantService = inject(TenantService);
private readonly document = inject(DOCUMENT);
private readonly title = inject(Title);
constructor() {
effect(() => {
const tenant = this.tenantService.tenant();
const siteTitle = tenant?.site_title?.trim() || 'ShopitFront';
const faviconHref = tenant?.favicon || 'favicon.ico';
this.title.setTitle(siteTitle);
let favicon = this.document.head.querySelector<HTMLLinkElement>('link[rel~="icon"]');
if (!favicon) {
favicon = this.document.createElement('link');
favicon.setAttribute('rel', 'icon');
this.document.head.appendChild(favicon);
}
favicon.setAttribute('href', faviconHref);
});
}
protected readonly status = this.tenantService.status;
@@ -118,4 +88,4 @@ export class App {
this.tenantService.tenant()?.footer_bg_color ??
DEFAULT_TENANT_BRANDING.footerBgColor
);
}
}

View File

@@ -1,76 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter, Router, UrlTree } from '@angular/router';
import { Tenant } from '../services/tenant.interface';
import { TenantService } from '../services/tenant.service';
import { hasMenuGuard } from './menu.guard';
const tenant: Tenant = {
id: 1,
codigo: 'test',
nombre: 'Test Tenant',
dominio: 'localhost',
primary_color: '#6376F3',
secondary_color: '#A0A0A0',
danger_color: '#FF8888',
success_color: '#198754',
header_bg_color: '#313131',
footer_bg_color: '#313131',
header_logo: 'https://example.com/header.png',
footer_logo: 'https://example.com/footer.png',
categories: [],
menues: [
{
id: 1,
code: 'index',
label: 'Inicio',
parent_menu_code: null,
content_type: 'dynamic',
route: '/',
submenues: [],
},
],
};
function runGuard(menuCode: string, url: string, currentTenant: Tenant | null) {
TestBed.configureTestingModule({
providers: [
provideRouter([]),
{
provide: TenantService,
useValue: { tenant: () => currentTenant },
},
],
});
return TestBed.runInInjectionContext(() =>
hasMenuGuard(menuCode)(null as never, { url } as never),
);
}
describe('hasMenuGuard', () => {
beforeEach(() => {
TestBed.resetTestingModule();
});
it('allows a route exposed by the tenant menu', () => {
expect(runGuard('index', '/', tenant)).toBe(true);
});
it('cancels navigation when the tenant is missing', () => {
expect(runGuard('index', '/', null)).toBe(false);
});
it('redirects a missing menu route to the store root', () => {
const result = runGuard('checkout', '/checkout', tenant);
expect(result instanceof UrlTree).toBe(true);
expect(TestBed.inject(Router).serializeUrl(result as UrlTree)).toBe('/');
});
it('allows the store root when its menu is missing to avoid a self-redirect', () => {
const tenantWithoutMenus = { ...tenant, menues: [] };
expect(runGuard('index', '/', tenantWithoutMenus)).toBe(true);
});
});

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

View File

@@ -1,10 +1,7 @@
<footer
class="store-layout__footer mt-auto text-light"
[style.background-image]="backgroundImageUrl ? 'url(' + backgroundImageUrl + ')' : null"
>
<div class="container-xl px-3 px-md-4 py-4 py-md-5 store-layout__footer-content">
<div class="row g-4 store-layout__footer-grid">
<div class="col-12 store-layout__brand-column">
<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="row g-4">
<div class="col-12 col-md-6 col-xl-5 d-grid gap-3 align-content-start">
<div
class="store-layout__brand-slot d-inline-flex align-items-center gap-3"
data-testid="store-footer-brand-slot"
@@ -18,61 +15,40 @@
</div>
}
</div>
</div>
<div class="col-12 col-md-6 col-xl-5 d-grid gap-3 align-content-start store-layout__contact">
<div class="d-grid gap-2 store-layout__contact-details">
<div class="d-flex align-items-center gap-2">
<div class="d-grid 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>
<span>Av. San Lorenzo 1542, Rosario</span>
</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>
<span>54 9 (0341) 6658247</span>
</div>
</div>
<p class="mb-0 small store-layout__muted-text store-layout__copyright">
Copyright &copy; {{ currentYear }} - {{ storeName || 'Sonder' }}
</p>
<p class="mb-0 small store-layout__muted-text">Copyright &copy; {{ currentYear }} - Sonder</p>
</div>
@for (section of footerSections; track section.heading) {
<div class="col-12 col-md-6 col-xl-2 d-grid align-content-start store-layout__links-column">
<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">
@for (link of section.links; track link.label) {
@if (link.routerLink) {
<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>
}
@for (link of section.links; track link) {
<span class="store-layout__muted-text">{{ link }}</span>
}
</div>
</div>
}
<div class="store-layout__mobile-divider" aria-hidden="true"></div>
<div class="col-12 col-md-6 col-xl-3 d-grid align-content-start store-layout__social-column">
<div class="d-flex align-items-center gap-3 store-layout__social-list">
@for (social of socialMedia; track social.code) {
<app-social-media
class="text-white"
[code]="social.code"
[icon]="social.icon"
[name]="social.name"
[url]="social.url"
/>
<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">
@for (social of socialLinks; track social.label) {
<span class="fs-5 text-white">
<i [class]="social.iconClass" aria-hidden="true"></i>
<span class="visually-hidden">{{ social.label }}</span>
</span>
}
</div>
</div>

View File

@@ -1,32 +1,23 @@
.store-layout__footer {
background-color: var(--tenant-footer-bg);
background-repeat: no-repeat;
background-position: center;
background-size: cover;
}
.store-layout__mobile-divider {
display: none;
}
.store-layout__brand-slot {
width: min(100%, 230px);
min-width: 10rem;
}
.store-layout__brand-logo-shell {
position: relative;
width: min(100%, 230px);
height: 65px;
width: min(100%, 12rem);
height: 2.75rem;
}
.store-layout__brand-logo {
display: block;
width: 100%;
height: 100%;
max-height: 65px;
max-height: 2.75rem;
object-fit: contain;
object-position: left center;
}
.store-layout__brand-logo-skeleton {
@@ -49,7 +40,7 @@
place-items: center;
width: 2.75rem;
height: 2.75rem;
border: 1px dashed var(--border-color);
border: 1px dashed rgba(255, 255, 255, 0.35);
border-radius: 999px;
font-size: 1.05rem;
font-weight: 700;
@@ -64,30 +55,17 @@
color: #ffffff;
}
.store-layout__footer-heading {
margin: 0;
font-size: 0.95rem;
font-weight: 500;
color: #f8f8f8;
}
.store-layout__muted-text {
color: rgba(255, 255, 255, 0.72);
}
.store-layout__footer-link {
width: fit-content;
color: #ffffff;
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 {
from {
background-position: 200% 0;
@@ -99,97 +77,11 @@
}
@media (max-width: 767.98px) {
.store-layout__footer-content {
padding: 1.45rem 2.5rem 1.8rem !important;
font-size: 13px;
}
.store-layout__footer-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0;
margin: 0;
}
.store-layout__footer-grid > * {
margin-top: 0;
padding-right: 0;
padding-left: 0;
}
.store-layout__links-column {
order: 1;
width: auto;
padding-bottom: 1.65rem;
line-height: 1.25;
&:nth-of-type(even) {
padding-left: 0.65rem;
}
> div {
gap: 0.5rem !important;
}
}
.store-layout__mobile-divider {
display: block;
order: 2;
grid-column: 1 / -1;
width: 100%;
border-top: 1px solid var(--border-color);
}
.store-layout__brand-column,
.store-layout__social-column {
order: 3;
width: auto;
padding-top: 2rem;
}
.store-layout__brand-slot {
width: min(100%, 150px);
min-width: 0;
}
.store-layout__brand-logo {
max-height: 55px;
}
.store-layout__brand-logo-shell {
width: min(100%, 150px);
height: 55px;
}
.store-layout__brand-text {
font-size: 13px;
}
.store-layout__social-column {
align-items: center;
}
.store-layout__social-list {
justify-content: flex-end;
gap: 0 !important;
}
.store-layout__contact {
order: 4;
grid-column: 1 / -1;
width: auto;
gap: 1.25rem !important;
padding-top: 0.9rem;
text-align: center;
}
.store-layout__contact-details {
justify-items: center;
gap: 0.4rem !important;
}
.store-layout__copyright {
font-size: 11px;
line-height: 1.3;
font-size: 1.45rem;
}
}

View File

@@ -1,31 +1,24 @@
import { Component, Input, output } 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';
};
import { Component, Input } from '@angular/core';
export type StoreFooterSection = {
heading: string;
links: StoreFooterLink[];
links: string[];
};
export type StoreSocialLink = {
label: string;
iconClass: string;
};
@Component({
selector: 'app-store-footer',
imports: [RouterLink, SocialMediaComponent],
imports: [],
templateUrl: './store-footer.component.html',
styleUrl: './store-footer.component.scss',
styleUrl: './store-footer.component.scss'
})
export class StoreFooterComponent {
@Input({ required: true }) currentYear = new Date().getFullYear();
@Input({ required: true }) footerSections: StoreFooterSection[] = [];
@Input({ required: true }) socialMedia: SocialMedia[] = [];
@Input({ required: true }) socialLinks: StoreSocialLink[] = [];
@Input() logoUrl: string | null = null;
@Input() backgroundImageUrl: string | null = null;
@Input() storeName: 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,92 +1,46 @@
<header
class="store-layout__header"
[style.background-image]="backgroundImageUrl() ? 'url(' + backgroundImageUrl() + ')' : null"
>
<header class="store-layout__header">
<div class="store-layout__header-main">
<div class="container-xl px-3 px-md-4 py-3 py-lg-4">
<div class="row align-items-center gy-3 gy-md-0">
<div class="col-auto col-md-auto order-1 store-layout__brand-column">
<a
routerLink="/"
class="store-layout__brand-slot d-inline-flex align-items-center gap-3 text-decoration-none"
data-testid="store-header-brand-slot"
aria-label="Logo del comercio"
>
@if (logoUrl()) {
<img class="store-layout__brand-logo" [src]="logoUrl()" alt="Logo del comercio" />
} @else {
<div class="store-layout__brand-logo-shell" aria-hidden="true">
<span class="store-layout__brand-logo-skeleton"></span>
</div>
}
</a>
</div>
@if (displaySeachBar()) {
<div class="col-12 px-0 d-md-none order-3">
<hr class="store-layout__mobile-separator" />
</div>
<div class="col-12 col-md order-4 order-md-2">
<div class="store-layout__search-wrapper ms-md-auto">
<div class="input-group store-layout__search" role="search">
<label class="visually-hidden" for="store-search-input">Buscar productos</label>
<input
id="store-search-input"
type="search"
class="form-control store-layout__search-input border-end-0 rounded-start"
placeholder="Buscar productos"
autocomplete="off"
[attr.aria-describedby]="showSearchError() ? 'store-search-error' : null"
[attr.aria-invalid]="showSearchError()"
[minlength]="minSearchLength"
[formControl]="searchControl"
(input)="clearSearchError()"
(keydown.enter)="submitSearch($event)"
/>
<button
type="button"
class="btn store-layout__search-button border-start-0 rounded-end px-3"
aria-label="Buscar"
(click)="submitSearch()"
>
<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
</button>
</div>
@if (showSearchError()) {
<p id="store-search-error" class="store-layout__search-error mb-0" role="alert">
Ingresá al menos {{ minSearchLength }} caracteres para buscar.
</p>
}
</div>
<div class="container-xl px-3 px-md-4 py-3 py-lg-4 d-flex flex-row flex-wrap align-items-center justify-content-between gap-3">
<a
routerLink="/"
class="store-layout__brand-slot d-inline-flex align-items-center gap-3 flex-shrink-0 text-decoration-none"
data-testid="store-header-brand-slot"
aria-label="Logo del comercio"
>
@if (logoUrl()) {
<img class="store-layout__brand-logo" [src]="logoUrl()" alt="Logo del comercio" />
} @else {
<div class="store-layout__brand-logo-shell" aria-hidden="true">
<span class="store-layout__brand-logo-skeleton"></span>
</div>
}
</a>
<div
class="store-layout__actions-column col-auto col-md-auto order-2 order-md-3 ms-auto d-flex flex-row align-items-center justify-content-end gap-2 gap-md-3"
>
@if (ticketsMenu(); as menu) {
<app-button
variant="primary"
hostClass="store-layout__tickets-btn-host"
data-testid="store-header-tickets"
(click)="ticketsClick.emit()"
>
{{ menu.label }}
</app-button>
}
<div class="d-flex flex-row align-items-center gap-3 ms-auto flex-nowrap flex-grow-1 justify-content-between justify-content-md-end">
<div class="input-group store-layout__search" role="search">
<label class="visually-hidden" for="store-search-input">Buscar productos</label>
<input
id="store-search-input"
type="search"
class="form-control store-layout__search-input border-end-0 rounded-start"
/>
<button
type="button"
class="btn store-layout__search-button border-start-0 rounded-end px-3"
aria-label="Buscar"
>
<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
</button>
</div>
@if (displayCart()) {
<app-cart-icon
[quantity]="cartQuantity()"
ariaLabel="Carrito de compras"
title="Carrito"
(click)="cartClick.emit()"
/>
}
<div class="store-layout__user-menu d-none d-md-inline-flex position-relative">
<div class="d-flex align-items-center justify-content-end gap-1" aria-label="Acciones de usuario">
<app-cart-icon
[quantity]="cartQuantity()"
ariaLabel="Carrito de compras"
title="Carrito"
(click)="cartClick.emit()"
/>
<div class="store-layout__user-menu">
<app-icon-button
variant="user"
ariaLabel="Mi cuenta"
@@ -100,79 +54,25 @@
<app-user-dropdown
id="store-user-dropdown"
[user]="user()"
[accountNavigationMenus]="accountNavigationMenus()"
(logoutClick)="onLogoutClick()"
/>
}
</div>
<div class="store-layout__mobile-menu d-inline-flex d-md-none position-relative">
<app-icon-button
variant="bars"
ariaLabel="Menu"
ariaControls="store-mobile-menu"
[ariaExpanded]="isMobileMenuOpen()"
title="Menu"
(clicked)="toggleMobileMenu()"
/>
@if (isMobileMenuOpen()) {
<div
id="store-mobile-menu"
class="position-absolute end-0 bg-white rounded shadow mt-2 py-2 store-layout__mobile-dropdown-menu"
>
<a
class="dropdown-item d-flex align-items-center gap-2 px-3 py-2 text-decoration-none store-layout__mobile-dropdown-item"
(click)="onMobileAccountClick()"
>
<i class="fa-regular fa-circle-user fa-lg"></i>
<span class="fw-medium">Mi cuenta</span>
</a>
@if (displayCategories()) {
<hr class="my-1 border-secondary opacity-25" />
@for (category of categories(); track category.id) {
<a
class="dropdown-item px-3 py-2 text-decoration-none store-layout__mobile-dropdown-item"
(click)="onCategorySelect(category)"
>
<span class="fw-medium">{{ category.nombre }}</span>
</a>
}
}
</div>
}
</div>
</div>
</div>
</div>
</div>
@if (displayCategories()) {
<div class="store-layout__categories d-none d-md-block">
<div class="container-xl px-3 px-md-4">
<div class="store-layout__category-menu">
<button
type="button"
class="btn btn-link d-inline-flex align-items-center gap-2 px-0 py-3 text-uppercase fw-bold text-decoration-none store-layout__category-trigger"
aria-label="Categorias"
aria-controls="store-category-dropdown"
[attr.aria-expanded]="isCategoryDropdownOpen()"
(click)="toggleCategoryDropdown()"
>
<span>Categorias</span>
<i class="fa-solid fa-chevron-down" aria-hidden="true"></i>
</button>
<app-category-dropdown
id="store-category-dropdown"
[categories]="categories()"
[open]="isCategoryDropdownOpen()"
(categorySelect)="onCategorySelect($event)"
/>
</div>
</div>
<div>
<div class="container-xl px-3 px-md-4">
<button
type="button"
class="btn btn-link d-inline-flex align-items-center gap-2 px-0 py-2 text-uppercase fw-bold text-decoration-none store-layout__category-trigger"
aria-label="Categorias"
>
<span>Categorias</span>
<i class="fa-solid fa-chevron-down" aria-hidden="true"></i>
</button>
</div>
}
</div>
</header>

View File

@@ -1,21 +1,15 @@
.store-layout__header {
color: #ffffff;
background-color: var(--tenant-header-bg);
background-repeat: no-repeat;
background-position: center;
background-size: cover;
box-shadow: inset 0 -1px 0 rgba(255, 255, 255, 0.08);
}
.store-layout__header-main {
border-bottom: 1px solid var(--border-color);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.store-layout__brand-slot {
min-width: 150px;
}
.store-layout__brand-column {
max-width: 12rem;
min-width: 10rem;
}
.store-layout__brand-logo-shell {
@@ -46,40 +40,14 @@
animation: store-header-logo-skeleton 1.2s ease-in-out infinite;
}
.store-layout__search-wrapper {
width: 100%;
min-width: 0;
}
@media (min-width: 768px) {
.store-layout__search-wrapper {
width: min(100%, 260px);
}
.store-layout__actions-column {
padding-left: 0;
}
::ng-deep .store-layout__tickets-btn-host {
flex: 0 0 clamp(8rem, 15vw, 13.5rem);
width: clamp(8rem, 15vw, 13.5rem);
}
}
.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);
width: min(100%, 260px);
min-width: 0;
}
.store-layout__search-input {
color: #666666;
border-color: var(--border-color);
border-color: #cccccc;
background-color: rgba(255, 255, 255, 0.96);
}
@@ -88,21 +56,21 @@
}
.store-layout__search-input:focus {
border-color: var(--border-color);
border-color: #cccccc;
box-shadow: none;
outline: none;
}
.store-layout__search-button {
color: #a0a0a0;
border-color: var(--border-color);
border-color: #cccccc;
background-color: rgba(255, 255, 255, 0.96);
}
.store-layout__search-button:hover,
.store-layout__search-button:focus-visible {
color: #8a8a8a;
border-color: var(--border-color);
border-color: #cccccc;
background-color: #ffffff;
}
@@ -131,19 +99,6 @@
color: #8a8a8a;
}
.store-layout__category-menu {
position: relative;
display: inline-flex;
flex-direction: column;
}
.store-layout__category-menu app-category-dropdown {
position: absolute;
z-index: 1050;
top: 100%;
left: 0;
}
@keyframes store-header-logo-skeleton {
from {
background-position: 200% 0;
@@ -157,70 +112,5 @@
@media (max-width: 767.98px) {
.store-layout__brand-slot {
min-width: 0;
max-width: 150px;
}
.store-layout__actions-column {
flex: 1 1 auto;
width: auto;
min-width: 0;
}
::ng-deep .store-layout__tickets-btn-host {
flex: 1 1 0;
width: auto;
min-width: 0;
max-width: 50vw;
}
.store-layout__mobile-separator {
border-color: var(--border-color);
opacity: 1;
margin: 0 -1rem;
}
}
@media (max-width: 399.98px) {
.store-layout__brand-column {
flex: 1 1 0;
width: auto;
min-width: 0;
max-width: none;
padding-right: 0.25rem;
}
.store-layout__brand-slot {
width: 100%;
max-width: 6.5rem;
}
.store-layout__actions-column {
flex: 1 1 auto;
width: auto;
min-width: 0;
margin-left: 0 !important;
padding-left: 0.25rem;
gap: 0.25rem !important;
}
::ng-deep .store-layout__tickets-btn-host .btn {
padding-inline: 0.75rem;
font-size: 0.875rem;
}
}
.store-layout__mobile-dropdown-menu {
top: 100%;
min-width: 220px;
z-index: 1050;
}
.store-layout__mobile-dropdown-item {
cursor: pointer;
color: #8a8a8a;
&:hover,
&:focus-visible {
color: #666666;
}
}

View File

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

View File

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

View File

@@ -10,7 +10,7 @@
.user-dropdown {
display: grid;
background: #ffffff;
border: 1px solid var(--border-color);
border: 1px solid rgba(32, 32, 32, 0.1);
border-radius: 8px;
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.16);
padding: 0.75rem;

View File

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

View File

@@ -1,26 +1,15 @@
<div class="store-layout d-flex flex-column flex-grow-1">
<app-store-header
ngSkipHydration
[cartQuantity]="cartQuantity()"
[logoUrl]="tenant()?.header_logo ?? null"
[backgroundImageUrl]="tenant()?.header_bg_image ?? null"
[user]="user()"
[isAuthenticated]="isAuthenticated()"
[accountNavigationMenus]="accountNavigationMenus()"
[ticketsMenu]="ticketsMenu() ?? null"
[categories]="tenant()?.categories ?? []"
[displayCategories]="tenant()?.display_categories ?? true"
[displaySeachBar]="tenant()?.display_seach_bar ?? true"
[displayCart]="displayCart()"
(cartClick)="isCartOpen.set(!isCartOpen())"
(ticketsClick)="onTicketsClick()"
(loginClick)="onLoginClick()"
(logoutClick)="onLogoutClick()"
(searchSubmit)="onSearch($event)"
(categorySelect)="onCategorySelect($event)"
/>
@if (displayCart() && isCartOpen()) {
@if (isCartOpen()) {
<div class="store-layout__cart-overlay" (click)="isCartOpen.set(false)"></div>
<div class="store-layout__cart-dropdown card border-0 shadow-lg">
<app-cart
@@ -32,17 +21,8 @@
[backgroundColor]="'#ffffff'"
(closed)="isCartOpen.set(false)"
>
<app-button variant="secondary" class="flex-grow-1" (click)="isCartOpen.set(false)"
>Seguir comprando</app-button
>
<app-button
variant="primary"
class="flex-grow-1"
[disabled]="isCreatingPurchase()"
(click)="onCheckoutClick()"
>
Comprar
</app-button>
<app-button variant="secondary" class="flex-grow-1" (click)="isCartOpen.set(false)">Seguir comprando</app-button>
<app-button variant="primary" class="flex-grow-1" (click)="onCheckoutClick()">Comprar</app-button>
</app-cart>
</div>
}
@@ -57,11 +37,8 @@
<app-store-footer
[currentYear]="currentYear"
[footerSections]="footerSections()"
[socialMedia]="tenant()?.social_media ?? []"
[footerSections]="footerSections"
[socialLinks]="socialLinks"
[logoUrl]="tenant()?.footer_logo ?? null"
[backgroundImageUrl]="tenant()?.footer_bg_image ?? null"
[storeName]="tenant()?.nombre ?? null"
(logoutClick)="onLogoutClick()"
/>
</div>

View File

@@ -21,15 +21,14 @@
.store-layout__cart-dropdown {
position: absolute;
top: 100px;
top: 76px;
right: calc((100% - 1320px) / 2 + 1.5rem);
width: 100%;
max-width: 400px;
max-height: calc(100vh - 120px);
z-index: 1050;
background-color: #ffffff;
border-radius: 8px;
border: 1px solid var(--border-color);
border: 1px solid rgba(0, 0, 0, 0.08);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.08);
overflow: hidden;
animation: store-layout-slide-down 0.2s ease-out;
@@ -41,7 +40,6 @@
right: 1rem;
left: 1rem;
width: auto;
max-width: none;
}
}

View File

@@ -1,7 +1,6 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { provideRouter, Router } from '@angular/router';
import { of } from 'rxjs';
@@ -9,13 +8,10 @@ import { of } from 'rxjs';
import { Tenant } from '../../services/tenant.interface';
import { TenantService } from '../../services/tenant.service';
import { CartService } from '../../services/cart/cart.service';
import { Cart } from '../../services/cart/cart.interface';
import { ToastService } from '../../services/toast.service';
import { AuthService } from '../../services/auth/auth.service';
import { AuthUser } from '../../services/auth/auth.interfaces';
import { StoreLayoutComponent } from './store-layout.component';
import { StoreHeaderComponent } from './store-header/store-header.component';
import { CheckoutService } from '../../services/checkout.service';
const tenant: Tenant = {
id: 1,
@@ -29,119 +25,14 @@ const tenant: Tenant = {
header_bg_color: '#313131',
footer_bg_color: '#313131',
header_logo: 'https://example.com/header.png',
footer_logo: 'https://example.com/footer.png',
header_bg_image: 'https://example.com/header-background.png',
footer_bg_image: 'https://example.com/footer-background.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',
},
],
footer_logo: 'https://example.com/footer.png'
};
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 () => {
tenantState = signal<Tenant | null>(tenant);
cartState = signal<Cart | null>(null);
authUserState = signal<AuthUser | null>(null);
const tenantState = signal(tenant);
const authUserState = signal<AuthUser | null>(null);
const isAuthenticatedState = signal(false);
checkoutServiceStub = {
startCheckout: vi.fn().mockResolvedValue({ id: 55 }),
};
await TestBed.configureTestingModule({
imports: [StoreLayoutComponent],
@@ -153,20 +44,17 @@ describe('StoreLayoutComponent', () => {
tenant: tenantState.asReadonly(),
status: signal<'ready'>('ready').asReadonly(),
getTenant: () => tenantState(),
bootstrap: vi.fn().mockResolvedValue(undefined),
},
bootstrap: vi.fn().mockResolvedValue(undefined)
}
},
{
provide: CartService,
useValue: {
cart: cartState.asReadonly(),
cart: signal(null).asReadonly(),
loadCart: () => of({ id: 1, items: [], subtotal: '0' }),
updateItemQuantity: vi
.fn()
.mockReturnValue(of({ data: { id: 1, items: [], subtotal: '0' } })),
removeItem: vi.fn().mockReturnValue(of({ data: { id: 1, items: [], subtotal: '0' } })),
clearCart: vi.fn(() => cartState.set(null)),
},
updateItemQuantity: vi.fn().mockReturnValue(of({ data: { id: 1, items: [], subtotal: '0' } })),
removeItem: vi.fn().mockReturnValue(of({ data: { id: 1, items: [], subtotal: '0' } }))
}
},
{
provide: ToastService,
@@ -175,22 +63,18 @@ describe('StoreLayoutComponent', () => {
info: vi.fn(),
danger: vi.fn(),
success: vi.fn(),
dismiss: vi.fn(),
},
dismiss: vi.fn()
}
},
{
provide: AuthService,
useValue: {
user: authUserState,
isAuthenticated: isAuthenticatedState,
logout: vi.fn().mockReturnValue(of(void 0)),
},
},
{
provide: CheckoutService,
useValue: checkoutServiceStub,
},
],
logout: vi.fn().mockReturnValue(of(void 0))
}
}
]
}).compileComponents();
});
@@ -206,37 +90,6 @@ describe('StoreLayoutComponent', () => {
expect(compiled.querySelector('app-store-footer .store-layout__footer')).not.toBeNull();
});
it('hides the configured header elements when the tenant disables them', () => {
tenantState.set({
...tenant,
categories: [{ id: 1, nombre: 'Remeras', subcategories: [] }],
display_categories: false,
display_seach_bar: false,
display_cart: false,
});
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('#store-search-input')).toBeNull();
expect(compiled.querySelector('.store-layout__category-trigger')).toBeNull();
expect(compiled.querySelector('app-cart-icon')).toBeNull();
(fixture.componentInstance as any).isCartOpen.set(true);
fixture.detectChanges();
expect(compiled.querySelector('app-cart')).toBeNull();
expect(compiled.querySelector('.store-layout__cart-overlay')).toBeNull();
const header = fixture.debugElement.query(By.directive(StoreHeaderComponent));
(header.componentInstance as any).toggleMobileMenu();
fixture.detectChanges();
expect(compiled.querySelectorAll('.store-layout__mobile-dropdown-item')).toHaveLength(1);
});
it('renders the tenant branding and action icons', () => {
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
@@ -246,21 +99,11 @@ describe('StoreLayoutComponent', () => {
expect(compiled.querySelector('[data-testid="store-header-brand-slot"]')).not.toBeNull();
expect(compiled.querySelector('[data-testid="store-footer-brand-slot"]')).not.toBeNull();
expect(
compiled.querySelector(
`app-store-header img.store-layout__brand-logo[src="${tenant.header_logo}"]`,
),
compiled.querySelector(`app-store-header img.store-layout__brand-logo[src="${tenant.header_logo}"]`)
).not.toBeNull();
expect(
compiled.querySelector(
`app-store-footer img.store-layout__brand-logo[src="${tenant.footer_logo}"]`,
),
compiled.querySelector(`app-store-footer img.store-layout__brand-logo[src="${tenant.footer_logo}"]`)
).not.toBeNull();
expect(
(compiled.querySelector('.store-layout__header') as HTMLElement).style.backgroundImage,
).toContain(tenant.header_bg_image!);
expect(
(compiled.querySelector('.store-layout__footer') as HTMLElement).style.backgroundImage,
).toContain(tenant.footer_bg_image!);
expect(compiled.querySelector('.fa-cart-shopping')).not.toBeNull();
expect(compiled.querySelector('.fa-circle-user')).not.toBeNull();
expect(compiled.querySelector('.fa-instagram')).not.toBeNull();
@@ -269,127 +112,6 @@ describe('StoreLayoutComponent', () => {
expect(compiled.querySelector('.fa-linkedin-in')).not.toBeNull();
});
it('navigates to search results when the search button is clicked', () => {
const router = TestBed.inject(Router);
vi.spyOn(router, 'navigate');
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
const header = fixture.debugElement.query(By.directive(StoreHeaderComponent));
const searchButton = header.query(By.css('.store-layout__search-button'));
(header.componentInstance as any).searchControl.setValue(' zapatillas ');
searchButton.triggerEventHandler('click');
fixture.detectChanges();
expect(router.navigate).toHaveBeenCalledWith(['/buscar'], {
queryParams: { q: 'zapatillas', page: 1 },
});
});
it('prevents the native submit and searches when Enter is pressed', () => {
const router = TestBed.inject(Router);
vi.spyOn(router, 'navigate');
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
const header = fixture.debugElement.query(By.directive(StoreHeaderComponent));
const input = header.query(By.css('#store-search-input'));
const keyboardEvent = {
preventDefault: vi.fn(),
};
(header.componentInstance as any).searchControl.setValue('remera');
input.triggerEventHandler('keydown.enter', keyboardEvent);
expect(keyboardEvent.preventDefault).toHaveBeenCalled();
expect(router.navigate).toHaveBeenCalledWith(['/buscar'], {
queryParams: { q: 'remera', page: 1 },
});
});
it('requires at least three characters to search', () => {
const router = TestBed.inject(Router);
vi.spyOn(router, 'navigate');
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
const header = fixture.debugElement.query(By.directive(StoreHeaderComponent));
const input = header.query(By.css('#store-search-input'));
const searchButton = header.query(By.css('.store-layout__search-button'));
expect(input.nativeElement.getAttribute('minlength')).toBe('3');
(header.componentInstance as any).searchControl.setValue('ab');
searchButton.triggerEventHandler('click');
fixture.detectChanges();
expect(router.navigate).not.toHaveBeenCalled();
expect((fixture.nativeElement as HTMLElement).textContent).toContain(
'Ingresá al menos 3 caracteres para buscar.',
);
expect(input.nativeElement.getAttribute('aria-invalid')).toBe('true');
});
it('renders the primary tickets action when the tenant has the tickets menu', () => {
const router = TestBed.inject(Router);
vi.spyOn(router, 'navigate');
const configuredTicketsLabel = 'Entradas digitales';
tenantState.set({
...tenant,
menues: tenant.menues?.map((menu) =>
menu.code === 'account'
? {
...menu,
submenues: menu.submenues.map((submenu) =>
submenu.code === 'account.tickets'
? { ...submenu, label: configuredTicketsLabel }
: submenu,
),
}
: menu,
),
});
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
const ticketsAction = (fixture.nativeElement as HTMLElement).querySelector<HTMLElement>(
'app-button[data-testid="store-header-tickets"]',
);
const ticketsButton = ticketsAction?.querySelector('button');
expect(ticketsAction).not.toBeNull();
expect(ticketsButton).not.toBeNull();
expect(ticketsButton?.textContent?.trim()).toBe(configuredTicketsLabel);
expect(ticketsButton?.classList).toContain('btn-primary');
ticketsButton?.click();
expect(router.navigate).toHaveBeenCalledWith(['/mi-cuenta/tickets']);
});
it('does not render the tickets action when the tenant lacks the tickets menu', () => {
tenantState.set({
...tenant,
menues: tenant.menues?.map((menu) =>
menu.code === 'account'
? {
...menu,
submenues: menu.submenues.filter((submenu) => submenu.code !== 'account.tickets'),
}
: menu,
),
});
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
expect(
(fixture.nativeElement as HTMLElement).querySelector('[data-testid="store-header-tickets"]'),
).toBeNull();
});
it('redirects to /login when user icon is clicked and user is not logged in', () => {
const authService = TestBed.inject(AuthService);
const router = TestBed.inject(Router);
@@ -401,9 +123,7 @@ describe('StoreLayoutComponent', () => {
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const userButton = compiled.querySelector(
'app-icon-button[variant="user"] button',
) as HTMLButtonElement;
const userButton = compiled.querySelector('app-icon-button[variant="user"] button') as HTMLButtonElement;
expect(userButton).not.toBeNull();
userButton.click();
fixture.detectChanges();
@@ -421,33 +141,25 @@ describe('StoreLayoutComponent', () => {
(authService.user as any).set({
id: 1,
nombre_apellido: 'Ada Lovelace',
email: 'ada@example.com',
email: 'ada@example.com'
});
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const userButton = compiled.querySelector(
'app-icon-button[variant="user"] button',
) as HTMLButtonElement;
const userButton = compiled.querySelector('app-icon-button[variant="user"] button') as HTMLButtonElement;
expect(userButton).not.toBeNull();
userButton.click();
fixture.detectChanges();
expect(compiled.querySelector('app-user-dropdown')).not.toBeNull();
expect(compiled.textContent).toContain('Ada Lovelace');
expect(compiled.querySelector('[data-testid="user-dropdown-account"]')).not.toBeNull();
expect(
compiled.querySelector('[data-testid="user-dropdown-account.purchases"]'),
).not.toBeNull();
expect(compiled.querySelector('[data-testid="user-dropdown-account.tickets"]')).toBeNull();
expect(router.navigate).not.toHaveBeenCalled();
});
it('logs out from the authenticated user dropdown, clears the cart and returns home', () => {
it('logs out from the authenticated user dropdown', () => {
const authService = TestBed.inject(AuthService);
const cartService = TestBed.inject(CartService);
const router = TestBed.inject(Router);
vi.spyOn(router, 'navigate');
@@ -455,196 +167,45 @@ describe('StoreLayoutComponent', () => {
(authService.user as any).set({
id: 1,
nombre_apellido: 'Ada Lovelace',
email: 'ada@example.com',
email: 'ada@example.com'
});
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const userButton = compiled.querySelector(
'app-icon-button[variant="user"] button',
) as HTMLButtonElement;
const userButton = compiled.querySelector('app-icon-button[variant="user"] button') as HTMLButtonElement;
userButton.click();
fixture.detectChanges();
const logoutButton = compiled.querySelector(
'[data-testid="user-dropdown-logout"]',
) as HTMLButtonElement;
const logoutButton = compiled.querySelector('[data-testid="user-dropdown-logout"]') as HTMLButtonElement;
expect(logoutButton).not.toBeNull();
logoutButton.click();
fixture.detectChanges();
expect(authService.logout).toHaveBeenCalled();
expect(cartService.clearCart).toHaveBeenCalled();
expect(router.navigate).toHaveBeenCalledWith(['/']);
expect(router.navigate).toHaveBeenCalledWith(['/login']);
});
it('provides account actions from the footer', () => {
const authService = TestBed.inject(AuthService);
const cartService = TestBed.inject(CartService);
it('redirects to /checkout when cart buy button is clicked', () => {
const router = TestBed.inject(Router);
vi.spyOn(router, 'navigate');
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 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.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const buyButton = Array.from(compiled.querySelectorAll('app-button button')).find(
(button) => button.textContent?.trim() === 'Comprar',
) as HTMLButtonElement | undefined;
const buyButton = Array.from(compiled.querySelectorAll('app-button button'))
.find((button) => button.textContent?.trim() === 'Comprar') as HTMLButtonElement | undefined;
expect(buyButton).toBeDefined();
buyButton!.click();
await Promise.resolve();
fixture.detectChanges();
expect(checkoutServiceStub.startCheckout).toHaveBeenCalledWith('test', {
cart_id: 1,
});
expect(router.navigate).toHaveBeenCalledWith(['/checkout'], {
queryParams: { purchase: 55 },
});
expect(router.navigate).toHaveBeenCalledWith(['/checkout']);
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,40 +2,26 @@ import { Component, computed, inject, OnInit, signal } from '@angular/core';
import { Router, RouterOutlet } from '@angular/router';
import { TenantService } from '../../services/tenant.service';
import { CartService } from '../../services/cart/cart.service';
import { StoreFooterComponent, StoreFooterSection } from './store-footer/store-footer.component';
import { StoreFooterComponent, StoreFooterSection, StoreSocialLink } from './store-footer/store-footer.component';
import { StoreHeaderComponent } from './store-header/store-header.component';
import { CartComponent, CartItemMock } from '../../../shared/components/cart/cart.component';
import { ButtonComponent } from '../../../shared/components/button/button.component';
import { CartItem, CartItemVariantValue } from '../../services/cart/cart.interface';
import { CartItem } from '../../services/cart/cart.interface';
import { AuthService } from '../../services/auth/auth.service';
import { findMenu } from '../../services/menu.utils';
import { CheckoutService } from '../../services/checkout.service';
import { ToastService } from '../../services/toast.service';
import { Category } from '../../services/tenant.interface';
@Component({
selector: 'app-store-layout',
imports: [
RouterOutlet,
StoreHeaderComponent,
StoreFooterComponent,
CartComponent,
ButtonComponent,
],
imports: [RouterOutlet, StoreHeaderComponent, StoreFooterComponent, CartComponent, ButtonComponent],
templateUrl: './store-layout.component.html',
styleUrl: './store-layout.component.scss',
styleUrl: './store-layout.component.scss'
})
export class StoreLayoutComponent implements OnInit {
private readonly tenantService = inject(TenantService);
private readonly cartService = inject(CartService);
private readonly authService = inject(AuthService);
private readonly checkoutService = inject(CheckoutService);
private readonly toastService = inject(ToastService);
private readonly router = inject(Router);
protected readonly isCartOpen = signal(false);
protected readonly isCreatingPurchase = signal(false);
protected readonly displayCart = computed(() => this.tenant()?.display_cart ?? true);
protected readonly cartSubtotal = computed(() => {
const cart = this.cartService.cart();
@@ -70,64 +56,22 @@ export class StoreLayoutComponent implements OnInit {
});
}
const variants = item.product?.variants ?? [];
const selectedVariant = item.product?.variants?.find(
(variant) => variant.id === item.variant_id,
);
if (selectedVariant) {
attributes = Object.entries(selectedVariant.values).map(([label, value]) => ({
label: this.formatAttributeLabel(label),
value: this.formatAttributeValue(value),
}));
}
return {
cartItemId: item.id,
productVariantId: item.product_variant_id,
imageUrl: item.product?.imagen ?? null,
product,
originalPrice: null,
discountedPrice: parseFloat(item.precio_unitario),
discountPercentage: null,
attributes,
quantity: item.cantidad,
variantId: item.variant_id,
variants,
quantity: item.cantidad
};
}
private formatAttributeLabel(value: string): string {
const label = value.replace(/[_-]+/g, ' ');
return label.charAt(0).toUpperCase() + label.slice(1);
}
private formatAttributeValue(value: CartItemVariantValue): string {
const values = Array.isArray(value) ? value : [value];
return values.map((item) => (typeof item === 'string' ? item : item.label)).join(', ');
}
protected readonly currentYear = new Date().getFullYear();
protected readonly tenant = this.tenantService.tenant;
protected readonly user = this.authService.user;
protected readonly isAuthenticated = this.authService.isAuthenticated;
protected readonly ticketsMenu = computed(() =>
findMenu(this.tenant()?.menues ?? [], 'account.tickets'),
);
protected readonly accountNavigationMenus = computed(() => {
const accountMenu = findMenu(this.tenant()?.menues ?? [], 'account');
if (!accountMenu) {
return [];
}
const profile = accountMenu.submenues.find((menu) => menu.code === 'account.profile');
const purchases = accountMenu.submenues.find((menu) => menu.code === 'account.purchases');
return [
...(profile ? [{ ...accountMenu, route: profile.route }] : []),
...(purchases ? [purchases] : []),
];
});
protected readonly cartQuantity = computed(() => {
const cart = this.cartService.cart();
@@ -137,7 +81,7 @@ export class StoreLayoutComponent implements OnInit {
ngOnInit(): void {
this.cartService.loadCart().subscribe({
error: (err) => console.error('Error loading cart', err),
error: (err) => console.error('Error loading cart', err)
});
}
@@ -145,112 +89,46 @@ export class StoreLayoutComponent implements OnInit {
void this.router.navigate(['/login']);
}
protected onSearch(term: string): void {
void this.router.navigate(['/buscar'], {
queryParams: { q: term, page: 1 },
});
}
protected onCategorySelect(category: Category): void {
void this.router.navigate(['/categoria', category.id], {
queryParams: { page: 1 },
});
}
protected onTicketsClick(): void {
const route = this.ticketsMenu()?.route;
if (route) {
void this.router.navigate([route]);
}
}
protected async onLogoutClick(): Promise<void> {
const isLeavingCheckout = this.router.url.startsWith('/checkout');
// Checkout must be left while the authenticated session is still valid so
// its CanDeactivate guard can cancel the pending purchase.
if (isLeavingCheckout) {
const navigationSucceeded = await this.router.navigate(['/']);
if (!navigationSucceeded) {
return;
}
}
protected onLogoutClick(): void {
this.authService.logout().subscribe({
next: () => {
this.cartService.clearCart();
this.isCartOpen.set(false);
if (!isLeavingCheckout) {
void this.router.navigate(['/']);
}
},
error: (err) => console.error('Error logging out', err),
next: () => void this.router.navigate(['/login']),
error: (err) => console.error('Error logging out', err)
});
}
protected async onCheckoutClick(): Promise<void> {
if (this.isCreatingPurchase()) {
return;
}
const cart = this.cartService.cart();
const tenant = this.tenantService.tenant();
const user = this.authService.user();
if (!user) {
this.isCartOpen.set(false);
void this.router.navigate(['/login'], { queryParams: { returnUrl: '/' } });
return;
}
if (!cart?.id || !tenant) {
this.toastService.danger('No hay un carrito activo para iniciar la compra.');
return;
}
this.isCreatingPurchase.set(true);
try {
const purchase = await this.checkoutService.startCheckout(tenant.codigo, {
cart_id: cart.id,
});
this.cartService.clearCart();
this.isCartOpen.set(false);
await this.router.navigate(['/checkout'], {
queryParams: { purchase: purchase.id },
});
} catch (error) {
console.error('Failed to create cart purchase:', error);
this.toastService.danger('No se pudo iniciar la compra.');
} finally {
this.isCreatingPurchase.set(false);
}
protected onCheckoutClick(): void {
this.isCartOpen.set(false);
void this.router.navigate(['/checkout']);
}
protected readonly footerSections = computed<StoreFooterSection[]>(() => {
const helpSubmenues = findMenu(this.tenant()?.menues ?? [], 'help')?.submenues ?? [];
return [
{
heading: 'Cuenta',
links: [
...this.accountNavigationMenus().map((menu) => ({
label: menu.label,
routerLink: menu.route,
})),
{ label: 'Cerrar sesion', action: 'logout' },
],
},
{
heading: 'Ayuda',
links: helpSubmenues.map((menu) => ({
label: menu.label,
routerLink: menu.route,
})),
},
];
});
protected readonly footerSections: StoreFooterSection[] = [
{
heading: 'Cuenta',
links: ['Mi cuenta', 'Mis compras', 'Cerrar sesion']
},
{
heading: 'Ayuda',
links: ['Contacto', 'Ayuda', 'Preguntas frecuentes']
}
];
protected readonly socialLinks: StoreSocialLink[] = [
{
label: 'Instagram',
iconClass: 'fa-brands fa-instagram'
},
{
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() }]
});
const result = TestBed.runInInjectionContext(() =>
authGuard(null as never, { url: '/checkout?mode=direct' } as never),
);
const result = TestBed.runInInjectionContext(() => authGuard(null as never, null as never));
expect(result instanceof UrlTree).toBe(true);
expect(TestBed.inject(Router).serializeUrl(result as UrlTree)).toBe(
'/login?returnUrl=%2Fcheckout%3Fmode%3Ddirect',
);
expect(TestBed.inject(Router).serializeUrl(result as UrlTree)).toBe('/login');
});
it('allows authenticated users through authGuard', () => {
@@ -38,9 +34,7 @@ describe('auth guards', () => {
providers: [provideRouter([]), { provide: AuthService, useValue: createAuthServiceStub(true) }]
});
const result = TestBed.runInInjectionContext(() =>
authGuard(null as never, { url: '/checkout' } as never),
);
const result = TestBed.runInInjectionContext(() => authGuard(null as never, null as never));
expect(result).toBe(true);
});

View File

@@ -3,15 +3,11 @@ import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from './auth.service';
export const authGuard: CanActivateFn = (_route, state) => {
export const authGuard: CanActivateFn = () => {
const authService = inject(AuthService);
const router = inject(Router);
return authService.isAuthenticated()
? true
: router.createUrlTree(['/login'], {
queryParams: { returnUrl: state.url },
});
return authService.isAuthenticated() ? true : router.createUrlTree(['/login']);
};
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 { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { computed, signal } from '@angular/core';
@@ -69,7 +69,7 @@ describe('authInterceptor', () => {
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');
TestBed.configureTestingModule({
@@ -83,21 +83,14 @@ describe('authInterceptor', () => {
const client = TestBed.inject(HttpClient);
const httpController = TestBed.inject(HttpTestingController);
let receivedError: unknown;
let receivedStatus: number | null = null;
client.get(`${environment.url}me`).subscribe({
error: (error: HttpErrorResponse) => {
receivedError = error;
receivedStatus = error.status;
}
error: () => undefined
});
const request = httpController.expectOne(`${environment.url}me`);
request.flush({ message: 'Unauthorized' }, { status: 401, statusText: 'Unauthorized' });
expect(receivedError).toBeInstanceOf(HttpErrorResponse);
expect(receivedStatus).toBe(401);
expect(authService.clearSession).not.toHaveBeenCalled();
expect(authService.clearSession).toHaveBeenCalledTimes(1);
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 { Observable } from 'rxjs';
import { Observable, catchError, throwError } from 'rxjs';
import { AuthService } from './auth.service';
@@ -19,5 +25,13 @@ export const authInterceptor: HttpInterceptorFn = (
})
: 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;
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

@@ -2,13 +2,10 @@ import { PLATFORM_ID, TransferState } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TimeoutError } from 'rxjs';
import { environment } from '../../../../environments/environment';
import { BOOTSTRAP_REQUEST_TIMEOUT_MS } from '../bootstrap-request-timeout';
import { AuthService } from './auth.service';
import { CookieService } from '../cookie/cookie.service';
import { TenantService } from '../tenant.service';
describe('AuthService', () => {
let cookieStore: Record<string, string> = {};
@@ -21,12 +18,8 @@ describe('AuthService', () => {
function createCookieServiceStub() {
return {
get: (name: string) => cookieStore[name] || null,
set: (name: string, value: string) => {
cookieStore[name] = value;
},
delete: (name: string) => {
delete cookieStore[name];
}
set: (name: string, value: string) => { cookieStore[name] = value; },
delete: (name: string) => { delete cookieStore[name]; }
};
}
@@ -37,10 +30,6 @@ describe('AuthService', () => {
provideHttpClientTesting(),
AuthService,
{ provide: CookieService, useValue: createCookieServiceStub() },
{
provide: TenantService,
useValue: { getTenant: () => ({ codigo: 'tenant-test' }) }
},
TransferState
]
});
@@ -54,12 +43,6 @@ describe('AuthService', () => {
const request = httpController.expectOne(`${environment.url}login`);
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual({
email: 'ada@example.com',
password: 'secret123',
tenant_codigo: 'tenant-test'
});
expect(request.request.withCredentials).toBe(true);
request.flush({
message: 'Sesion iniciada correctamente.',
token: 'plain-text-token',
@@ -74,13 +57,13 @@ describe('AuthService', () => {
expect(service.token()).toBe('plain-text-token');
expect(service.user()?.email).toBe('ada@example.com');
expect(service.isAuthenticated()).toBe(true);
expect(cookieStore['shopit.front.auth.token']).toBe('plain-text-token');
expect(cookieStore['shopit.auth.token']).toBe('plain-text-token');
httpController.verify();
});
it('hydrates token from cookies and loads the current user during bootstrap', async () => {
cookieStore['shopit.front.auth.token'] = 'persisted-token';
cookieStore['shopit.auth.token'] = 'persisted-token';
TestBed.configureTestingModule({
providers: [
@@ -112,49 +95,8 @@ describe('AuthService', () => {
httpController.verify();
});
it('sends tenant and credentials when completing Google login', () => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
AuthService,
{ provide: CookieService, useValue: createCookieServiceStub() },
{
provide: TenantService,
useValue: { getTenant: () => ({ codigo: 'tenant-test' }) }
},
TransferState
]
});
const service = TestBed.inject(AuthService);
const httpController = TestBed.inject(HttpTestingController);
service.completeGoogleLogin('oauth-code').subscribe();
const request = httpController.expectOne(`${environment.url}auth/google/exchange`);
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual({
oauth_code: 'oauth-code',
tenant_codigo: 'tenant-test'
});
expect(request.request.withCredentials).toBe(true);
request.flush({
message: 'Sesion iniciada correctamente.',
token: 'google-token',
token_type: 'Bearer',
user: {
id: 1,
nombre_apellido: 'Ada Lovelace',
email: 'ada@example.com'
}
});
httpController.verify();
});
it('clears an expired session when /me returns 401', async () => {
cookieStore['shopit.front.auth.token'] = 'expired-token';
it('clears the session when bootstrap receives 401 from /me', async () => {
cookieStore['shopit.auth.token'] = 'expired-token';
TestBed.configureTestingModule({
providers: [
@@ -178,48 +120,11 @@ describe('AuthService', () => {
expect(service.user()).toBeNull();
expect(service.token()).toBeNull();
expect(service.isAuthenticated()).toBe(false);
expect(cookieStore['shopit.front.auth.token']).toBeUndefined();
expect(cookieStore['shopit.auth.token']).toBeUndefined();
httpController.verify();
});
it('propagates a /me timeout without clearing the local session', async () => {
cookieStore['shopit.front.auth.token'] = 'valid-token';
vi.useFakeTimers();
try {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
AuthService,
{ provide: CookieService, useValue: createCookieServiceStub() },
{ provide: BOOTSTRAP_REQUEST_TIMEOUT_MS, useValue: 25 },
TransferState,
],
});
const service = TestBed.inject(AuthService);
const httpController = TestBed.inject(HttpTestingController);
const bootstrapPromise = service.bootstrap();
const rejection = expect(bootstrapPromise).rejects.toBeInstanceOf(TimeoutError);
const request = httpController.expectOne(`${environment.url}me`);
await vi.advanceTimersByTimeAsync(25);
await rejection;
expect(request.cancelled).toBe(true);
expect(service.user()).toBeNull();
expect(service.token()).toBe('valid-token');
expect(cookieStore['shopit.front.auth.token']).toBe('valid-token');
httpController.verify();
} finally {
vi.useRealTimers();
}
});
it('registers without creating a session', () => {
TestBed.configureTestingModule({
providers: [
@@ -227,10 +132,6 @@ describe('AuthService', () => {
provideHttpClientTesting(),
AuthService,
{ provide: CookieService, useValue: createCookieServiceStub() },
{
provide: TenantService,
useValue: { getTenant: () => ({ codigo: 'tenant-test' }) }
},
TransferState
]
});
@@ -251,7 +152,6 @@ describe('AuthService', () => {
const request = httpController.expectOne(`${environment.url}register`);
expect(request.request.method).toBe('POST');
expect(request.request.body.tenant_codigo).toBe('tenant-test');
request.flush({
message: 'Usuario registrado correctamente.',
data: {
@@ -268,132 +168,13 @@ describe('AuthService', () => {
httpController.verify();
});
it('requests a password reset and exposes the final HTTP status', () => {
it('clears local session even when logout request fails', () => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
AuthService,
{ provide: CookieService, useValue: createCookieServiceStub() },
{
provide: TenantService,
useValue: { getTenant: () => ({ codigo: 'tenant-test' }) }
},
TransferState
]
});
const service = TestBed.inject(AuthService);
const httpController = TestBed.inject(HttpTestingController);
service.requestPasswordReset('ada@example.com').subscribe((response) => {
expect(response.status).toBe(202);
expect(response.body?.status).toBe('pending');
});
const request = httpController.expectOne(`${environment.url}password/reset-attempts`);
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual({
email: 'ada@example.com',
tenant_codigo: 'tenant-test'
});
request.flush(
{
message: 'Si el email está registrado, recibirás un código.',
status: 'pending'
},
{ status: 202, statusText: 'Accepted' }
);
httpController.verify();
});
it('validates a password reset code and exposes its final status', () => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
AuthService,
{ provide: CookieService, useValue: createCookieServiceStub() },
TransferState
]
});
const service = TestBed.inject(AuthService);
const httpController = TestBed.inject(HttpTestingController);
service.validatePasswordResetCode('ada@example.com', '0123').subscribe((response) => {
expect(response.status).toBe(200);
expect(response.body?.status).toBe('validated');
});
const request = httpController.expectOne(`${environment.url}password/reset-attempts/validate`);
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual({
email: 'ada@example.com',
codigo: '0123'
});
request.flush(
{
message: 'Código validado correctamente.',
status: 'validated'
},
{ status: 200, statusText: 'OK' }
);
httpController.verify();
});
it('resets the password and exposes the consumed attempt status', () => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
AuthService,
{ provide: CookieService, useValue: createCookieServiceStub() },
TransferState
]
});
const service = TestBed.inject(AuthService);
const httpController = TestBed.inject(HttpTestingController);
const payload = {
email: 'ada@example.com',
codigo: '0123',
password: 'NewSecret!456',
password_confirmation: 'NewSecret!456'
};
service.resetPassword(payload).subscribe((response) => {
expect(response.status).toBe(200);
expect(response.body?.status).toBe('used');
});
const request = httpController.expectOne(`${environment.url}password/reset`);
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual(payload);
request.flush(
{
message: 'Contraseña modificada correctamente.',
status: 'used'
},
{ status: 200, statusText: 'OK' }
);
httpController.verify();
});
it('propagates logout errors without clearing the local session', () => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
AuthService,
{ provide: CookieService, useValue: createCookieServiceStub() },
{
provide: TenantService,
useValue: { getTenant: () => ({ codigo: 'tenant-test' }) }
},
TransferState
]
});
@@ -421,9 +202,9 @@ describe('AuthService', () => {
expect(logoutRequest.request.method).toBe('POST');
logoutRequest.flush({ message: 'Unauthorized' }, { status: 401, statusText: 'Unauthorized' });
expect(service.token()).toBe('plain-text-token');
expect(service.user()?.email).toBe('ada@example.com');
expect(cookieStore['shopit.front.auth.token']).toBe('plain-text-token');
expect(service.token()).toBeNull();
expect(service.user()).toBeNull();
expect(cookieStore['shopit.auth.token']).toBeUndefined();
httpController.verify();
});

View File

@@ -1,40 +1,30 @@
import { DOCUMENT } from '@angular/common';
import { HttpErrorResponse, HttpResponse } from '@angular/common/http';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { isPlatformBrowser, isPlatformServer } from '@angular/common';
import { computed, inject, Injectable, PLATFORM_ID, signal, TransferState, makeStateKey } from '@angular/core';
import { firstValueFrom, map, Observable, of, tap, timeout } from 'rxjs';
import { firstValueFrom, map, Observable, of, tap } from 'rxjs';
import { environment } from '../../../../environments/environment';
import { BaseApiService } from '../base-api.service';
import {
AuthUser,
LoginPayload,
LoginResponse,
RegisterPayload,
RegisterResponse,
ResetPasswordPayload,
ResetPasswordAttemptResponse,
ResetPasswordResponse,
UpdateProfilePayload,
ValidateResetPasswordAttemptResponse
UpdateProfilePayload
} from './auth.interfaces';
import { CookieService } from '../cookie/cookie.service';
import { TenantService } from '../tenant.service';
import { BOOTSTRAP_REQUEST_TIMEOUT_MS } from '../bootstrap-request-timeout';
const AUTH_TOKEN_COOKIE_KEY = 'shopit.front.auth.token';
const AUTH_TOKEN_COOKIE_KEY = 'shopit.auth.token';
const AUTH_USER_SSR_STATE_KEY = makeStateKey<AuthUser>('shopit.auth.user');
@Injectable({
providedIn: 'root'
})
export class AuthService extends BaseApiService {
private readonly document = inject(DOCUMENT);
export class AuthService {
private readonly http = inject(HttpClient);
private readonly platformId = inject(PLATFORM_ID);
private readonly cookieService = inject(CookieService);
private readonly transferState = inject(TransferState);
private readonly tenantService = inject(TenantService);
private readonly bootstrapRequestTimeoutMs = inject(BOOTSTRAP_REQUEST_TIMEOUT_MS);
private readonly userState = signal<AuthUser | null>(null);
private readonly tokenState = signal<string | null>(null);
@@ -44,111 +34,20 @@ export class AuthService extends BaseApiService {
readonly isAuthenticated = computed(() => this.tokenState() !== null);
login(payload: LoginPayload): 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}login`,
{ ...payload, tenant_codigo: tenant.codigo },
{
withCredentials: true
}
)
.pipe(
tap((response) => this.applyAuthenticatedState(response.token, response.user)),
map((response) => response.user)
);
return this.http.post<LoginResponse>(`${environment.url}login`, payload).pipe(
tap((response) => this.applyAuthenticatedState(response.token, response.user)),
map((response) => response.user)
);
}
register(payload: RegisterPayload): Observable<RegisterResponse> {
const tenantCode = this.tenantService.getTenant()?.codigo;
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)
);
return this.http.post<RegisterResponse>(`${environment.url}register`, payload);
}
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> {
@@ -157,9 +56,12 @@ export class AuthService extends BaseApiService {
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> {
@@ -177,21 +79,13 @@ export class AuthService extends BaseApiService {
}
try {
const user = await firstValueFrom(
this.loadCurrentUser().pipe(timeout(this.bootstrapRequestTimeoutMs)),
);
const user = await firstValueFrom(this.loadCurrentUser());
if (isPlatformServer(this.platformId)) {
this.transferState.set(AUTH_USER_SSR_STATE_KEY, user);
}
} catch (error) {
// An expired token is an expected state, including while rendering on the
// server. Do not let it reject the application initializer: Angular's SSR
// pipeline can otherwise attempt to serialize HttpErrorResponse internals
// as HTTP headers.
if (error instanceof HttpErrorResponse && error.status === 401) {
if (this.isUnauthorizedError(error)) {
this.clearSession();
return;
}
@@ -200,9 +94,9 @@ export class AuthService extends BaseApiService {
}
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 {
@@ -221,4 +115,8 @@ export class AuthService extends BaseApiService {
this.userState.set(user);
this.cookieService.set(AUTH_TOKEN_COOKIE_KEY, token);
}
private isUnauthorizedError(error: unknown): error is HttpErrorResponse {
return error instanceof HttpErrorResponse && error.status === 401;
}
}

View File

@@ -1,42 +0,0 @@
import '@angular/compiler';
import { provideHttpClient } from '@angular/common/http';
import { TestBed } from '@angular/core/testing';
import { afterEach, describe, expect, it } from 'vitest';
import { BaseApiService } from './base-api.service';
import { LOADING_MODE } from './global-loading/loading-mode';
class TestApiService extends BaseApiService {
contextFor() {
return this.loadingContext();
}
}
describe('BaseApiService', () => {
afterEach(() => {
TestBed.resetTestingModule();
});
it('uses global loading by default', () => {
TestBed.configureTestingModule({ providers: [provideHttpClient()] });
const service = TestBed.runInInjectionContext(() => new TestApiService());
expect(service.contextFor().get(LOADING_MODE)).toBe('global');
});
it('creates an isolated custom-loading scope', () => {
TestBed.configureTestingModule({ providers: [provideHttpClient()] });
const service = TestBed.runInInjectionContext(() => new TestApiService());
expect(service.withCustomLoading().contextFor().get(LOADING_MODE)).toBe('custom');
expect(service.contextFor().get(LOADING_MODE)).toBe('global');
});
it('creates an isolated scope without a loading indicator', () => {
TestBed.configureTestingModule({ providers: [provideHttpClient()] });
const service = TestBed.runInInjectionContext(() => new TestApiService());
expect(service.withoutLoading().contextFor().get(LOADING_MODE)).toBe('none');
expect(service.contextFor().get(LOADING_MODE)).toBe('global');
});
});

View File

@@ -1,79 +0,0 @@
import { HttpClient, HttpContext } from '@angular/common/http';
import { inject } from '@angular/core';
import {
LOADING_MODE,
LoadingMode
} from './global-loading/loading-mode';
const REQUEST_LOADING_MODE = Symbol('request-loading-mode');
class LoadingHttpClient {
readonly get: HttpClient['get'];
readonly post: HttpClient['post'];
readonly put: HttpClient['put'];
readonly patch: HttpClient['patch'];
readonly delete: HttpClient['delete'];
constructor(
http: HttpClient,
private readonly buildContext: (context?: HttpContext) => HttpContext
) {
this.get = ((url: string, options?: object) =>
http.get(url, this.withLoadingContext(options))) as HttpClient['get'];
this.post = ((url: string, body: unknown, options?: object) =>
http.post(url, body, this.withLoadingContext(options))) as HttpClient['post'];
this.put = ((url: string, body: unknown, options?: object) =>
http.put(url, body, this.withLoadingContext(options))) as HttpClient['put'];
this.patch = ((url: string, body: unknown, options?: object) =>
http.patch(url, body, this.withLoadingContext(options))) as HttpClient['patch'];
this.delete = ((url: string, options?: object) =>
http.delete(url, this.withLoadingContext(options))) as HttpClient['delete'];
}
private withLoadingContext(options?: object): object {
const requestOptions = options as { context?: HttpContext } | undefined;
return {
...requestOptions,
context: this.buildContext(requestOptions?.context)
};
}
}
export abstract class BaseApiService {
private readonly rawHttp = inject(HttpClient);
private readonly [REQUEST_LOADING_MODE]: LoadingMode = 'global';
protected get http(): LoadingHttpClient {
return new LoadingHttpClient(
this.rawHttp,
(context) => this.loadingContext(context)
);
}
withLoading(loadingMode: LoadingMode): this {
const scopedService = Object.create(this) as this;
Object.defineProperty(scopedService, REQUEST_LOADING_MODE, {
configurable: false,
enumerable: false,
value: loadingMode,
writable: false
});
return scopedService;
}
withCustomLoading(): this {
return this.withLoading('custom');
}
withoutLoading(): this {
return this.withLoading('none');
}
protected loadingContext(context: HttpContext = new HttpContext()): HttpContext {
return context.set(LOADING_MODE, this[REQUEST_LOADING_MODE]);
}
}

View File

@@ -1,9 +0,0 @@
import { InjectionToken } from '@angular/core';
export const BOOTSTRAP_REQUEST_TIMEOUT_MS = new InjectionToken<number>(
'BOOTSTRAP_REQUEST_TIMEOUT_MS',
{
providedIn: 'root',
factory: () => 10_000,
},
);

View File

@@ -1,32 +1,14 @@
export interface CartItemProduct {
nombre: string;
imagen: string | null;
variants?: CartItemVariant[];
}
export interface CartItemVariantOption {
value: string;
label: string;
}
export type CartItemVariantValue =
| string
| CartItemVariantOption
| Array<string | CartItemVariantOption>;
export interface CartItemVariant {
id: number;
precio: string;
stock_tecnico: number | null;
values: Record<string, CartItemVariantValue>;
}
export interface CartItem {
id: number;
cantidad: number;
precio_unitario: string;
catalog_item_id: number;
variant_id: number | null;
product_id: number;
product_variant_id: number;
product: CartItemProduct | null;
}

View File

@@ -6,7 +6,6 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { CartService } from './cart.service';
import { TenantService } from '../tenant.service';
import { Cart } from './cart.interface';
import { LOADING_MODE } from '../global-loading/loading-mode';
describe('CartService', () => {
let service: CartService;
@@ -22,21 +21,21 @@ describe('CartService', () => {
id: 1,
cantidad: 2,
precio_unitario: '10.00',
catalog_item_id: 5,
variant_id: 10,
product_id: 5,
product_variant_id: 10,
product: {
nombre: 'Test Product (Size: M)',
imagen: null,
},
},
imagen: null
}
}
],
subtotal: '20.00',
subtotal: '20.00'
};
beforeEach(() => {
tenantServiceMock = {
getTenantApiUrl: vi.fn().mockReturnValue('http://api.test/tenants/acme'),
tenant: vi.fn().mockReturnValue({ codigo: 'acme' }),
tenant: vi.fn().mockReturnValue({ codigo: 'acme' })
};
TestBed.configureTestingModule({
@@ -44,8 +43,8 @@ describe('CartService', () => {
CartService,
provideHttpClient(),
provideHttpClientTesting(),
{ provide: TenantService, useValue: tenantServiceMock },
],
{ provide: TenantService, useValue: tenantServiceMock }
]
});
service = TestBed.inject(CartService);
@@ -76,14 +75,6 @@ describe('CartService', () => {
req.flush({ data: mockCart });
});
it('propagates a custom loading mode to the request context', () => {
service.withCustomLoading().loadCart().subscribe();
const req = httpMock.expectOne('http://api.test/tenants/acme/cart');
expect(req.request.context.get(LOADING_MODE)).toBe('custom');
req.flush({ data: mockCart });
});
it('should clear cart state locally', () => {
service.clearCart();
@@ -92,23 +83,19 @@ describe('CartService', () => {
tenant_codigo: 'acme',
status: 'active',
items: [],
subtotal: '0.00',
subtotal: '0.00'
});
});
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(service.cart()).toEqual(mockCart);
});
const req = httpMock.expectOne('http://api.test/tenants/acme/cart/items');
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual({
catalog_item_id: 5,
variant_id: 10,
cantidad: 2,
});
expect(req.request.body).toEqual({ product_variant_id: 10, cantidad: 2 });
expect(req.request.withCredentials).toBe(true);
req.flush({ data: mockCart });
});
@@ -135,7 +122,7 @@ describe('CartService', () => {
tenant_codigo: 'acme',
status: 'active',
items: [],
subtotal: '0.00',
subtotal: '0.00'
};
service.removeItem(10).subscribe((res) => {

View File

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

View File

@@ -1,10 +1,5 @@
import { ApiPaginatedResponse } from '../api-paginated-response.interface';
export type CatalogItemType = 'product' | 'bundle';
export interface Product {
id: number;
type: CatalogItemType;
category_id: number;
brand_id: number | null;
slug: string;
@@ -18,157 +13,38 @@ export interface Product {
export interface ProductAttributeOption {
id: number;
validity_time_id?: number | null;
value: string;
label: string;
sort_order: number;
metadata: Record<string, unknown> | null;
}
export type ProductAttributeType =
| 'string'
| 'number'
| 'boolean'
| 'select'
| 'multiselect'
| 'color'
| 'image'
| 'event_date';
export interface ProductAttribute {
id: number;
codigo: string;
nombre: string;
is_required: boolean;
allow_multi_select?: boolean;
show_in_selector?: boolean;
metadata_schema: Record<string, unknown> | null;
type: ProductAttributeType;
type: string;
options: ProductAttributeOption[];
}
export type InventoryPolicy = 'tracked' | 'unlimited';
export interface CatalogVariantOption {
value: string;
label: string;
}
export type CatalogVariantValue =
| string
| CatalogVariantOption
| Array<string | CatalogVariantOption>;
export interface CatalogItemVariant {
export interface ProductVariant {
id: number;
descripcion?: string | null;
precio?: string;
event_date_id?: number | null;
event_date_ids?: number[];
event_dates?: string[];
stock_tecnico: number | null;
minimum_use_date?: string | null;
maximum_use_date?: string | null;
effective_minimum_use_date?: string | null;
effective_maximum_use_date?: string | null;
values: Record<string, string | string[]>;
}
export interface SelectedCatalogItemVariant extends CatalogItemVariant {
cantidad_maxima: number;
definitions: Record<string, string>;
images: string[];
}
export interface CatalogItemDetail {
id: number;
type: CatalogItemType;
purpose?: 'product' | 'entry';
category_id: number | null;
brand_id: number | null;
slug: string;
nombre: string;
descripcion: string | null;
precio: string;
category: string | null;
brand: string | null;
inventory_policy: InventoryPolicy;
max_units_per_user?: number | null;
has_tickets: boolean;
minimum_use_date: string | null;
maximum_use_date: string | null;
export interface ProductVariantMap {
variant_id: number;
cantidad_maxima: number;
attributes: Record<string, string>;
}
export interface ProductDetail extends Product {
attributes: ProductAttribute[];
variants: CatalogItemVariant[];
selected_variant?: SelectedCatalogItemVariant;
stock_tecnico?: number | null;
images?: string[];
variants_map: ProductVariantMap[];
variant: ProductVariant | null;
}
export type CatalogProductLayout =
| 'row'
| 'column_with_image'
| 'column_with_cart'
| 'ticket_selector';
export type CatalogGroupLayout = 'paginated' | 'simple' | 'simple_vertical' | 'carousel' | 'single';
export interface CatalogFeaturedItemVariant {
id: number;
descripcion?: string | null;
precio?: string;
stock_tecnico: number | null;
values: Record<string, CatalogVariantValue>;
}
export interface CatalogVariantOptionsResponse {
selectors: CatalogVariantSelector[];
selected_values: Record<string, string | string[]>;
resolved_variant: CatalogFeaturedItemVariant | null;
valid: boolean;
available_variant_count: number;
matching_variant_count: number;
price_range: {
minimum: string;
maximum: string;
};
}
export interface CatalogVariantSelector {
key: string;
label: string;
options: CatalogVariantValue[];
enabled: boolean;
}
export interface CatalogFeaturedItem {
id: number;
type: CatalogItemType;
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

@@ -1,104 +1,48 @@
import { HttpParams } from '@angular/common/http';
import { inject, Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { map, Observable } from 'rxjs';
import { ApiPaginationQueryParams } from '../api-pagination-query-params.interface';
import { ApiPaginatedResponse } from '../api-paginated-response.interface';
import { ApiResponse } from '../api-response.interface';
import { BaseApiService } from '../base-api.service';
import { TenantService } from '../tenant.service';
import {
CatalogFeaturedGroup,
CatalogFeaturedItem,
CatalogFeaturedItems,
CatalogItemDetail,
CatalogVariantOptionsResponse,
CategoryItemsResponse,
Product,
} from './catalog.interface';
import { Product, ProductDetail } from './catalog.interface';
type HttpParamValue = string | number | boolean | readonly (string | number | boolean)[];
export interface CatalogSearchQueryParams extends ApiPaginationQueryParams {
q: string;
}
type HttpParamValue =
| string
| number
| boolean
| readonly (string | number | boolean)[];
@Injectable({
providedIn: 'root',
providedIn: 'root'
})
export class CatalogService extends BaseApiService {
export class CatalogService {
private readonly http = inject(HttpClient);
private readonly tenantService = inject(TenantService);
private get tenantApiUrl(): string {
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[]> {
return this.http.get<CatalogFeaturedGroup[]>(`${this.tenantApiUrl}/catalog`);
}
searchCatalog(
params: CatalogSearchQueryParams,
): Observable<ApiPaginatedResponse<CatalogFeaturedItem[]>> {
return this.http.get<ApiPaginatedResponse<CatalogFeaturedItem[]>>(
`${this.tenantApiUrl}/catalog-items`,
{
params: this.buildHttpParams(params),
},
getProductos(
params?: ApiPaginationQueryParams
): Observable<ApiPaginatedResponse<Product[]>> {
return this.http.get<ApiPaginatedResponse<Product[]>>(
`${this.tenantApiUrl}/productos`,
{ params: this.buildHttpParams(params) }
);
}
getCategoryItems(
categoryId: number,
params?: ApiPaginationQueryParams,
): Observable<CategoryItemsResponse> {
return this.http.get<CategoryItemsResponse>(`${this.tenantApiUrl}/categories/${categoryId}`, {
params: this.buildHttpParams(params),
});
}
getFeaturedGroupItems(
featuredGroupId: number,
params?: ApiPaginationQueryParams,
): 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> {
getProducto(id: number, variantId?: number): Observable<ProductDetail> {
let params = new HttpParams();
if (variantId) {
params = params.set('variant_id', variantId);
}
return this.http
.get<ApiResponse<CatalogItemDetail>>(`${this.tenantApiUrl}/catalog-items/${id}`, {
params,
})
.pipe(map((response) => response.data));
}
getVariantOptions(
catalogItemId: number,
payload: {
selected_values: Record<string, string | string[]>;
cart_item_id?: number | null;
},
): Observable<CatalogVariantOptionsResponse> {
return this.http
.post<
ApiResponse<CatalogVariantOptionsResponse>
>(`${this.tenantApiUrl}/catalog-items/${catalogItemId}/variant-options`, payload, { withCredentials: true })
.get<ApiResponse<ProductDetail>>(`${this.tenantApiUrl}/productos/${id}`, { params })
.pipe(map((response) => response.data));
}
@@ -115,7 +59,7 @@ export class CatalogService extends BaseApiService {
return acc;
},
{},
{}
);
return new HttpParams({ fromObject });

View File

@@ -1,41 +0,0 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { environment } from '../../../environments/environment';
import { CheckoutService } from './checkout.service';
describe('CheckoutService', () => {
let service: CheckoutService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [CheckoutService, provideHttpClient(), provideHttpClientTesting()],
});
service = TestBed.inject(CheckoutService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('starts one direct checkout with all selected variants', async () => {
const payload = {
direct_items: [
{ catalog_item_id: 10, variant_id: 101, cantidad: 1 },
{ catalog_item_id: 10, variant_id: 102, cantidad: 1 },
],
};
const purchasePromise = service.startCheckout('desfile', payload);
const request = httpMock.expectOne(`${environment.url}tenants/desfile/compras/start-checkout`);
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual(payload);
request.flush({ data: { id: 55 } });
await expect(purchasePromise).resolves.toMatchObject({ id: 55 });
});
});

View File

@@ -1,56 +1,17 @@
import { Injectable } from '@angular/core';
import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { environment } from '../../../environments/environment';
import { BaseApiService } from './base-api.service';
export interface UpdatePurchaseCustomerPayload {
export interface CreatePurchasePayload {
cart_id: number;
dni: string;
telefono: string;
nombre_apellido: string;
email: string;
}
export interface DirectCheckoutItem {
catalog_item_id: number;
variant_id: number | null;
cantidad: number;
}
export interface UnavailableCheckoutItem {
index: number;
catalog_item_id: number;
variant_id: number | null;
requested_quantity: number;
available_quantity: number;
message: string;
}
export interface InsufficientStockResponse {
code: 'purchase.insufficient_stock';
message: string;
errors: Record<string, string[]>;
unavailable_items: UnavailableCheckoutItem[];
}
export function isInsufficientStockResponse(value: unknown): value is InsufficientStockResponse {
if (typeof value !== 'object' || value === null) return false;
const response = value as Partial<InsufficientStockResponse>;
return (
response.code === 'purchase.insufficient_stock' && Array.isArray(response.unavailable_items)
);
}
export type StartCheckoutPayload =
| {
cart_id: number;
}
| {
direct_items: DirectCheckoutItem[];
};
export interface PurchaseStatusResponse {
status: string | null;
}
@@ -63,7 +24,7 @@ export interface PurchaseSummaryResponse extends PurchaseStatusResponse {
export interface PurchaseDetailAttributeResponse {
name: string;
value: string | number | boolean | null;
value: string | null;
}
export interface PurchaseDetailItemResponse {
@@ -71,15 +32,16 @@ export interface PurchaseDetailItemResponse {
quantity: number;
unit_price: string;
line_total: string;
source_catalog_item_id: number | null;
source_variant_id: number | null;
item_details: {
product: {
id: number;
nombre: string;
descripcion: string | null;
slug: string;
imagen: string | null;
} | null;
variant: {
id: number;
attributes: PurchaseDetailAttributeResponse[];
};
} | null;
}
export interface PurchaseDetailResponse extends PurchaseStatusResponse {
@@ -89,36 +51,29 @@ export interface PurchaseDetailResponse extends PurchaseStatusResponse {
user_id: number;
created_at: string | null;
payment_method: string | null;
expires_at: string | null;
dni: string | null;
transfer_payer_dni: string | null;
payer_dni: string | null;
telefono: string | null;
nombre_apellido: string | null;
email: string | null;
items_source: 'purchase';
items_source: 'purchase' | 'cart' | null;
items: PurchaseDetailItemResponse[];
tickets_count?: number;
has_generated_tickets?: boolean;
subtotal: string;
total: string;
}
@Injectable({
providedIn: 'root',
providedIn: 'root'
})
export class CheckoutService extends BaseApiService {
async startCheckout(
tenantCode: string,
payload: StartCheckoutPayload,
): Promise<PurchaseDetailResponse> {
export class CheckoutService {
private readonly http = inject(HttpClient);
async createPurchase(tenantCode: string, payload: CreatePurchasePayload): Promise<{ id: number }> {
const response = await firstValueFrom(
this.http.post<{ data?: PurchaseDetailResponse } | PurchaseDetailResponse>(
`${environment.url}tenants/${tenantCode}/compras/start-checkout`,
payload,
),
this.http.post<{ data?: { id: number }; id?: number }>(`${environment.url}tenants/${tenantCode}/compras`, payload)
);
const purchase = this.extractResponseData<PurchaseDetailResponse>(response);
const purchase = this.extractResponseData<{ id: number }>(response);
if (!purchase?.id) {
throw new Error('Error al crear la compra.');
@@ -131,17 +86,13 @@ export class CheckoutService extends BaseApiService {
tenantCode: string,
purchaseId: number,
method: 'qr' | 'transfer' | 'telepagos',
payerDni?: string,
payerDni?: string
): Promise<any> {
const payload: any = { method };
if (payerDni) {
payload.transfer_payer_dni = payerDni;
}
const response = await firstValueFrom(
this.http.post<any>(
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/payment-intent`,
payload,
),
this.http.post<any>(`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/payment-intent`, {
method,
...(method === 'transfer' ? { payer_dni: payerDni } : {})
})
);
if (!response) {
throw new Error('Error al generar la intención de pago.');
@@ -149,72 +100,9 @@ export class CheckoutService extends BaseApiService {
return response;
}
async updateCustomerData(
tenantCode: string,
purchaseId: number,
payload: UpdatePurchaseCustomerPayload,
): Promise<PurchaseDetailResponse> {
const response = await firstValueFrom(
this.http.patch<{ data?: PurchaseDetailResponse } | PurchaseDetailResponse>(
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/customer-data`,
payload,
),
);
const purchase = this.extractResponseData<PurchaseDetailResponse>(response);
if (!purchase) {
throw new Error('Error al actualizar los datos de la compra.');
}
return purchase;
}
async updateItemQuantity(
tenantCode: string,
purchaseId: number,
itemId: number,
quantity: number,
): Promise<PurchaseDetailResponse> {
const response = await firstValueFrom(
this.http.patch<{ data?: PurchaseDetailResponse } | PurchaseDetailResponse>(
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/items/${itemId}`,
{ quantity },
),
);
const purchase = this.extractResponseData<PurchaseDetailResponse>(response);
if (!purchase) {
throw new Error('Error al actualizar la cantidad del producto.');
}
return purchase;
}
async prepareItemEditing(
tenantCode: string,
purchaseId: number,
): Promise<PurchaseDetailResponse> {
const response = await firstValueFrom(
this.http.post<{ data?: PurchaseDetailResponse } | PurchaseDetailResponse>(
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/edit-items`,
{},
),
);
const purchase = this.extractResponseData<PurchaseDetailResponse>(response);
if (!purchase) {
throw new Error('Error al preparar la compra para editarla.');
}
return purchase;
}
async completePurchase(tenantCode: string, purchaseId: number): Promise<PurchaseStatusResponse> {
const response = await firstValueFrom(
this.http.post<{ data?: PurchaseStatusResponse } | PurchaseStatusResponse>(
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/complete`,
{},
),
this.http.post<{ data?: PurchaseStatusResponse } | PurchaseStatusResponse>(`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/complete`, {})
);
const purchase = this.extractResponseData<PurchaseStatusResponse>(response);
@@ -224,68 +112,27 @@ export class CheckoutService extends BaseApiService {
}
return {
status: purchase.status ?? null,
status: purchase.status ?? null
};
}
async submitPurchaseForReview(
tenantCode: string,
purchaseId: number,
): Promise<PurchaseStatusResponse> {
const response = await firstValueFrom(
this.http.post<{ data?: PurchaseStatusResponse } | PurchaseStatusResponse>(
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/review`,
{},
),
);
const purchase = this.extractResponseData<PurchaseStatusResponse>(response);
if (!purchase) {
throw new Error('Error al enviar la compra a revisi\u00f3n.');
}
return { status: purchase.status ?? null };
}
async cancelPurchase(tenantCode: string, purchaseId: number): Promise<PurchaseStatusResponse> {
const response = await firstValueFrom(
this.http.post<{ data?: PurchaseStatusResponse } | PurchaseStatusResponse>(
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/cancel`,
{},
),
);
const purchase = this.extractResponseData<PurchaseStatusResponse>(response);
if (!purchase) {
throw new Error('Error al cancelar la compra.');
}
return { status: purchase.status ?? null };
}
async getPurchases(
tenantCode: string,
status?: string,
): Promise<{ data: PurchaseSummaryResponse[] }> {
async getPurchases(tenantCode: string, status?: string): Promise<{ data: PurchaseSummaryResponse[] }> {
let url = `${environment.url}tenants/${tenantCode}/compras`;
if (status) {
url += `?status=${status}`;
}
const response = await firstValueFrom(this.http.get<{ data: PurchaseSummaryResponse[] }>(url));
const response = await firstValueFrom(
this.http.get<{ data: PurchaseSummaryResponse[] }>(url)
);
if (!response) {
throw new Error('Error al obtener las compras.');
}
return response;
}
async getPurchase(
tenantCode: string,
purchaseId: string | number,
): Promise<PurchaseDetailResponse> {
async getPurchase(tenantCode: string, purchaseId: string | number): Promise<PurchaseDetailResponse> {
const response = await firstValueFrom(
this.http.get<{ data?: PurchaseDetailResponse } | PurchaseDetailResponse>(
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}`,
),
this.http.get<{ data?: PurchaseDetailResponse } | PurchaseDetailResponse>(`${environment.url}tenants/${tenantCode}/compras/${purchaseId}`)
);
const purchase = this.extractResponseData<PurchaseDetailResponse>(response);

View File

@@ -23,9 +23,12 @@ export class CookieService {
return null;
}
set(name: string, value: string): void {
set(name: string, value: string, days = 7): void {
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,80 +0,0 @@
import '@angular/compiler';
import { HttpClient, HttpContext, provideHttpClient, withInterceptors } from '@angular/common/http';
import {
HttpTestingController,
provideHttpClientTesting
} from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { globalLoadingInterceptor } from './global-loading.interceptor';
import { GlobalLoadingService } from './global-loading.service';
import { LOADING_MODE, LoadingMode } from './loading-mode';
describe('globalLoadingInterceptor', () => {
let client: HttpClient;
let httpTesting: HttpTestingController;
let loadingService: GlobalLoadingService;
beforeEach(() => {
vi.useFakeTimers();
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptors([globalLoadingInterceptor])),
provideHttpClientTesting()
]
});
client = TestBed.inject(HttpClient);
httpTesting = TestBed.inject(HttpTestingController);
loadingService = TestBed.inject(GlobalLoadingService);
});
afterEach(() => {
httpTesting.verify();
TestBed.resetTestingModule();
vi.useRealTimers();
});
it('tracks concurrent requests until the last one completes', () => {
client.get('/api/first').subscribe();
client.get('/api/second').subscribe();
vi.advanceTimersByTime(150);
expect(loadingService.isLoading()).toBe(true);
httpTesting.expectOne('/api/first').flush({});
expect(loadingService.isLoading()).toBe(true);
httpTesting.expectOne('/api/second').flush({});
expect(loadingService.isLoading()).toBe(false);
});
it('stops loading when a request fails', () => {
client.get('/api/failing').subscribe({ error: () => undefined });
vi.advanceTimersByTime(150);
expect(loadingService.isLoading()).toBe(true);
httpTesting.expectOne('/api/failing').flush(
{ message: 'Error' },
{ status: 500, statusText: 'Server Error' }
);
expect(loadingService.isLoading()).toBe(false);
});
it.each<LoadingMode>(['custom', 'none'])(
'does not show the global loader for %s requests',
(loadingMode) => {
const context = new HttpContext().set(LOADING_MODE, loadingMode);
client.get(`/api/${loadingMode}`, { context }).subscribe();
vi.advanceTimersByTime(150);
expect(loadingService.isLoading()).toBe(false);
httpTesting.expectOne(`/api/${loadingMode}`).flush({});
}
);
});

View File

@@ -1,20 +0,0 @@
import { HttpEvent, HttpHandlerFn, HttpInterceptorFn, HttpRequest } from '@angular/common/http';
import { inject } from '@angular/core';
import { Observable, finalize } from 'rxjs';
import { GlobalLoadingService } from './global-loading.service';
import { LOADING_MODE } from './loading-mode';
export const globalLoadingInterceptor: HttpInterceptorFn = (
request: HttpRequest<unknown>,
next: HttpHandlerFn
): Observable<HttpEvent<unknown>> => {
if (request.context.get(LOADING_MODE) !== 'global') {
return next(request);
}
const loadingService = inject(GlobalLoadingService);
loadingService.start();
return next(request).pipe(finalize(() => loadingService.stop()));
};

View File

@@ -1,65 +0,0 @@
import '@angular/compiler';
import { TestBed } from '@angular/core/testing';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { GlobalLoadingService } from './global-loading.service';
describe('GlobalLoadingService', () => {
let service: GlobalLoadingService;
beforeEach(() => {
vi.useFakeTimers();
TestBed.configureTestingModule({});
service = TestBed.inject(GlobalLoadingService);
});
afterEach(() => {
TestBed.resetTestingModule();
vi.useRealTimers();
});
it('delays the loading state to avoid flashes on fast operations', () => {
service.start();
expect(service.isLoading()).toBe(false);
service.stop();
vi.advanceTimersByTime(150);
expect(service.isLoading()).toBe(false);
});
it('shows the loading state for an operation that exceeds the delay', () => {
service.start();
vi.advanceTimersByTime(150);
expect(service.isLoading()).toBe(true);
service.stop();
expect(service.isLoading()).toBe(false);
});
it('stays visible until all concurrent operations finish', () => {
service.start();
service.start();
vi.advanceTimersByTime(150);
service.stop();
expect(service.isLoading()).toBe(true);
service.stop();
expect(service.isLoading()).toBe(false);
});
it('ignores extra stop calls without corrupting the counter', () => {
service.stop();
service.start();
vi.advanceTimersByTime(150);
expect(service.isLoading()).toBe(true);
service.stop();
expect(service.isLoading()).toBe(false);
});
});

View File

@@ -1,58 +0,0 @@
import { isPlatformBrowser } from '@angular/common';
import { Injectable, PLATFORM_ID, inject, signal } from '@angular/core';
const SHOW_DELAY_MS = 150;
@Injectable({ providedIn: 'root' })
export class GlobalLoadingService {
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
private readonly loadingState = signal(false);
private pendingOperations = 0;
private showTimer: ReturnType<typeof setTimeout> | undefined;
readonly isLoading = this.loadingState.asReadonly();
start(): void {
if (!this.isBrowser) {
return;
}
this.pendingOperations += 1;
if (this.pendingOperations > 1 || this.loadingState()) {
return;
}
this.showTimer = setTimeout(() => {
this.showTimer = undefined;
if (this.pendingOperations > 0) {
this.loadingState.set(true);
}
}, SHOW_DELAY_MS);
}
stop(): void {
if (!this.isBrowser || this.pendingOperations === 0) {
return;
}
this.pendingOperations -= 1;
if (this.pendingOperations > 0) {
return;
}
this.cancelShowTimer();
this.loadingState.set(false);
}
private cancelShowTimer(): void {
if (this.showTimer === undefined) {
return;
}
clearTimeout(this.showTimer);
this.showTimer = undefined;
}
}

View File

@@ -1,5 +0,0 @@
import { HttpContextToken } from '@angular/common/http';
export type LoadingMode = 'global' | 'custom' | 'none';
export const LOADING_MODE = new HttpContextToken<LoadingMode>(() => 'global');

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

View File

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

View File

@@ -9,95 +9,11 @@ export interface BankAccount {
cvu: string;
}
export interface WebsiteType {
codigo: string;
nombre: string;
primary_color: string | null;
secondary_color: string | null;
danger_color: string | null;
success_color: string | null;
site_logo: string | null;
}
export interface HeroConfig {
background_image_id?: string | null;
title_html?: string | null;
description_html?: string | null;
button_text?: string | null;
button_href?: string | null;
}
export interface AdditionalInfoConfig {
description?: string | null;
}
export interface EventDate {
id?: number;
date: string;
start_time: string;
end_time: string;
}
export interface EventConfig {
id?: number;
title?: string | null;
location?: string | null;
dates_text?: string | null;
dates?: EventDate[] | null;
contact?: SocialMedia[];
}
export interface ActiveEventDate {
id: number;
date: string;
time_start: string;
time_end: string;
}
export interface TenantEvent {
title: string;
location: string;
dates: ActiveEventDate[];
}
export interface WebsiteExtras {
carousel?: string[];
heroConfig?: HeroConfig | null;
additionalInfoConfig?: AdditionalInfoConfig | null;
[extraName: string]: unknown;
}
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 {
id: number;
codigo: string;
nombre: string;
dominio: string;
site_title?: string | null;
favicon?: string | null;
primary_color: string;
secondary_color: string;
danger_color: string;
@@ -106,24 +22,9 @@ export interface Tenant {
footer_bg_color: string;
header_logo: string;
footer_logo: string;
header_bg_image?: string | null;
footer_bg_image?: string | null;
website_type_code?: string | null;
website_type?: WebsiteType | null;
event_date_text?: string | null;
extras?: WebsiteExtras;
event?: TenantEvent | null;
selected_bank_account_id?: number | null;
selected_bank_account?: BankAccount | null;
search_product_layout?: 'row' | 'column_with_image' | 'column_with_cart' | 'ticket_selector';
search_group_layout?: 'paginated' | 'simple' | 'simple_vertical' | 'carousel' | 'single';
search_items_per_page?: number;
display_categories?: boolean;
display_seach_bar?: boolean;
display_cart?: boolean;
social_media?: SocialMedia[];
menues?: Menu[];
categories: Category[];
}
export type TenantBootstrapResponse = ApiResponse<Tenant>;

View File

@@ -2,10 +2,8 @@ import { PLATFORM_ID, REQUEST, RESPONSE_INIT, TransferState } from '@angular/cor
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TimeoutError } from 'rxjs';
import { environment } from '../../../environments/environment';
import { BOOTSTRAP_REQUEST_TIMEOUT_MS } from './bootstrap-request-timeout';
import { Tenant, TenantBootstrapResponse } from './tenant.interface';
import { TENANT_SSR_STATE_KEY } from './tenant-ssr-cache.store';
import { TenantService } from './tenant.service';
@@ -22,24 +20,11 @@ const tenant: Tenant = {
header_bg_color: '#313131',
footer_bg_color: '#313131',
header_logo: 'https://example.com/header.png',
footer_logo: 'https://example.com/footer.png',
categories: [
{
id: 1,
nombre: 'Remeras',
subcategories: [
{
id: 2,
nombre: 'Manga corta',
subcategories: [],
},
],
},
],
footer_logo: 'https://example.com/footer.png'
};
const tenantResponse: TenantBootstrapResponse = {
data: tenant,
data: tenant
};
describe('TenantService', () => {
@@ -53,16 +38,14 @@ describe('TenantService', () => {
it('requests the tenant bootstrap endpoint using the current hostname and stores the response', async () => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting(), TenantService],
providers: [provideHttpClient(), provideHttpClientTesting(), TenantService]
});
const service = TestBed.inject(TenantService);
const httpController = TestBed.inject(HttpTestingController);
const bootstrapPromise = service.bootstrap();
const request = httpController.expectOne(
`${environment.url}tenants/bootstrap?dominio=localhost&path=/`,
);
const request = httpController.expectOne(`${environment.url}tenants/bootstrap/localhost`);
expect(request.request.method).toBe('GET');
@@ -78,7 +61,7 @@ describe('TenantService', () => {
it('hydrates the tenant from TransferState without performing HTTP on the browser', async () => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting(), TenantService],
providers: [provideHttpClient(), provideHttpClientTesting(), TenantService]
});
const service = TestBed.inject(TenantService);
@@ -87,7 +70,7 @@ describe('TenantService', () => {
transferState.set(TENANT_SSR_STATE_KEY, {
status: 'ready',
tenant,
tenant
});
await expect(service.bootstrap()).resolves.toBeUndefined();
@@ -113,12 +96,12 @@ describe('TenantService', () => {
useValue: new Request('https://internal.example/render', {
headers: {
'x-forwarded-host': 'STORE.EXAMPLE.COM:443, proxy.internal',
host: 'ignored.example.com',
},
}),
host: 'ignored.example.com'
}
})
},
{ provide: RESPONSE_INIT, useValue: responseInit },
],
{ provide: RESPONSE_INIT, useValue: responseInit }
]
});
const service = TestBed.inject(TenantService);
@@ -126,9 +109,7 @@ describe('TenantService', () => {
const httpController = TestBed.inject(HttpTestingController);
const bootstrapPromise = service.bootstrap();
const request = httpController.expectOne(
`${environment.url}tenants/bootstrap?dominio=store.example.com&path=/render`,
);
const request = httpController.expectOne(`${environment.url}tenants/bootstrap/store.example.com`);
request.flush(tenantResponse);
@@ -137,7 +118,7 @@ describe('TenantService', () => {
expect(service.tenant()).toEqual(tenant);
expect(transferState.get(TENANT_SSR_STATE_KEY, null)).toEqual({
status: 'ready',
tenant,
tenant
});
expect(responseInit.status).toBeUndefined();
@@ -157,12 +138,12 @@ describe('TenantService', () => {
provide: REQUEST,
useValue: new Request('https://internal.example/render', {
headers: {
host: 'missing.example.com:8443',
},
}),
host: 'missing.example.com:8443'
}
})
},
{ provide: RESPONSE_INIT, useValue: responseInit },
],
{ provide: RESPONSE_INIT, useValue: responseInit }
]
});
const service = TestBed.inject(TenantService);
@@ -170,9 +151,7 @@ describe('TenantService', () => {
const httpController = TestBed.inject(HttpTestingController);
const bootstrapPromise = service.bootstrap();
const request = httpController.expectOne(
`${environment.url}tenants/bootstrap?dominio=missing.example.com&path=/render`,
);
const request = httpController.expectOne(`${environment.url}tenants/bootstrap/missing.example.com`);
request.flush({ message: 'Not Found' }, { status: 404, statusText: 'Not Found' });
@@ -181,7 +160,7 @@ describe('TenantService', () => {
expect(service.tenant()).toBeNull();
expect(service.getTenant()).toBeNull();
expect(transferState.get(TENANT_SSR_STATE_KEY, null)).toEqual({
status: 'not-found',
status: 'not-found'
});
expect(responseInit.status).toBe(404);
@@ -190,16 +169,14 @@ describe('TenantService', () => {
it('rejects when the bootstrap response does not contain tenant data', async () => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting(), TenantService],
providers: [provideHttpClient(), provideHttpClientTesting(), TenantService]
});
const service = TestBed.inject(TenantService);
const httpController = TestBed.inject(HttpTestingController);
const bootstrapPromise = service.bootstrap();
const request = httpController.expectOne(
`${environment.url}tenants/bootstrap?dominio=localhost&path=/`,
);
const request = httpController.expectOne(`${environment.url}tenants/bootstrap/localhost`);
request.flush({});
@@ -223,21 +200,19 @@ describe('TenantService', () => {
provide: REQUEST,
useValue: new Request('https://internal.example/render', {
headers: {
host: 'broken.example.com',
},
}),
host: 'broken.example.com'
}
})
},
{ provide: RESPONSE_INIT, useValue: responseInit },
],
{ provide: RESPONSE_INIT, useValue: responseInit }
]
});
const service = TestBed.inject(TenantService);
const httpController = TestBed.inject(HttpTestingController);
const bootstrapPromise = service.bootstrap();
const request = httpController.expectOne(
`${environment.url}tenants/bootstrap?dominio=broken.example.com&path=/render`,
);
const request = httpController.expectOne(`${environment.url}tenants/bootstrap/broken.example.com`);
request.flush({ message: 'Boom' }, { status: 500, statusText: 'Server Error' });
@@ -247,39 +222,4 @@ describe('TenantService', () => {
httpController.verify();
});
it('rejects and cancels a tenant bootstrap request that exceeds the configured timeout', async () => {
vi.useFakeTimers();
try {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
TenantService,
{ provide: BOOTSTRAP_REQUEST_TIMEOUT_MS, useValue: 25 },
],
});
const service = TestBed.inject(TenantService);
const httpController = TestBed.inject(HttpTestingController);
const bootstrapPromise = service.bootstrap();
const rejection = expect(bootstrapPromise).rejects.toBeInstanceOf(TimeoutError);
const request = httpController.expectOne(
`${environment.url}tenants/bootstrap?dominio=localhost&path=/`,
);
await vi.advanceTimersByTimeAsync(25);
await rejection;
expect(request.cancelled).toBe(true);
expect(service.status()).toBe('idle');
expect(service.tenant()).toBeNull();
httpController.verify();
} finally {
vi.useRealTimers();
}
});
});

View File

@@ -1,5 +1,5 @@
import { isPlatformBrowser, isPlatformServer } from '@angular/common';
import { HttpErrorResponse } from '@angular/common/http';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import {
inject,
Injectable,
@@ -7,31 +7,29 @@ import {
REQUEST,
RESPONSE_INIT,
signal,
TransferState,
TransferState
} from '@angular/core';
import { IS_DISCOVERING_ROUTES } from '@angular/ssr';
import { firstValueFrom, timeout } from 'rxjs';
import { firstValueFrom } from 'rxjs';
import { environment } from '../../../environments/environment';
import { BaseApiService } from './base-api.service';
import { BOOTSTRAP_REQUEST_TIMEOUT_MS } from './bootstrap-request-timeout';
import { Tenant, TenantBootstrapResponse } from './tenant.interface';
import {
TENANT_SSR_STATE_KEY,
TenantBootstrapStatus,
TenantSsrState,
TenantSsrState
} from './tenant-ssr-cache.store';
@Injectable({
providedIn: 'root',
providedIn: 'root'
})
export class TenantService extends BaseApiService {
export class TenantService {
private readonly http = inject(HttpClient);
private readonly platformId = inject(PLATFORM_ID);
private readonly request = inject(REQUEST, { optional: true });
private readonly responseInit = inject(RESPONSE_INIT, { optional: true });
private readonly transferState = inject(TransferState);
private readonly isDiscoveringRoutes = inject(IS_DISCOVERING_ROUTES, { optional: true }) ?? false;
private readonly bootstrapRequestTimeoutMs = inject(BOOTSTRAP_REQUEST_TIMEOUT_MS);
private readonly tenantState = signal<Tenant | null>(null);
private readonly statusState = signal<TenantBootstrapStatus>('idle');
@@ -50,6 +48,7 @@ export class TenantService extends BaseApiService {
return `${environment.url}tenants/${tenant.codigo}`;
}
async bootstrap(): Promise<void> {
if (this.statusState() !== 'idle') {
return;
@@ -70,18 +69,11 @@ export class TenantService extends BaseApiService {
return;
}
const { domain, path } = this.resolveRequestContext();
const domain = this.resolveDomain();
try {
const response = await firstValueFrom(
this.http
.get<TenantBootstrapResponse>(`${environment.url}tenants/bootstrap`, {
params: {
dominio: domain,
path,
},
})
.pipe(timeout(this.bootstrapRequestTimeoutMs)),
this.http.get<TenantBootstrapResponse>(`${environment.url}tenants/bootstrap/${domain}`)
);
if (!response?.data) {
@@ -91,7 +83,7 @@ export class TenantService extends BaseApiService {
this.setReady(response.data);
this.persistSsrState({
status: 'ready',
tenant: response.data,
tenant: response.data
});
} catch (error) {
if (this.isNotFoundError(error)) {
@@ -109,7 +101,7 @@ export class TenantService extends BaseApiService {
}
}
private resolveRequestContext(): { domain: string; path: string } {
private resolveDomain(): string {
if (isPlatformBrowser(this.platformId)) {
const domain = window.location.hostname;
@@ -117,10 +109,7 @@ export class TenantService extends BaseApiService {
throw new Error('Tenant bootstrap could not resolve the current domain.');
}
return {
domain: this.normalizeHost(domain),
path: this.normalizePath(window.location.pathname),
};
return this.normalizeHost(domain);
}
const requestHost =
@@ -132,14 +121,7 @@ export class TenantService extends BaseApiService {
throw new Error('Tenant bootstrap could not resolve the current domain.');
}
const forwardedPath =
this.request?.headers.get('x-forwarded-uri') ?? this.request?.headers.get('x-original-uri');
const requestPath = forwardedPath ?? (this.request ? new URL(this.request.url).pathname : '/');
return {
domain: this.normalizeHost(requestHost),
path: this.normalizePath(requestPath),
};
return this.normalizeHost(requestHost);
}
private normalizeHost(host: string): string {
@@ -161,16 +143,6 @@ export class TenantService extends BaseApiService {
return normalizedHost.replace(/:\d+$/, '');
}
private normalizePath(path: string): string {
const pathname = path.split(/[?#]/, 1)[0]?.trim() ?? '';
if (!pathname || pathname === '/') {
return '/';
}
return `/${pathname.replace(/^\/+|\/+$/g, '')}`;
}
private applyState(state: TenantSsrState): void {
if (state.status === 'ready') {
this.setReady(state.tenant);

View File

@@ -3,7 +3,9 @@
<div class="card-body">
<span class="eyebrow">Componentes reutilizables</span>
<h1>Button + Input</h1>
<p class="lead mb-4">Pagina dedicada a probar variantes de los componentes compartidos.</p>
<p class="lead mb-4">
Pagina dedicada a probar variantes de los componentes compartidos.
</p>
<div class="button-showcase">
<div class="button-group">
@@ -24,7 +26,9 @@
<app-button variant="secondary" [disabled]="true">Secondary</app-button>
<app-button variant="danger" [disabled]="true">Danger</app-button>
<app-button variant="danger-secondary" [disabled]="true"> Danger secondary </app-button>
<app-button variant="danger-secondary" [disabled]="true">
Danger secondary
</app-button>
<app-button variant="cancel" [disabled]="true">Cancel</app-button>
</div>
</div>
@@ -56,8 +60,13 @@
<div class="button-group">
<h2>Toasts Persistentes (No se van)</h2>
<div class="button-grid">
<app-button (click)="triggerPersistentToast('info')"> Info Persistente </app-button>
<app-button variant="secondary" (click)="triggerPersistentToast('success')">
<app-button (click)="triggerPersistentToast('info')">
Info Persistente
</app-button>
<app-button
variant="secondary"
(click)="triggerPersistentToast('success')"
>
Success Persistente
</app-button>
<app-button variant="danger" (click)="triggerPersistentToast('danger')">
@@ -82,7 +91,9 @@
<app-button variant="danger-secondary" (click)="openWideModal()">
Abrir modal ancho
</app-button>
<app-button (click)="openSimpleModal()"> Abrir simple modal </app-button>
<app-button (click)="openSimpleModal()">
Abrir simple modal
</app-button>
</div>
<div class="modal-showcase__result" data-testid="modal-last-result">
{{ lastModalResult }}
@@ -95,9 +106,9 @@
<div class="icon-button-showcase">
<h2>Icon Buttons</h2>
<p class="text-muted mb-4">
Botones de icono pequenos con color base <code>#666666</code>, deshabilitados con
<code>#A0A0A0</code> y estados hover/active con color de marca (a excepcion de trash que
usa danger).
Botones de icono pequenos con color base <code>#666666</code>,
deshabilitados con <code>#A0A0A0</code> y estados hover/active con color
de marca (a excepcion de trash que usa danger).
</p>
<div class="icon-button-showcase__grid">
@@ -144,7 +155,9 @@
<div class="icon-button-showcase mt-4">
<h2>Cart Icons</h2>
<p class="text-muted mb-4">Icono de carrito con badge opcional indicando cantidad.</p>
<p class="text-muted mb-4">
Icono de carrito con badge opcional indicando cantidad.
</p>
<div class="icon-button-showcase__grid">
<div class="icon-button-showcase__row">
@@ -187,7 +200,12 @@
<div class="input-field">
<label for="number-input">Numero</label>
<app-input id="number-input" type="number" placeholder="0" [(value)]="numberValue" />
<app-input
id="number-input"
type="number"
placeholder="0"
[(value)]="numberValue"
/>
</div>
<div class="input-field">
@@ -202,7 +220,12 @@
<div class="input-field">
<label for="user-input">Usuario</label>
<app-input id="user-input" type="user" placeholder="Nombre de usuario" [(value)]="user" />
<app-input
id="user-input"
type="user"
placeholder="Nombre de usuario"
[(value)]="user"
/>
</div>
<div class="input-field">
@@ -227,12 +250,22 @@
<div class="input-field">
<label for="amount-input">Moneda</label>
<app-input id="amount-input" type="currency" placeholder="0" [(value)]="amount" />
<app-input
id="amount-input"
type="currency"
placeholder="0"
[(value)]="amount"
/>
</div>
<div class="input-field">
<label for="percent-input">Porcentaje</label>
<app-input id="percent-input" type="percent" placeholder="0" [(value)]="percent" />
<app-input
id="percent-input"
type="percent"
placeholder="0"
[(value)]="percent"
/>
</div>
<div class="input-field">
@@ -280,7 +313,9 @@
/>
</div>
<app-button variant="secondary" (click)="clearFile()"> Limpiar archivo </app-button>
<app-button variant="secondary" (click)="clearFile()">
Limpiar archivo
</app-button>
</div>
</div>
@@ -289,7 +324,8 @@
<div class="paginator-showcase">
<h2>Paginator</h2>
<p class="text-muted mb-0">
Componente shared para navegar por paginas con primera, anterior, siguiente y ultima.
Componente shared para navegar por paginas con primera, anterior,
siguiente y ultima.
</p>
<app-paginator
[page]="currentPage"
@@ -303,11 +339,11 @@
<div class="stepper-showcase">
<h2>Form Stepper</h2>
<p class="text-muted mb-4">
Stepper vertical reutilizable. Cada paso maneja sus propios botones de navegacion. No se
puede avanzar si el paso no es valido.
Stepper vertical reutilizable. Cada paso maneja sus propios botones de navegacion. No se puede avanzar si el paso no es valido.
</p>
<app-stepper #demoStepper>
<!-- Paso 1: Siempre valido -->
<app-step label="Informacion Personal" [isValid]="true">
<div class="row g-3">
@@ -317,12 +353,7 @@
</div>
<div class="col-md-6 input-field">
<label for="stepper-email">Email</label>
<app-input
id="stepper-email"
type="email"
placeholder="mail@empresa.com"
[(value)]="email"
/>
<app-input id="stepper-email" type="email" placeholder="mail@empresa.com" [(value)]="email" />
</div>
</div>
<div class="d-flex justify-content-end mt-4">
@@ -337,12 +368,7 @@
<div class="row g-3">
<div class="col-md-6 input-field">
<label for="stepper-password">Contraseña</label>
<app-input
id="stepper-password"
type="password-toggle"
placeholder="********"
[(value)]="password"
/>
<app-input id="stepper-password" type="password-toggle" placeholder="********" [(value)]="password" />
</div>
<div class="col-md-6 input-field">
<label for="stepper-amount">Presupuesto mensual</label>
@@ -351,7 +377,9 @@
</div>
<div class="stepper-validity-control mt-3 d-flex align-items-center gap-3">
<span class="text-muted small"> Simular validez del paso: </span>
<span class="text-muted small">
Simular validez del paso:
</span>
<app-button
[variant]="testStep2Valid ? 'secondary' : 'primary'"
(click)="testStep2Valid = !testStep2Valid"
@@ -390,49 +418,12 @@
</app-button>
</div>
</app-step>
</app-stepper>
</div>
<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__header">
<div>
@@ -463,6 +454,7 @@
</div>
}
</div>
</div>
</section>
@@ -612,73 +604,20 @@
</div>
</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">
<div class="card-body">
<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">
Las tarjetas se adaptan al tamano del contenedor padre. A continuacion se presentan dentro
de una grilla de Bootstrap.
Las tarjetas se adaptan al tamano del contenedor padre. A continuacion se
presentan dentro de una grilla de Bootstrap.
</p>
<div class="row">
@for (product of testProducts; track product.title) {
<div class="col-12 col-md-6 col-lg-4 mb-4 d-flex align-items-stretch">
<app-product-column-with-image
<app-product-card
[imageUrl]="product.imageUrl"
[title]="product.title"
[originalPrice]="product.originalPrice"
@@ -692,98 +631,16 @@
</div>
</section>
<section class="component-demo card shadow-sm mt-4" data-testid="location-map-demo">
<div class="card-body">
<span class="eyebrow">Reusable Component</span>
<h2 class="mb-2">Mapa de ubicaciones</h2>
<p class="text-muted mb-4">
Prueba de Leaflet con múltiples puntos, encuadre automático y acceso a indicaciones.
</p>
<app-location-map
[locations]="mapLocations"
[markerColor]="tenant()?.primary_color ?? '#24a9e0'"
mapStyle="minimal-light"
ariaLabel="Mapa de puntos de atención en Rosario"
/>
</div>
</section>
<section class="component-demo card shadow-sm mt-4">
<div class="card-body">
<span class="eyebrow">Reusable Component</span>
<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>
<section
class="component-demo component-demo--ticket-selector card shadow-sm mt-4"
data-testid="ticket-selector-demo"
<app-store-section
title="PRODUCTOS"
class="component-demo store-section-demo mt-5"
>
<div class="card-body">
<span class="eyebrow">Reusable Component</span>
<h2 class="mb-2">Selector de entradas (Ticket Selector Layout)</h2>
<p class="text-muted mb-4">
Demo de selección encadenada por tipo, sector, fila y asiento. Las combinaciones sin stock
no aparecen como disponibles.
</p>
<app-product-ticket-selector
[productId]="1"
[title]="testTicketSelectorProduct.title"
[description]="testTicketSelectorProduct.description"
[price]="testTicketSelectorProduct.price"
[imageUrl]="testTicketSelectorProduct.imageUrl"
(buy)="onTicketBuy($event)"
/>
</div>
</section>
<app-store-section title="PRODUCTOS" class="component-demo store-section-demo mt-5">
<div class="row">
@for (product of testProducts; track product.title) {
<div
class="col-12 col-md-6 col-lg-4 mb-4 d-flex align-items-stretch store-section-demo__item"
>
<app-product-column-with-image
<app-product-card
[imageUrl]="product.imageUrl"
[title]="product.title"
[originalPrice]="product.originalPrice"
@@ -796,3 +653,4 @@
</div>
</app-store-section>
</main>

View File

@@ -15,40 +15,10 @@
border: 0;
}
.component-demo--ticket-selector {
width: min(100%, 76rem);
}
.store-section-demo {
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 {
display: inline-block;
margin-bottom: 0.75rem;
@@ -81,7 +51,7 @@ h1 {
.modal-showcase__result {
padding: 0.875rem 1rem;
border: 1px dashed var(--border-color);
border: 1px dashed rgba(32, 32, 32, 0.14);
border-radius: 0.875rem;
background: linear-gradient(180deg, rgba(248, 248, 248, 0.92), rgba(255, 255, 255, 0.98));
color: #495057;
@@ -160,7 +130,7 @@ h1 {
}
.store-section-demo__item {
app-product-column-with-image {
app-product-card {
width: 100%;
}
}
@@ -185,6 +155,7 @@ h1 {
gap: 0.75rem;
}
.file-field {
display: grid;
gap: 0.75rem;
@@ -197,10 +168,6 @@ h1 {
}
@media (max-width: 767.98px) {
.carousel-product {
width: min(18rem, calc(100vw - 5rem));
}
.input-grid {
grid-template-columns: minmax(0, 1fr);
}
@@ -235,19 +202,15 @@ h1 {
justify-content: center;
height: 120px;
border-radius: 8px;
border: 1px solid var(--border-color);
border: 1px solid #dee2e6;
padding: 1rem;
overflow: hidden;
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.05);
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
transition: transform 0.2s ease, box-shadow 0.2s ease;
&:hover {
transform: translateY(-2px);
box-shadow:
inset 0 2px 4px rgba(0, 0, 0, 0.05),
0 4px 12px rgba(0, 0, 0, 0.08);
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.05), 0 4px 12px rgba(0, 0, 0, 0.08);
}
}
@@ -285,12 +248,10 @@ h1 {
display: flex;
flex-direction: column;
border-radius: 8px;
border: 1px solid var(--border-color);
border: 1px solid #dee2e6;
overflow: hidden;
background-color: #ffffff;
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
transition: transform 0.2s ease, box-shadow 0.2s ease;
&:hover {
transform: translateY(-3px);
@@ -300,7 +261,7 @@ h1 {
&__preview {
height: 80px;
width: 100%;
border-bottom: 1px solid var(--border-color);
border-bottom: 1px solid #dee2e6;
transition: opacity 0.2s ease;
&:hover {
@@ -341,7 +302,7 @@ h1 {
background-color: #eaeaea;
padding: 1.5rem;
border-radius: 8px;
border: 1px solid var(--border-color);
border: 1px solid #dee2e6;
}
&__row {

View File

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

View File

@@ -1,42 +1,20 @@
import { Component, inject } from '@angular/core';
import { ButtonComponent } from '../../../../shared/components/button/button.component';
import { CartComponent, CartItemMock } from '../../../../shared/components/cart/cart.component';
import {
CartComponent,
CartItemMock
} from '../../../../shared/components/cart/cart.component';
import { CartIconComponent } from '../../../../shared/components/cart-icon/cart-icon.component';
import { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.component';
import { InputComponent } from '../../../../shared/components/input/input.component';
import { PaginatorComponent } from '../../../../shared/components/paginator/paginator.component';
import { ProductColumnWithImageComponent } from '../../../../shared/components/product-column-with-image/product-column-with-image.component';
import { ProductRowCardComponent } from '../../../../shared/components/product-row-card/product-row-card.component';
import { ProductTicketSelectorComponent } from '../../../../shared/components/product-ticket-selector/product-ticket-selector.component';
import { ProductVerticalWithCartCardComponent } from '../../../../shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component';
import { ProductCardComponent } from '../../../../shared/components/product-card/product-card.component';
import { StoreSectionComponent } from '../../../../shared/components/store-section/store-section.component';
import { ModalService } from '../../../../core/services/modal.service';
import { TenantService } from '../../../../core/services/tenant.service';
import { ToastService } from '../../../../core/services/toast.service';
import { StepperComponent } from '../../../../shared/components/stepper/stepper.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;
}
const ticketOption = (value: string, label: string = value): { value: string; label: string } => ({
value,
label,
});
@Component({
selector: 'app-reutilizables-test-page',
@@ -45,23 +23,15 @@ const ticketOption = (value: string, label: string = value): { value: string; la
CartComponent,
InputComponent,
PaginatorComponent,
ProductColumnWithImageComponent,
ProductRowCardComponent,
ProductTicketSelectorComponent,
ProductVerticalWithCartCardComponent,
ProductCardComponent,
StoreSectionComponent,
IconButtonComponent,
CartIconComponent,
StepperComponent,
StepComponent,
CarouselComponent,
MainCarouselComponent,
AccordionComponent,
AccordionItemComponent,
LocationMapComponent,
StepComponent
],
templateUrl: './reutilizables-test-page.component.html',
styleUrl: './reutilizables-test-page.component.scss',
styleUrl: './reutilizables-test-page.component.scss'
})
export class ReutilizablesTestPageComponent {
private readonly tenantService = inject(TenantService);
@@ -91,39 +61,6 @@ export class ReutilizablesTestPageComponent {
protected lastModalResult = 'Todavia no se abrio ningun modal.';
protected readonly paginatorTotalPages = 8;
protected readonly cartBackgroundColor = '#ffffff';
protected readonly mapLocations: readonly MapLocation[] = [
{
id: 'gigante-de-arroyito',
label: 'Gigante de Arroyito',
address: 'Av. Génova 640, Rosario',
latitude: -32.913997,
longitude: -60.674567,
},
{
id: 'punto-centro',
label: 'Punto Centro',
address: 'Centro, Rosario',
latitude: -32.94682,
longitude: -60.63932,
},
{
id: 'punto-sur',
label: 'Punto Sur',
address: 'Zona sur, Rosario',
latitude: -32.97151,
longitude: -60.65064,
},
];
protected readonly mainCarouselImages = [
'https://images.unsplash.com/photo-1441986300917-64674bd600d8?auto=format&fit=crop&w=1600&q=80',
'https://images.unsplash.com/photo-1529139574466-a303027c1d8b?auto=format&fit=crop&w=1600&q=80',
'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 = [
{
@@ -131,217 +68,24 @@ export class ReutilizablesTestPageComponent {
originalPrice: 95000,
discount: 20,
transferPrice: 74800,
imageUrl: '/images/pantalon-scuba.png',
imageUrl: '/images/pantalon-scuba.png'
},
{
title: 'Zapatillas de running azul',
originalPrice: 120000,
discount: 15,
transferPrice: 98000,
imageUrl: '/images/zapatillas-running.png',
imageUrl: '/images/zapatillas-running.png'
},
{
title: 'Remera basica blanca (Sin Descuento)',
originalPrice: 32000,
discount: null,
transferPrice: 30000,
imageUrl: null,
},
imageUrl: null
}
];
protected readonly 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: [
{ id: '10-oct-manana', values: { fecha: '10 de Octubre', turno: 'Mañana' } },
{ id: '10-oct-tarde', values: { fecha: '10 de Octubre', turno: 'Tarde' } },
{ id: '11-oct-tarde', values: { fecha: '11 de Octubre', turno: 'Tarde' } },
],
};
protected readonly testVerticalWithCartProduct = {
title: 'PANCHO',
description: 'Pancho con aderezo a elección.',
price: 11500,
};
protected readonly testTicketSelectorProduct = {
title: 'ENTRADAS DESFILE PURA TENDENCIA',
description: '',
price: 40000,
imageUrl: '/images/ticket-selector-entrada-pasarela.png',
variants: [
{
id: 1001,
precio: 250000,
stock_tecnico: 1,
values: {
tipo: ticketOption('vip_lunch', 'VIP + Lunch'),
sector: ticketOption('a', 'Sector A'),
fila: ticketOption('1', 'Fila 1'),
asiento: ticketOption('1', 'Asiento 1'),
},
},
{
id: 1002,
precio: 250000,
stock_tecnico: 1,
values: {
tipo: ticketOption('vip_lunch', 'VIP + Lunch'),
sector: ticketOption('a', 'Sector A'),
fila: ticketOption('1', 'Fila 1'),
asiento: ticketOption('2', 'Asiento 2'),
},
},
{
id: 1003,
precio: 250000,
stock_tecnico: 1,
values: {
tipo: ticketOption('vip_lunch', 'VIP + Lunch'),
sector: ticketOption('c', 'Sector C'),
fila: ticketOption('1', 'Fila 1'),
asiento: ticketOption('1', 'Asiento 1'),
},
},
{
id: 1004,
precio: 200000,
stock_tecnico: 1,
values: {
tipo: ticketOption('vip_lunch', 'VIP + Lunch'),
sector: ticketOption('a', 'Sector A'),
fila: ticketOption('2', 'Fila 2'),
asiento: ticketOption('1', 'Asiento 1'),
},
},
{
id: 1005,
precio: 200000,
stock_tecnico: 0,
values: {
tipo: ticketOption('vip_lunch', 'VIP + Lunch'),
sector: ticketOption('a', 'Sector A'),
fila: ticketOption('2', 'Fila 2'),
asiento: ticketOption('2', 'Asiento 2'),
},
},
{
id: 1006,
precio: 100000,
stock_tecnico: 1,
values: {
tipo: ticketOption('general', 'General'),
sector: ticketOption('b', 'Sector B'),
fila: ticketOption('3', 'Fila 3'),
asiento: ticketOption('1', 'Asiento 1'),
},
},
{
id: 1007,
precio: 100000,
stock_tecnico: 1,
values: {
tipo: ticketOption('general', 'General'),
sector: ticketOption('b', 'Sector B'),
fila: ticketOption('3', 'Fila 3'),
asiento: ticketOption('2', 'Asiento 2'),
},
},
{
id: 1008,
precio: 90000,
stock_tecnico: 1,
values: {
tipo: ticketOption('general', 'General'),
sector: ticketOption('d', 'Sector D'),
fila: ticketOption('3', 'Fila 3'),
asiento: ticketOption('1', 'Asiento 1'),
},
},
{
id: 1009,
precio: 65000,
stock_tecnico: 1,
values: {
tipo: ticketOption('general', 'General'),
sector: ticketOption('d', 'Sector D'),
fila: ticketOption('4', 'Fila 4'),
asiento: ticketOption('1', 'Asiento 1'),
},
},
{
id: 1010,
precio: 40000,
stock_tecnico: 1,
values: {
tipo: ticketOption('general', 'General'),
sector: ticketOption('d', 'Sector D'),
fila: ticketOption('5', 'Fila 5'),
asiento: ticketOption('1', 'Asiento 1'),
},
},
],
};
protected readonly cartMockItems: CartItemMock[] = [
{
imageUrl: null,
@@ -352,8 +96,8 @@ export class ReutilizablesTestPageComponent {
quantity: 1,
attributes: [
{ label: 'Color', value: 'Beige' },
{ label: 'Talle', value: 'S' },
],
{ label: 'Talle', value: 'S' }
]
},
{
imageUrl: null,
@@ -364,8 +108,8 @@ export class ReutilizablesTestPageComponent {
quantity: 1,
attributes: [
{ label: 'Color', value: 'Beige' },
{ label: 'Talle', value: 'S' },
],
{ label: 'Talle', value: 'S' }
]
},
{
imageUrl: null,
@@ -376,8 +120,8 @@ export class ReutilizablesTestPageComponent {
quantity: 1,
attributes: [
{ label: 'Color', value: 'Negro' },
{ label: 'Talle', value: 'XL' },
],
{ label: 'Talle', value: 'XL' }
]
},
{
imageUrl: null,
@@ -388,9 +132,9 @@ export class ReutilizablesTestPageComponent {
quantity: 2,
attributes: [
{ label: 'Color', value: 'Gris' },
{ label: 'Talle', value: 'M' },
],
},
{ label: 'Talle', value: 'M' }
]
}
];
protected onProductBuy(productTitle: string): void {
@@ -398,12 +142,6 @@ export class ReutilizablesTestPageComponent {
alert(`Comprando: ${productTitle}`);
}
protected onTicketBuy(variantIds: number[]): void {
this.toastService.success(
`Compra iniciada para ${variantIds.length} entrada/s: ${variantIds.join(', ')}.`,
);
}
protected onFileChange(file: File | null): void {
this.fileName = file?.name ?? '';
}
@@ -424,32 +162,45 @@ export class ReutilizablesTestPageComponent {
protected triggerToast(type: 'success' | 'danger' | 'info'): void {
if (type === 'success') {
this.toastService.success('Operacion exitosa. El producto se ha guardado correctamente.');
this.toastService.success(
'Operacion exitosa. El producto se ha guardado correctamente.'
);
} else if (type === 'danger') {
this.toastService.danger('Alerta de error. Hubo un problema al procesar la solicitud.');
this.toastService.danger(
'Alerta de error. Hubo un problema al procesar la solicitud.'
);
} else if (type === 'info') {
this.toastService.info('Informacion del sistema: Hay una nueva actualizacion disponible.');
this.toastService.info(
'Informacion del sistema: Hay una nueva actualizacion disponible.'
);
}
}
protected triggerPersistentToast(type: 'success' | 'danger' | 'info'): void {
if (type === 'success') {
this.toastService.success('Exito persistente. Esta alerta no se cerrara sola.', 0);
this.toastService.success(
'Exito persistente. Esta alerta no se cerrara sola.',
0
);
} else if (type === 'danger') {
this.toastService.danger(
'Error persistente. Por favor, atienda este problema y cierrelo manualmente.',
0,
0
);
} else if (type === 'info') {
this.toastService.info('Informacion persistente: Mantendremos este aviso en pantalla.', 0);
this.toastService.info(
'Informacion persistente: Mantendremos este aviso en pantalla.',
0
);
}
}
protected openBasicModal(): void {
this.openConfirmModal({
title: 'Confirmar accion',
content: 'Caso base para verificar apertura, cierre y devolucion de resultado.',
confirmLabel: 'Confirmar',
content:
'Caso base para verificar apertura, cierre y devolucion de resultado.',
confirmLabel: 'Confirmar'
});
}
@@ -458,17 +209,18 @@ export class ReutilizablesTestPageComponent {
title: 'Eliminar producto',
content:
'Esta accion eliminara el producto del catalogo. Podras volver a crearlo manualmente.',
confirmLabel: 'Eliminar',
confirmLabel: 'Eliminar'
});
}
protected openLockedModal(): void {
this.openConfirmModal({
title: 'Modal bloqueado',
content: 'Este modal no se cierra tocando el backdrop ni con la tecla Escape.',
content:
'Este modal no se cierra tocando el backdrop ni con la tecla Escape.',
confirmLabel: 'Entendido',
closeOnBackdrop: false,
closeOnEscape: false,
closeOnEscape: false
});
}
@@ -477,20 +229,18 @@ export class ReutilizablesTestPageComponent {
title: 'Modal ancho',
content: 'Demuestra una variante visual mas amplia para contenido mas pesado.',
confirmLabel: 'Seguir',
size: 'xl',
size: 'xl'
});
}
protected openSimpleModal(): void {
this.modalService
.openSimple({
title: 'Mensaje del sistema',
content: 'Este es un mensaje simple del sistema que no requiere confirmación.',
buttonLabel: 'Entendido',
})
.subscribe(() => {
this.lastModalResult = 'Simple modal cerrado';
});
this.modalService.openSimple({
title: 'Mensaje del sistema',
content: 'Este es un mensaje simple del sistema que no requiere confirmación.',
buttonLabel: 'Entendido'
}).subscribe(() => {
this.lastModalResult = 'Simple modal cerrado';
});
}
private openConfirmModal(config: Parameters<ModalService['openConfirm']>[0]): void {
@@ -499,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.lastModalResult = `Resultado: ${confirmed}`;
});
}
}

View File

@@ -1,15 +0,0 @@
<section class="menu-content-section" [attr.aria-labelledby]="titleId()">
<header class="menu-content-section__header">
<div class="menu-content-section__prefix">
<ng-content select="[menuContentPrefix]" />
</div>
<h2 class="menu-content-section__title" [id]="titleId()">
{{ title() | uppercase }}
</h2>
</header>
<div class="menu-content-section__body">
<ng-content />
</div>
</section>

View File

@@ -1,53 +0,0 @@
:host {
display: block;
width: 100%;
min-width: 0;
}
.menu-content-section {
width: 100%;
}
.menu-content-section__header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.25rem;
}
.menu-content-section__prefix:empty {
display: none;
}
.menu-content-section__title {
margin: 0;
color: #888888;
font-size: 1rem;
font-weight: 600;
line-height: 1.3;
}
.menu-content-section__body {
min-width: 0;
}
@media (max-width: 767.98px) {
.menu-content-section {
margin-inline: auto;
}
.menu-content-section__header {
position: relative;
justify-content: center;
}
.menu-content-section__prefix:not(:empty) {
position: absolute;
left: 0;
}
.menu-content-section__title {
width: 100%;
text-align: center;
}
}

View File

@@ -1,58 +0,0 @@
import { Component, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { describe, expect, it } from 'vitest';
import { Tenant } from '../../../../core/services/tenant.interface';
import { TenantService } from '../../../../core/services/tenant.service';
import { MenuContentSectionComponent } from './menu-content-section.component';
@Component({
imports: [MenuContentSectionComponent],
template: `
<app-menu-content-section menuCode="help.contact" titleId="contact-title">
<a menuContentPrefix href="/ayuda">Volver</a>
<p>Contenido proyectado</p>
</app-menu-content-section>
`,
})
class TestHostComponent {}
describe('MenuContentSectionComponent', () => {
it('renders an accessible title and projects its content', async () => {
const tenant = signal({
menues: [
{
id: 1,
code: 'help.contact',
label: 'Contacto del comercio',
parent_menu_code: 'help',
content_type: 'static',
route: '/ayuda/contacto',
submenues: [],
},
],
} as unknown as Tenant);
await TestBed.configureTestingModule({
imports: [TestHostComponent],
providers: [{ provide: TenantService, useValue: { tenant: tenant.asReadonly() } }],
}).compileComponents();
const fixture = TestBed.createComponent(TestHostComponent);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const section = element.querySelector('section');
expect(section?.getAttribute('aria-labelledby')).toBe('contact-title');
expect(element.querySelector('#contact-title')?.textContent?.trim()).toBe(
'CONTACTO DEL COMERCIO',
);
expect(element.querySelector('.menu-content-section__prefix a')?.textContent).toContain(
'Volver',
);
expect(element.querySelector('.menu-content-section__body')?.textContent).toContain(
'Contenido proyectado',
);
});
});

View File

@@ -1,22 +0,0 @@
import { UpperCasePipe } from '@angular/common';
import { Component, computed, inject, input } from '@angular/core';
import { findMenu } from '../../../../core/services/menu.utils';
import { TenantService } from '../../../../core/services/tenant.service';
@Component({
selector: 'app-menu-content-section',
imports: [UpperCasePipe],
templateUrl: './menu-content-section.component.html',
styleUrl: './menu-content-section.component.scss',
})
export class MenuContentSectionComponent {
private readonly tenantService = inject(TenantService);
readonly menuCode = input.required<string>();
readonly titleId = input.required<string>();
protected readonly title = computed(
() => findMenu(this.tenantService.tenant()?.menues ?? [], this.menuCode())?.label ?? '',
);
}

View File

@@ -1,22 +0,0 @@
<div class="menu-page-layout">
<aside class="menu-page-layout__sidebar">
<h1 class="menu-page-layout__title">{{ menu()?.label }}</h1>
<div class="menu-page-layout__divider"></div>
<nav class="menu-page-layout__nav" [attr.aria-label]="navigationLabel">
@for (submenu of submenues(); track submenu.code) {
<a
class="menu-page-layout__link"
[routerLink]="submenu.route"
routerLinkActive="menu-page-layout__link--active"
>
{{ submenu.label }}
</a>
}
</nav>
</aside>
<main class="menu-page-layout__content">
<router-outlet />
</main>
</div>

View File

@@ -1,88 +0,0 @@
:host {
display: block;
width: 100%;
}
.menu-page-layout {
display: grid;
grid-template-columns: minmax(190px, 250px) minmax(0, 1fr);
gap: clamp(3rem, 8vw, 6.25rem);
width: 100%;
&__sidebar,
&__content {
min-width: 0;
}
&__sidebar {
display: flex;
flex-direction: column;
}
&__title {
margin: 0 0 0.75rem;
color: #1a1a1a;
font-size: 1rem;
font-weight: 700;
line-height: 1.25;
text-transform: uppercase;
}
&__divider {
height: 1px;
margin-bottom: 1.25rem;
background: #dedede;
}
&__nav {
display: flex;
flex-direction: column;
gap: 1rem;
}
&__link {
color: #888888;
font-size: 0.875rem;
font-weight: 500;
line-height: 1.35;
text-decoration: none;
transition: color 150ms ease;
&:hover,
&:focus-visible {
color: #5a5a5a;
}
&:focus-visible {
border-radius: 2px;
outline: 2px solid var(--color-primary);
outline-offset: 3px;
}
&--active {
color: var(--color-primary);
font-weight: 600;
}
}
}
@media (max-width: 767.98px) {
.menu-page-layout {
grid-template-columns: 1fr;
gap: 2rem;
&__title {
text-align: center;
}
&__nav {
flex-flow: row wrap;
justify-content: center;
gap: 0.75rem 1.25rem;
}
&__content {
width: 100%;
}
}
}

View File

@@ -1,136 +0,0 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, 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 { MenuPageLayoutComponent } from './menu-page-layout.component';
function createTenant(): Tenant {
return {
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: [],
},
],
},
{
id: 4,
code: 'help',
label: 'Centro de ayuda',
parent_menu_code: null,
content_type: 'dynamic',
route: '/ayuda',
submenues: [
{
id: 5,
code: 'help.faq',
label: 'Consultas habituales',
parent_menu_code: 'help',
content_type: 'dynamic',
route: '/ayuda/preguntas-frecuentes',
submenues: [],
},
],
},
],
};
}
async function renderLayout(menuCode: string, navigationLabel: string): Promise<HTMLElement> {
const tenant = signal(createTenant());
await TestBed.configureTestingModule({
imports: [MenuPageLayoutComponent],
providers: [
provideRouter([]),
{
provide: ActivatedRoute,
useValue: {
snapshot: {
data: { menuCode, navigationLabel },
},
},
},
{
provide: TenantService,
useValue: { tenant: tenant.asReadonly() },
},
],
}).compileComponents();
const fixture = TestBed.createComponent(MenuPageLayoutComponent);
fixture.detectChanges();
return fixture.nativeElement as HTMLElement;
}
describe('MenuPageLayoutComponent', () => {
it('renders the configured account menu and its navigation label', async () => {
const element = await renderLayout('account', 'Secciones de mi cuenta');
const links = Array.from(
element.querySelectorAll<HTMLAnchorElement>('.menu-page-layout__link'),
);
expect(element.querySelector('.menu-page-layout__title')?.textContent?.trim()).toBe(
'Mi cuenta',
);
expect(element.querySelector('nav')?.getAttribute('aria-label')).toBe('Secciones de 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',
]);
});
it('uses the same component for the help menu', async () => {
const element = await renderLayout('help', 'Secciones de ayuda');
expect(element.querySelector('.menu-page-layout__title')?.textContent?.trim()).toBe(
'Centro de ayuda',
);
expect(element.querySelector('.menu-page-layout__link')?.textContent?.trim()).toBe(
'Consultas habituales',
);
});
});

View File

@@ -1,26 +0,0 @@
import { Component, computed, inject } from '@angular/core';
import { ActivatedRoute, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { findMenu } from '../../../../core/services/menu.utils';
import { TenantService } from '../../../../core/services/tenant.service';
@Component({
selector: 'app-menu-page-layout',
imports: [RouterLink, RouterLinkActive, RouterOutlet],
templateUrl: './menu-page-layout.component.html',
styleUrl: './menu-page-layout.component.scss',
})
export class MenuPageLayoutComponent {
private readonly route = inject(ActivatedRoute);
private readonly tenantService = inject(TenantService);
protected readonly navigationLabel =
(this.route.snapshot.data['navigationLabel'] as string | undefined) ?? 'Secciones';
private readonly menuCode = this.route.snapshot.data['menuCode'] as string | undefined;
protected readonly menu = computed(() =>
findMenu(this.tenantService.tenant()?.menues ?? [], this.menuCode ?? ''),
);
protected readonly submenues = computed(() => this.menu()?.submenues ?? []);
}

View File

@@ -1,5 +1,5 @@
<div class="attribute-selector">
@for (attribute of visibleAttributes(); track attribute.codigo) {
@for (attribute of attributes(); track attribute.id) {
<div class="attribute-selector__row">
<span class="attribute-selector__label">{{ attribute.nombre }}:</span>
@@ -10,14 +10,12 @@
type="button"
class="attribute-selector__swatch"
[class.attribute-selector__swatch--selected]="hasSelectedOption(attribute, option)"
[class.attribute-selector__swatch--disabled]="
!availableOptions()[attribute.codigo][option.id]
"
[class.attribute-selector__swatch--disabled]="!availableOptions()[attribute.id][option.id]"
[style.background-color]="getOptionSwatchColor(option)"
[attr.aria-label]="option.label"
[attr.aria-pressed]="hasSelectedOption(attribute, option)"
[title]="option.label"
[disabled]="!availableOptions()[attribute.codigo][option.id]"
[disabled]="!availableOptions()[attribute.id][option.id]"
(click)="selectAttributeOption(attribute, option)"
>
<span class="visually-hidden">{{ option.label }}</span>
@@ -26,14 +24,10 @@
<button
type="button"
class="attribute-selector__text-option"
[class.attribute-selector__text-option--selected]="
hasSelectedOption(attribute, option)
"
[class.attribute-selector__text-option--disabled]="
!availableOptions()[attribute.codigo][option.id]
"
[class.attribute-selector__text-option--selected]="hasSelectedOption(attribute, option)"
[class.attribute-selector__text-option--disabled]="!availableOptions()[attribute.id][option.id]"
[attr.aria-pressed]="hasSelectedOption(attribute, option)"
[disabled]="!availableOptions()[attribute.codigo][option.id]"
[disabled]="!availableOptions()[attribute.id][option.id]"
(click)="selectAttributeOption(attribute, option)"
>
{{ option.label }}

View File

@@ -54,7 +54,7 @@
}
&__text-option {
border: 1px solid var(--border-color);
border: 1px solid #dcdcdc;
background: #ffffff;
color: #666666;
transition: border-color 0.2s ease, color 0.2s ease, background-color 0.2s ease;

View File

@@ -1,152 +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);
});
it('allows multiple event dates and emits the exact matching variant', () => {
const fixture = TestBed.createComponent(ProductAttributeSelectorComponent);
const emittedIds: Array<number | null> = [];
fixture.componentInstance.variantChange.subscribe((variant) =>
emittedIds.push(variant?.id ?? null),
);
fixture.componentRef.setInput('attributes', [
{
id: 2,
codigo: 'event_date',
nombre: 'Fecha',
is_required: true,
allow_multi_select: true,
metadata_schema: null,
type: 'event_date',
options: [
{ id: 101, value: '1', label: '09/10/2026', sort_order: 0, metadata: null },
{ id: 102, value: '2', label: '10/10/2026', sort_order: 1, metadata: null },
],
} satisfies ProductAttribute,
]);
fixture.componentRef.setInput('inventoryPolicy', 'unlimited');
fixture.componentRef.setInput('variants', [
{ id: 1, stock_tecnico: null, values: { event_date: '1' } },
{ id: 2, stock_tecnico: null, values: { event_date: '2' } },
{ id: 3, stock_tecnico: null, values: { event_date: ['1', '2'] } },
]);
fixture.detectChanges();
const buttons = Array.from(
fixture.nativeElement.querySelectorAll('.attribute-selector__text-option'),
) as HTMLButtonElement[];
buttons[0].click();
fixture.detectChanges();
buttons[1].click();
fixture.detectChanges();
expect(buttons[0].getAttribute('aria-pressed')).toBe('true');
expect(buttons[1].getAttribute('aria-pressed')).toBe('true');
expect(emittedIds).toContain(3);
});
it('hides configured attributes and matches variants using only visible selections', () => {
const fixture = TestBed.createComponent(ProductAttributeSelectorComponent);
const emittedIds: Array<number | null> = [];
fixture.componentInstance.variantChange.subscribe((variant) =>
emittedIds.push(variant?.id ?? null),
);
fixture.componentRef.setInput('attributes', [
sizeAttribute,
{
id: 2,
codigo: 'internal_type',
nombre: 'Internal type',
is_required: true,
show_in_selector: false,
metadata_schema: null,
type: 'select',
options: [
{ id: 30, value: 'adult', label: 'Adult', sort_order: 0, metadata: null },
{ id: 40, value: 'child', label: 'Child', sort_order: 1, metadata: null },
],
} satisfies ProductAttribute,
]);
fixture.componentRef.setInput('inventoryPolicy', 'unlimited');
fixture.componentRef.setInput('variants', [
{ id: 1, stock_tecnico: null, values: { size: 'S', internal_type: 'adult' } },
{ id: 2, stock_tecnico: null, values: { size: 'M', internal_type: 'child' } },
]);
fixture.detectChanges();
const labels = fixture.nativeElement.querySelectorAll('.attribute-selector__label');
const buttons = fixture.nativeElement.querySelectorAll('.attribute-selector__text-option');
expect(labels).toHaveLength(1);
expect(labels[0].textContent).toContain('Size');
buttons[1].click();
fixture.detectChanges();
expect(emittedIds).toContain(2);
});
});

View File

@@ -6,14 +6,14 @@ import {
input,
output,
signal,
untracked,
untracked
} from '@angular/core';
import { CommonModule } from '@angular/common';
import {
CatalogItemVariant,
InventoryPolicy,
ProductAttribute,
ProductAttributeOption,
ProductVariant,
ProductVariantMap
} from '../../../../core/services/catalog/catalog.interface';
@Component({
@@ -22,79 +22,55 @@ import {
imports: [CommonModule],
templateUrl: './product-attribute-selector.component.html',
styleUrl: './product-attribute-selector.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProductAttributeSelectorComponent {
public attributes = input<ProductAttribute[]>([]);
public variants = input<CatalogItemVariant[]>([]);
public selectedVariant = input<CatalogItemVariant | null>(null);
public inventoryPolicy = input.required<InventoryPolicy>();
public variantsMap = input<ProductVariantMap[]>([]);
public defaultVariant = input<ProductVariant | null>(null);
public variantChange = output<CatalogItemVariant | null>();
public variantChange = output<ProductVariantMap | null>();
protected readonly visibleAttributes = computed(() =>
this.attributes().filter((attribute) => attribute.show_in_selector !== false),
);
protected readonly selectedAttributeOptions = signal<Record<string, number[]>>({});
protected readonly selectedAttributeOptions = signal<Record<number, number>>({});
protected readonly availableOptions = computed(() => {
const selections = this.selectedAttributeOptions();
const variants = this.variants();
const attributes = this.visibleAttributes();
const variants = this.variantsMap();
const attributes = this.attributes();
const availability: Record<string, Record<number, boolean>> = {};
const availability: Record<number, Record<number, boolean>> = {};
for (const attribute of attributes) {
availability[attribute.codigo] = {};
availability[attribute.id] = {};
for (const option of attribute.options) {
const optionNormalized = this.normalizeText(option.value || option.label);
const selectedForAttribute = selections[attribute.codigo] ?? [];
const isAvailable = variants.some(variant => {
if (variant.cantidad_maxima <= 0) return false;
if (
!attribute.allow_multi_select &&
selectedForAttribute.length >= 1 &&
!selectedForAttribute.includes(option.id)
) {
availability[attribute.codigo][option.id] = false;
continue;
}
const isAvailable = variants.some((variant) => {
if (!this.isVariantAvailable(variant)) return false;
const variantAttrValues = this.getVariantAttributeValues(attribute, variant.values);
const desiredOptionIds = selectedForAttribute.includes(option.id)
? selectedForAttribute
: [...selectedForAttribute, option.id];
const desiredValues = desiredOptionIds
.map((id) => attribute.options.find((candidate) => candidate.id === id))
.filter((candidate): candidate is ProductAttributeOption => candidate !== undefined)
.map((candidate) => this.normalizeText(candidate.value || candidate.label));
if (
!variantAttrValues.includes(optionNormalized) ||
!desiredValues.every((value) => variantAttrValues.includes(value))
) {
return false;
const variantAttrValue = this.getDefaultVariantAttributeValue(attribute, variant.attributes);
if (!variantAttrValue || this.normalizeText(variantAttrValue) !== optionNormalized) {
return false;
}
for (const otherAttr of attributes) {
if (otherAttr.codigo === attribute.codigo) continue;
const selectedOptionIds = selections[otherAttr.codigo] ?? [];
const selectedValues = selectedOptionIds
.map((id) => otherAttr.options.find((candidate) => candidate.id === id))
.filter((candidate): candidate is ProductAttributeOption => candidate !== undefined)
.map((candidate) => this.normalizeText(candidate.value || candidate.label));
const variantValues = this.getVariantAttributeValues(otherAttr, variant.values);
if (!selectedValues.every((value) => variantValues.includes(value))) {
return false;
}
if (otherAttr.id === attribute.id) continue;
const selectedOptionId = selections[otherAttr.id];
if (selectedOptionId !== undefined) {
const selectedOption = otherAttr.options.find(o => o.id === selectedOptionId);
if (selectedOption) {
const selectedNormalized = this.normalizeText(selectedOption.value || selectedOption.label);
const vAttrValue = this.getDefaultVariantAttributeValue(otherAttr, variant.attributes);
if (!vAttrValue || this.normalizeText(vAttrValue) !== selectedNormalized) {
return false;
}
}
}
}
return true;
});
availability[attribute.codigo][option.id] = isAvailable;
availability[attribute.id][option.id] = isAvailable;
}
}
@@ -102,14 +78,14 @@ export class ProductAttributeSelectorComponent {
});
public reset(): void {
this.initializeSelections(this.attributes(), this.selectedVariant());
this.initializeSelections(this.attributes(), this.defaultVariant());
}
constructor() {
effect(() => {
const attributes = this.visibleAttributes();
const defaultVariant = this.selectedVariant();
const attributes = this.attributes();
const defaultVariant = this.defaultVariant();
untracked(() => {
this.initializeSelections(attributes, defaultVariant);
});
@@ -117,41 +93,27 @@ export class ProductAttributeSelectorComponent {
effect(() => {
const selections = this.selectedAttributeOptions();
const variants = this.variants();
const attributes = this.visibleAttributes();
const variantsMap = this.variantsMap();
const attributes = this.attributes();
untracked(() => {
this.emitMatchingVariant(selections, variants, attributes);
this.emitMatchingVariant(selections, variantsMap, attributes);
});
});
}
protected hasSelectedOption(
attribute: ProductAttribute,
option: ProductAttributeOption,
): boolean {
return (this.selectedAttributeOptions()[attribute.codigo] ?? []).includes(option.id);
protected hasSelectedOption(attribute: ProductAttribute, option: ProductAttributeOption): boolean {
return this.selectedAttributeOptions()[attribute.id] === option.id;
}
protected selectAttributeOption(
attribute: ProductAttribute,
option: ProductAttributeOption,
option: ProductAttributeOption
): void {
this.selectedAttributeOptions.update((current) => {
const selected = current[attribute.codigo] ?? [];
const isMultiple = attribute.allow_multi_select ?? false;
let next: number[];
if (!isMultiple) {
next = [option.id];
} else if (selected.includes(option.id)) {
next = selected.filter((id) => id !== option.id);
} else {
next = [...selected, option.id];
}
return { ...current, [attribute.codigo]: next };
});
this.selectedAttributeOptions.update((current) => ({
...current,
[attribute.id]: option.id
}));
}
protected isColorOption(option: ProductAttributeOption): boolean {
@@ -162,48 +124,52 @@ export class ProductAttributeSelectorComponent {
return this.findFirstHexValue(option.metadata) ?? '#D9D9D9';
}
private initializeSelections(
attributes: ProductAttribute[],
defaultVariant: CatalogItemVariant | null,
): void {
const selections: Record<string, number[]> = {};
private initializeSelections(attributes: ProductAttribute[], defaultVariant: ProductVariant | null): void {
const selections: Record<number, number> = {};
for (const attribute of attributes) {
const optionIds = this.findDefaultOptionIds(attribute, defaultVariant);
if (optionIds.length > 0) {
selections[attribute.codigo] = optionIds;
const optionId = this.findDefaultOptionId(attribute, defaultVariant);
if (optionId !== null) {
selections[attribute.id] = optionId;
}
}
this.selectedAttributeOptions.set(selections);
}
private findDefaultOptionIds(
private findDefaultOptionId(
attribute: ProductAttribute,
variant: CatalogItemVariant | null,
): number[] {
variant: ProductVariant | null
): number | null {
if (!variant) {
return [];
return null;
}
const defaultValues = this.getVariantAttributeValues(attribute, variant.values);
if (defaultValues.length === 0) {
return [];
const defaultValue = this.getDefaultVariantAttributeValue(attribute, variant.definitions);
if (!defaultValue) {
return null;
}
return attribute.options
.filter(
(option) =>
defaultValues.includes(this.normalizeText(option.value)) ||
defaultValues.includes(this.normalizeText(option.label)),
)
.map((option) => option.id);
const normalizedValue = this.normalizeText(defaultValue);
const matchByValue = attribute.options.find(
(option) => this.normalizeText(option.value) === normalizedValue
);
if (matchByValue) {
return matchByValue.id;
}
const matchByLabel = attribute.options.find(
(option) => this.normalizeText(option.label) === normalizedValue
);
return matchByLabel?.id ?? null;
}
private getVariantAttributeValues(
private getDefaultVariantAttributeValue(
attribute: ProductAttribute,
variantAttributes: Record<string, string | string[]>,
): string[] {
variantAttributes: Record<string, string>
): string | null {
const normalizedCodigo = this.normalizeText(attribute.codigo);
const normalizedNombre = this.normalizeText(attribute.nombre);
@@ -211,11 +177,11 @@ export class ProductAttributeSelectorComponent {
const normalizedKey = this.normalizeText(key);
if (normalizedKey === normalizedCodigo || normalizedKey === normalizedNombre) {
return (Array.isArray(value) ? value : [value]).map((item) => this.normalizeText(item));
return value;
}
}
return [];
return null;
}
private normalizeText(value: string | null | undefined): string {
@@ -226,10 +192,6 @@ export class ProductAttributeSelectorComponent {
.toLowerCase();
}
private isVariantAvailable(variant: CatalogItemVariant): boolean {
return this.inventoryPolicy() === 'unlimited' || (variant.stock_tecnico ?? 0) > 0;
}
private findFirstHexValue(value: unknown): string | null {
if (typeof value === 'string') {
const match = value.match(/#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})\b/);
@@ -259,31 +221,27 @@ export class ProductAttributeSelectorComponent {
}
private emitMatchingVariant(
selections: Record<string, number[]>,
variants: CatalogItemVariant[],
attributes: ProductAttribute[],
selections: Record<number, number>,
variantsMap: ProductVariantMap[],
attributes: ProductAttribute[]
): void {
if (attributes.length === 0 || variants.length === 0) {
if (attributes.length === 0 || variantsMap.length === 0) {
this.variantChange.emit(null);
return;
}
const selectedValuesByCode: Record<string, string[]> = {};
const selectedValuesById: Record<number, string> = {};
let allSelected = true;
for (const attr of attributes) {
const selectedOptionIds = selections[attr.codigo] ?? [];
if (
selectedOptionIds.length === 0 ||
(!attr.allow_multi_select && selectedOptionIds.length !== 1)
) {
allSelected = false;
break;
const selectedOptionId = selections[attr.id];
if (selectedOptionId === undefined) {
allSelected = false;
break;
}
const option = attr.options.find(o => o.id === selectedOptionId);
if (option) {
selectedValuesById[attr.id] = this.normalizeText(option.value || option.label);
}
selectedValuesByCode[attr.codigo] = selectedOptionIds
.map((id) => attr.options.find((option) => option.id === id))
.filter((option): option is ProductAttributeOption => option !== undefined)
.map((option) => this.normalizeText(option.value || option.label))
.sort();
}
if (!allSelected) {
@@ -291,14 +249,12 @@ export class ProductAttributeSelectorComponent {
return;
}
const matchingVariant = variants.find((variant) => {
return attributes.every((attr) => {
const selectedValues = selectedValuesByCode[attr.codigo];
const variantValues = this.getVariantAttributeValues(attr, variant.values).sort();
return (
selectedValues.length === variantValues.length &&
selectedValues.every((value, index) => value === variantValues[index])
);
const matchingVariant = variantsMap.find(vMap => {
return attributes.every(attr => {
const selectedValue = selectedValuesById[attr.id];
const vMapValue = this.getDefaultVariantAttributeValue(attr, vMap.attributes);
if (!vMapValue) return false;
return this.normalizeText(vMapValue) === selectedValue;
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,13 +13,3 @@
flex: 1;
display: block;
}
@media (max-width: 767.98px) {
.form-actions {
flex-direction: column;
}
.action-btn {
width: 100%;
}
}

View File

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

View File

@@ -1,6 +1,6 @@
:host {
display: block;
border-bottom: 1px solid var(--border-color);
border-bottom: 1px solid #dddddd;
}
:host(:last-child) {
@@ -13,10 +13,6 @@
gap: 0.75rem;
background-color: transparent;
position: relative;
&--without-image {
grid-template-columns: minmax(0, 1fr);
}
}
.cart-item-media {
@@ -24,7 +20,7 @@
aspect-ratio: 4 / 3;
border-radius: 5px 0 0 5px;
background-color: #ffffff;
border: 1px solid var(--border-color);
border: 1px solid #eaeaea;
overflow: hidden;
img {
@@ -48,6 +44,12 @@
line-height: 1;
}
.cart-item-placeholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #e0e0e0, #f5f5f5);
}
.cart-item-content {
gap: 0.5rem;
}
@@ -114,16 +116,3 @@
display: grid;
justify-items: end;
}
.cart-item-description {
color: #666666;
font-size: 9px;
font-weight: 325;
line-height: 1.35;
white-space: pre-line;
}
.cart-item-bottom-details {
gap: 0.125rem;
justify-items: end;
}

View File

@@ -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 = {
id: number;
title: string;
description: string | null;
imageUrl: string | null;
quantity: number;
attributes: Array<{

View File

@@ -23,7 +23,7 @@
justify-content: space-between;
align-items: center;
padding: 1rem 0;
border-bottom: 1px solid var(--border-color);
border-bottom: 1px solid #DDDDDD;
}
.skeleton-item:last-child {

View File

@@ -29,9 +29,7 @@ export class PurchaseList implements OnInit {
try {
const tenantCode = this.tenantService.tenant()?.codigo || '';
const response = await this.checkoutService
.withCustomLoading()
.getPurchases(tenantCode, 'paid');
const response = await this.checkoutService.getPurchases(tenantCode, 'paid');
const mappedPurchases = response.data.map((purchase: PurchaseSummaryResponse) => ({
id: purchase.id,
date: this.formatDate(purchase.created_at),

View File

@@ -1,7 +1,4 @@
<app-menu-content-section
class="account-page"
menuCode="account.profile"
titleId="profile-page-title"
>
<app-profile-form />
</app-menu-content-section>
<div class="page-container">
<h2 class="page-title">DATOS PERSONALES</h2>
<app-profile-form></app-profile-form>
</div>

View File

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

View File

@@ -1,11 +1,10 @@
import { Component } from '@angular/core';
import { MenuContentSectionComponent } from '../../../../components/menu-content-section/menu-content-section.component';
import { ProfileForm } from '../../components/profile-form/profile-form';
@Component({
selector: 'app-profile-page',
standalone: true,
imports: [ProfileForm, MenuContentSectionComponent],
imports: [ProfileForm],
templateUrl: './profile-page.html',
styleUrl: './profile-page.scss',
})

View File

@@ -1,35 +1,26 @@
<app-menu-content-section
class="account-page"
menuCode="account.purchases"
titleId="purchase-detail-page-title"
>
<a
menuContentPrefix
routerLink="../"
class="purchase-back-link text-decoration-none"
aria-label="Volver a mis compras"
>
<i class="bi bi-arrow-left fs-5" aria-hidden="true"></i>
</a>
<div class="page-container">
<div class="d-flex align-items-center mb-4">
<a routerLink="../" class="text-decoration-none " >
<i class="bi bi-arrow-left fs-5"></i>
</a>
<h2 class="page-title m-0">MIS COMPRAS</h2>
</div>
@if (isLoading()) {
<p class="purchase-loading">Cargando detalle de compra...</p>
} @else if (purchase(); as purchase) {
<div
class="d-flex justify-content-between align-items-center mb-4 pb-3"
style="border-bottom: 1px solid #dddddd"
>
<div class="d-flex justify-content-between align-items-center mb-4 pb-3" style="border-bottom: 1px solid #dddddd;">
<div class="purchase-info">
<span class="purchase-id">Compra {{ purchase.id }}.</span>
<span class="purchase-date">Fecha de compra: {{ purchase.date }}</span>
</div>
<div class="purchase-total">
<div class="purchase-total ">
<span class="purchase-total-label">Total:</span>
<span class="purchase-total-value">${{ purchase.total }}</span>
</div>
</div>
<p style="font-size: 12px; color: #666666; margin: 0">Productos:</p>
<p style="font-size: 12px; color: #666666; margin:0;">Productos:</p>
<div class="d-flex flex-column">
@for (item of purchase.items; track item.id) {
@@ -39,4 +30,4 @@
}
</div>
}
</app-menu-content-section>
</div>

View File

@@ -1,21 +1,14 @@
:host {
display: block;
width: 100%;
}
.account-page {
display: block;
.page-container {
display: flex;
flex-direction: column;
width: 100%;
max-width: 600px;
}
.purchase-back-link {
color: #888888;
&:hover,
&:focus-visible {
color: var(--color-primary);
}
.page-title {
font-size: 15px;
font-weight: bold;
color: #A0A0A0;
}
.purchase-info {

View File

@@ -2,13 +2,9 @@ import { CommonModule } from '@angular/common';
import { Component, OnInit, inject, signal } from '@angular/core';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import {
CheckoutService,
PurchaseDetailResponse,
} from '../../../../../../core/services/checkout.service';
import { CheckoutService, PurchaseDetailResponse } from '../../../../../../core/services/checkout.service';
import { TenantService } from '../../../../../../core/services/tenant.service';
import { ToastService } from '../../../../../../core/services/toast.service';
import { MenuContentSectionComponent } from '../../../../components/menu-content-section/menu-content-section.component';
import { PurchaseItem, PurchaseItemViewModel } from '../../components/purchase-item/purchase-item';
type PurchaseDetailViewModel = {
@@ -21,7 +17,7 @@ type PurchaseDetailViewModel = {
@Component({
selector: 'app-purchase-detail-page',
standalone: true,
imports: [CommonModule, RouterLink, PurchaseItem, MenuContentSectionComponent],
imports: [CommonModule, RouterLink, PurchaseItem],
templateUrl: './purchase-detail-page.html',
styleUrl: './purchase-detail-page.scss',
})
@@ -45,9 +41,7 @@ export class PurchaseDetailPage implements OnInit {
}
try {
const response = await this.checkoutService
.withCustomLoading()
.getPurchase(tenant.codigo, purchaseId);
const response = await this.checkoutService.getPurchase(tenant.codigo, purchaseId);
this.purchase.set({
id: response.id,
@@ -85,13 +79,12 @@ export class PurchaseDetailPage implements OnInit {
private mapItems(purchase: PurchaseDetailResponse): PurchaseItemViewModel[] {
return purchase.items.map((item) => ({
id: item.id,
title: item.item_details.nombre || 'Producto sin nombre',
description: item.item_details.descripcion,
imageUrl: item.item_details.imagen,
title: item.product?.nombre ?? 'Producto sin nombre',
imageUrl: item.product?.imagen ?? null,
quantity: item.quantity,
attributes: item.item_details.attributes.map((attribute) => ({
attributes: (item.variant?.attributes ?? []).map((attribute) => ({
label: attribute.name,
value: attribute.value === null ? null : String(attribute.value),
value: attribute.value,
})),
price: item.line_total,
originalPrice: null,

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