Compare commits

..

6 Commits

300 changed files with 2933 additions and 18796 deletions

2
.vscode/launch.json vendored
View File

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

View File

@@ -30,15 +30,15 @@
"input": "public" "input": "public"
} }
], ],
"styles": ["node_modules/leaflet/dist/leaflet.css", "src/styles.scss"], "styles": [
"src/styles.scss"
],
"server": "src/main.server.ts", "server": "src/main.server.ts",
"outputMode": "server", "outputMode": "server",
"security": { "security": {
"allowedHosts": [ "allowedHosts": [
"localhost", "localhost",
"localhost:4300", "127.0.0.1"
"127.0.0.1",
"127.0.0.1:4300"
] ]
}, },
"ssr": { "ssr": {
@@ -47,9 +47,6 @@
}, },
"configurations": { "configurations": {
"production": { "production": {
"optimization": true,
"extractLicenses": true,
"sourceMap": false,
"budgets": [ "budgets": [
{ {
"type": "initial", "type": "initial",
@@ -62,13 +59,7 @@
"maximumError": "8kB" "maximumError": "8kB"
} }
], ],
"outputHashing": "all", "outputHashing": "all"
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.production.ts"
}
]
}, },
"development": { "development": {
"optimization": true, "optimization": true,
@@ -78,33 +69,13 @@
"local": { "local": {
"optimization": false, "optimization": false,
"extractLicenses": false, "extractLicenses": false,
"sourceMap": true, "sourceMap": true
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.local.ts"
}
]
},
"homo": {
"optimization": true,
"extractLicenses": true,
"sourceMap": false,
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.homo.ts"
}
]
} }
}, },
"defaultConfiguration": "production" "defaultConfiguration": "production"
}, },
"serve": { "serve": {
"builder": "@angular/build:dev-server", "builder": "@angular/build:dev-server",
"options": {
"port": 4300
},
"configurations": { "configurations": {
"production": { "production": {
"buildTarget": "shopit-front:build:production" "buildTarget": "shopit-front:build:production"
@@ -114,12 +85,9 @@
}, },
"local": { "local": {
"buildTarget": "shopit-front:build:local" "buildTarget": "shopit-front:build:local"
},
"homo": {
"buildTarget": "shopit-front:build:homo"
} }
}, },
"defaultConfiguration": "local" "defaultConfiguration": "development"
}, },
"test": { "test": {
"builder": "@angular/build:unit-test" "builder": "@angular/build:unit-test"

25
package-lock.json generated
View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

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

@@ -4,22 +4,18 @@ import {
provideAppInitializer, provideAppInitializer,
provideBrowserGlobalErrorListeners, provideBrowserGlobalErrorListeners,
} from '@angular/core'; } from '@angular/core';
import { provideRouter, UrlSerializer } from '@angular/router'; import { provideRouter } from '@angular/router';
import { provideClientHydration, withHttpTransferCacheOptions } from '@angular/platform-browser'; import { provideClientHydration, withHttpTransferCacheOptions } from '@angular/platform-browser';
import { routes } from './app.routes'; import { routes } from './app.routes';
import { authBootstrap } from './core/services/auth/auth-bootstrap'; import { authBootstrap } from './core/services/auth/auth-bootstrap';
import { authInterceptor } from './core/services/auth/auth.interceptor'; 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'; import { tenantBootstrap } from './core/services/tenant-bootstrap';
import { TenantUrlSerializer } from './core/services/tenant-url.serializer';
export function isStoreCatalogRequest(request: HttpRequest<unknown>): boolean { function isStoreCatalogRequest(request: HttpRequest<unknown>): boolean {
return ( return (
request.method === 'GET' && request.method === 'GET' &&
/\/api\/tenants\/[^/?#]+\/(?:catalog(?:\/featured-groups\/\d+\/items)?|catalog-items(?:\/\d+)?)\/?(?:[?#]|$)/.test( /\/api\/tenants\/[^/]+\/productos(?:\/\d+)?(?:\?|$)/.test(request.urlWithParams)
request.urlWithParams,
)
); );
} }
@@ -27,17 +23,13 @@ export const appConfig: ApplicationConfig = {
providers: [ providers: [
provideBrowserGlobalErrorListeners(), provideBrowserGlobalErrorListeners(),
provideRouter(routes), provideRouter(routes),
{ provide: UrlSerializer, useClass: TenantUrlSerializer },
provideClientHydration( provideClientHydration(
withHttpTransferCacheOptions({ withHttpTransferCacheOptions({
includeRequestsWithAuthHeaders: true, includeRequestsWithAuthHeaders: true,
filter: isStoreCatalogRequest, filter: isStoreCatalogRequest,
}), }),
), ),
provideHttpClient( provideHttpClient(withFetch(), withInterceptors([authInterceptor])),
withFetch(),
withInterceptors([authInterceptor, globalLoadingInterceptor])
),
provideAppInitializer(authBootstrap), provideAppInitializer(authBootstrap),
provideAppInitializer(tenantBootstrap), provideAppInitializer(tenantBootstrap),
], ],

View File

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

View File

@@ -7,7 +7,6 @@ import { of } from 'rxjs';
import { App } from './app'; import { App } from './app';
import { AuthService } from './core/services/auth/auth.service'; import { AuthService } from './core/services/auth/auth.service';
import { CartService } from './core/services/cart/cart.service'; import { CartService } from './core/services/cart/cart.service';
import { CheckoutService } from './core/services/checkout.service';
import { Tenant } from './core/services/tenant.interface'; import { Tenant } from './core/services/tenant.interface';
import { TenantService } from './core/services/tenant.service'; import { TenantService } from './core/services/tenant.service';
import { routes } from './app.routes'; import { routes } from './app.routes';
@@ -24,8 +23,7 @@ const tenant: Tenant = {
header_bg_color: '#313131', header_bg_color: '#313131',
footer_bg_color: '#313131', footer_bg_color: '#313131',
header_logo: 'https://example.com/header.png', header_logo: 'https://example.com/header.png',
footer_logo: 'https://example.com/footer.png', footer_logo: 'https://example.com/footer.png'
categories: []
}; };
function createTenantServiceStub( function createTenantServiceStub(
@@ -98,21 +96,6 @@ async function renderAppAt(
{ {
provide: AuthService, provide: AuthService,
useValue: authService useValue: authService
},
{
provide: CheckoutService,
useValue: {
withCustomLoading() {
return this;
},
getPurchase: vi.fn().mockResolvedValue({
id: 25,
status: 'created',
items: [],
subtotal: '0.00',
total: '0.00'
})
}
} }
] ]
}).compileComponents(); }).compileComponents();
@@ -182,55 +165,6 @@ describe('app routes', () => {
]); ]);
}); });
it('loads the recover password page inside the simple layout', async () => {
const { fixture, router } = await renderAppAt('/recuperar-contrasena');
const compiled = fixture.nativeElement as HTMLElement;
const recoverPasswordPage = compiled.querySelector('app-recover-password-page');
expect(router.url).toBe('/recuperar-contrasena');
expect(compiled.querySelector('.simple-layout')).not.toBeNull();
expect(recoverPasswordPage).not.toBeNull();
expect(recoverPasswordPage?.textContent).toContain('Recuperar contraseña');
expect(recoverPasswordPage?.querySelector('input')?.getAttribute('placeholder')).toBe('Email');
expect(recoverPasswordPage?.textContent).toContain('Recuperar acceso');
expect(recoverPasswordPage?.textContent).toContain('Volver');
});
it('loads the recovery code page and shows the entered email', async () => {
const { fixture, router } = await renderAppAt(
'/recuperar-contrasena/codigo?email=ada%40example.com'
);
const compiled = fixture.nativeElement as HTMLElement;
const codePage = compiled.querySelector('app-recover-password-code-page');
expect(router.url).toBe('/recuperar-contrasena/codigo?email=ada%40example.com');
expect(compiled.querySelector('.simple-layout')).not.toBeNull();
expect(codePage).not.toBeNull();
expect(codePage?.textContent).toContain('ada@example.com');
expect(codePage?.querySelectorAll('app-input')).toHaveLength(4);
expect(codePage?.textContent).toContain('Validar');
});
it('loads the reset password page with the shared password inputs', async () => {
const { fixture, router } = await renderAppAt(
'/recuperar-contrasena/restablecer?email=ada%40example.com&code=1234'
);
const compiled = fixture.nativeElement as HTMLElement;
const resetPage = compiled.querySelector('app-reset-password-page');
const placeholders = Array.from(resetPage?.querySelectorAll('input') ?? []).map((input) =>
input.getAttribute('placeholder')
);
expect(router.url).toBe(
'/recuperar-contrasena/restablecer?email=ada%40example.com&code=1234'
);
expect(compiled.querySelector('.simple-layout')).not.toBeNull();
expect(resetPage).not.toBeNull();
expect(resetPage?.textContent).toContain('Restablecer contraseña');
expect(placeholders).toEqual(['Nueva Contraseña', 'Repetir Nueva Contraseña']);
expect(resetPage?.textContent).toContain('Guardar');
});
it('redirects authenticated users away from /login', async () => { it('redirects authenticated users away from /login', async () => {
const { router } = await renderAppAt('/login', createTenantServiceStub(), createAuthServiceStub(true)); const { router } = await renderAppAt('/login', createTenantServiceStub(), createAuthServiceStub(true));
@@ -249,46 +183,15 @@ describe('app routes', () => {
expect(compiled.textContent).toContain('No encontramos una tienda para este dominio'); expect(compiled.textContent).toContain('No encontramos una tienda para este dominio');
}); });
it('renders the tenant not found screen at / without redirecting to itself', async () => { it('redirects unauthenticated users from /checkout to /login', async () => {
const { fixture, router } = await renderAppAt( const { router } = await renderAppAt('/checkout', createTenantServiceStub(), createAuthServiceStub(false));
'/',
createTenantServiceStub('not-found', null),
createAuthServiceStub()
);
const compiled = fixture.nativeElement as HTMLElement;
expect(router.url).toBe('/'); expect(router.url).toBe('/login');
expect(compiled.querySelector('.tenant-status')).not.toBeNull();
expect(compiled.textContent).toContain('No encontramos una tienda para este dominio');
}); });
it('redirects unauthenticated users from /checkout/:id to /login', async () => { it('allows authenticated users to access /checkout', async () => {
const { router } = await renderAppAt('/checkout/25', createTenantServiceStub(), createAuthServiceStub(false)); const { router } = await renderAppAt('/checkout', createTenantServiceStub(), createAuthServiceStub(true));
expect(router.url).toBe('/login?returnUrl=%2Fcheckout%2F25'); expect(router.url).toBe('/checkout');
});
it('allows authenticated users to access /checkout/:id', async () => {
const checkoutTenant = {
...tenant,
menues: [
{
id: 1,
code: 'checkout',
label: 'Checkout',
parent_menu_code: null,
content_type: 'dynamic' as const,
route: '/checkout',
submenues: []
}
]
};
const { router } = await renderAppAt(
'/checkout/25',
createTenantServiceStub('ready', checkoutTenant),
createAuthServiceStub(true)
);
expect(router.url).toBe('/checkout/25');
}); });
}); });

View File

@@ -14,12 +14,5 @@ export const routes: Routes = [
{ {
path: '', path: '',
loadChildren: () => import('./features/store/store.routes').then((m) => m.routes) loadChildren: () => import('./features/store/store.routes').then((m) => m.routes)
},
{
path: '**',
loadComponent: () =>
import('./shared/pages/route-not-found-page.component').then(
(m) => m.RouteNotFoundPageComponent,
)
} }
]; ];

View File

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

View File

@@ -17,8 +17,6 @@ const tenant: Tenant = {
codigo: 'test', codigo: 'test',
nombre: 'Test Tenant', nombre: 'Test Tenant',
dominio: 'localhost', dominio: 'localhost',
site_title: 'Test Store',
favicon: 'https://example.com/favicon.png',
primary_color: '#6376F3', primary_color: '#6376F3',
secondary_color: '#A0A0A0', secondary_color: '#A0A0A0',
danger_color: '#FF8888', danger_color: '#FF8888',
@@ -26,8 +24,7 @@ const tenant: Tenant = {
header_bg_color: '#313131', header_bg_color: '#313131',
footer_bg_color: '#313131', footer_bg_color: '#313131',
header_logo: 'https://example.com/header.png', header_logo: 'https://example.com/header.png',
footer_logo: 'https://example.com/footer.png', footer_logo: 'https://example.com/footer.png'
categories: []
}; };
function createTenantServiceStub(status: 'ready' | 'not-found', currentTenant: Tenant | null) { function createTenantServiceStub(status: 'ready' | 'not-found', currentTenant: Tenant | null) {
@@ -92,9 +89,6 @@ describe('App', () => {
expect(fixture.nativeElement.style.getPropertyValue('--tenant-danger-rgb')).toBe( expect(fixture.nativeElement.style.getPropertyValue('--tenant-danger-rgb')).toBe(
'255, 136, 136' '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 () => { it('renders the tenant not found screen when the tenant is missing', async () => {
@@ -113,8 +107,5 @@ describe('App', () => {
fixture.detectChanges(); fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('No encontramos una tienda para este dominio'); 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("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'/%3E");
}); });
}); });

View File

@@ -1,16 +1,11 @@
import { DOCUMENT } from '@angular/common'; import { Component, computed, inject } from '@angular/core';
import { Component, computed, effect, inject } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { RouterOutlet } from '@angular/router'; import { RouterOutlet } from '@angular/router';
import { TenantService } from './core/services/tenant.service'; import { TenantService } from './core/services/tenant.service';
import { DEFAULT_TENANT_BRANDING } from './core/services/tenant-ssr-cache.store'; 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 { ModalHostComponent } from './shared/components/modal-host/modal-host.component';
import { ToastContainerComponent } from './shared/components/toast-container/toast-container.component'; import { ToastContainerComponent } from './shared/components/toast-container/toast-container.component';
const EMPTY_FAVICON = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'/%3E";
function hexToRgb(hex: string): string { function hexToRgb(hex: string): string {
const cleanHex = hex.replace('#', '').trim(); const cleanHex = hex.replace('#', '').trim();
let r = 0, g = 0, b = 0; let r = 0, g = 0, b = 0;
@@ -28,12 +23,7 @@ function hexToRgb(hex: string): string {
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
imports: [ imports: [RouterOutlet, ToastContainerComponent, ModalHostComponent],
RouterOutlet,
ToastContainerComponent,
ModalHostComponent,
GlobalLoadingComponent
],
templateUrl: './app.html', templateUrl: './app.html',
styleUrl: './app.scss', styleUrl: './app.scss',
host: { host: {
@@ -61,28 +51,6 @@ function hexToRgb(hex: string): string {
}) })
export class App { export class App {
private readonly tenantService = inject(TenantService); 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 || EMPTY_FAVICON;
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; protected readonly status = this.tenantService.status;

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/25', 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,22 +1,21 @@
import { inject } from '@angular/core'; import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router'; import { CanActivateFn, Router } from '@angular/router';
import { findMenu } from '../services/menu.utils';
import { TenantService } from '../services/tenant.service'; import { TenantService } from '../services/tenant.service';
export const hasMenuGuard = (menuCode: string): CanActivateFn => { export const hasMenuGuard = (menuCode: string): CanActivateFn => {
return (_route, state) => { return () => {
const tenantService = inject(TenantService); const tenantService = inject(TenantService);
const router = inject(Router); const router = inject(Router);
const tenant = tenantService.tenant(); const tenant = tenantService.tenant();
if (!tenant) { if (!tenant) {
return false; return router.createUrlTree(['/']);
} }
const hasMenu = findMenu(tenant.menues ?? [], menuCode) !== undefined; const hasMenu = tenant.menues?.some(menu => menu.code === menuCode) ?? false;
if (hasMenu || state.url === '/') { if (hasMenu) {
return true; return true;
} }

View File

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

View File

@@ -1,32 +1,23 @@
.store-layout__footer { .store-layout__footer {
background-color: var(--tenant-footer-bg); 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 { .store-layout__brand-slot {
width: min(100%, 230px);
min-width: 10rem; min-width: 10rem;
} }
.store-layout__brand-logo-shell { .store-layout__brand-logo-shell {
position: relative; position: relative;
width: min(100%, 230px); width: min(100%, 12rem);
height: 65px; height: 2.75rem;
} }
.store-layout__brand-logo { .store-layout__brand-logo {
display: block; display: block;
width: 100%; width: 100%;
height: 100%; height: 100%;
max-height: 65px; max-height: 2.75rem;
object-fit: contain; object-fit: contain;
object-position: left center;
} }
.store-layout__brand-logo-skeleton { .store-layout__brand-logo-skeleton {
@@ -49,7 +40,7 @@
place-items: center; place-items: center;
width: 2.75rem; width: 2.75rem;
height: 2.75rem; height: 2.75rem;
border: 1px dashed var(--border-color); border: 1px dashed rgba(255, 255, 255, 0.35);
border-radius: 999px; border-radius: 999px;
font-size: 1.05rem; font-size: 1.05rem;
font-weight: 700; font-weight: 700;
@@ -64,30 +55,17 @@
color: #ffffff; color: #ffffff;
} }
.store-layout__footer-heading {
margin: 0;
font-size: 0.95rem;
font-weight: 500;
color: #f8f8f8;
}
.store-layout__muted-text { .store-layout__muted-text {
color: rgba(255, 255, 255, 0.72); color: rgba(255, 255, 255, 0.72);
} }
.store-layout__footer-link {
width: fit-content;
color: #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 { @keyframes store-footer-logo-skeleton {
from { from {
background-position: 200% 0; background-position: 200% 0;
@@ -99,97 +77,11 @@
} }
@media (max-width: 767.98px) { @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 { .store-layout__brand-slot {
width: min(100%, 150px);
min-width: 0; 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 { .store-layout__brand-text {
font-size: 13px; font-size: 1.45rem;
}
.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;
} }
} }

View File

@@ -1,33 +1,24 @@
import { Component, Input, output } from '@angular/core'; import { Component, Input } from '@angular/core';
import { RouterLink } from '@angular/router';
import { SocialMedia } from '../../../services/tenant.interface';
import { SocialMediaComponent } from '../../../../shared/components/social-media/social-media.component';
export type StoreFooterLink = {
label: string;
routerLink?: string;
action?: 'logout';
};
export type StoreFooterSection = { export type StoreFooterSection = {
heading: string; heading: string;
links: StoreFooterLink[]; links: string[];
};
export type StoreSocialLink = {
label: string;
iconClass: string;
}; };
@Component({ @Component({
selector: 'app-store-footer', selector: 'app-store-footer',
imports: [RouterLink, SocialMediaComponent], imports: [],
templateUrl: './store-footer.component.html', templateUrl: './store-footer.component.html',
styleUrl: './store-footer.component.scss', styleUrl: './store-footer.component.scss'
}) })
export class StoreFooterComponent { export class StoreFooterComponent {
@Input({ required: true }) currentYear = new Date().getFullYear(); @Input({ required: true }) currentYear = new Date().getFullYear();
@Input({ required: true }) footerSections: StoreFooterSection[] = []; @Input({ required: true }) footerSections: StoreFooterSection[] = [];
@Input({ required: true }) socialMedia: SocialMedia[] = []; @Input({ required: true }) socialLinks: StoreSocialLink[] = [];
@Input() logoUrl: string | null = null; @Input() logoUrl: string | null = null;
@Input() backgroundImageUrl: string | null = null;
@Input() storeName: string | null = null;
@Input() address: string | null = null;
@Input() phone: 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,14 +1,9 @@
<header <header class="store-layout__header">
class="store-layout__header"
[style.background-image]="backgroundImageUrl() ? 'url(' + backgroundImageUrl() + ')' : null"
>
<div class="store-layout__header-main"> <div class="store-layout__header-main">
<div class="container-xl px-3 px-md-4 py-3 py-lg-4"> <div class="container-xl px-3 px-md-4 py-3 py-lg-4 d-flex flex-row flex-wrap align-items-center justify-content-between gap-3">
<div class="row align-items-center gy-3 gy-md-0">
<div class="col-auto col-md-auto order-1 store-layout__brand-column">
<a <a
routerLink="/" routerLink="/"
class="store-layout__brand-slot d-inline-flex align-items-center gap-3 text-decoration-none" 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" data-testid="store-header-brand-slot"
aria-label="Logo del comercio" aria-label="Logo del comercio"
> >
@@ -20,76 +15,32 @@
</div> </div>
} }
</a> </a>
</div>
@if (displaySeachBar()) { <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="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"> <div class="input-group store-layout__search" role="search">
<label class="visually-hidden" for="store-search-input">Buscar productos</label> <label class="visually-hidden" for="store-search-input">Buscar productos</label>
<input <input
id="store-search-input" id="store-search-input"
type="search" type="search"
class="form-control store-layout__search-input border-end-0 rounded-start" 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 <button
type="button" type="button"
class="btn store-layout__search-button border-start-0 rounded-end px-3" class="btn store-layout__search-button border-start-0 rounded-end px-3"
aria-label="Buscar" aria-label="Buscar"
(click)="submitSearch()"
> >
<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i> <i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
</button> </button>
</div> </div>
@if (showSearchError()) { <div class="d-flex align-items-center justify-content-end gap-1" aria-label="Acciones de usuario">
<p id="store-search-error" class="store-layout__search-error mb-0" role="alert">
Ingresá al menos {{ minSearchLength }} caracteres para buscar.
</p>
}
</div>
</div>
}
<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>
}
@if (displayCart()) {
<app-cart-icon <app-cart-icon
[quantity]="cartDisabled() ? null : cartQuantity()" [quantity]="cartQuantity()"
[disabled]="cartDisabled()" ariaLabel="Carrito de compras"
[ariaLabel]=" title="Carrito"
cartDisabled() ? 'Carrito no disponible durante el checkout' : 'Carrito de compras' (click)="cartClick.emit()"
"
[title]="cartDisabled() ? 'Carrito no disponible durante el checkout' : 'Carrito'"
(click)="onCartClick()"
/> />
} <div class="store-layout__user-menu">
<div class="store-layout__user-menu d-none d-md-inline-flex position-relative">
<app-icon-button <app-icon-button
variant="user" variant="user"
ariaLabel="Mi cuenta" ariaLabel="Mi cuenta"
@@ -103,90 +54,25 @@
<app-user-dropdown <app-user-dropdown
id="store-user-dropdown" id="store-user-dropdown"
[user]="user()" [user]="user()"
[accountNavigationMenus]="accountNavigationMenus()"
(logoutClick)="onLogoutClick()" (logoutClick)="onLogoutClick()"
/> />
} }
</div> </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>
</div> </div>
</div> </div>
<div class="store-layout__categories d-none d-md-block"> <div>
<div class="container-xl h-100 px-3 px-md-4 d-flex align-items-center"> <div class="container-xl px-3 px-md-4">
@if (displayCategories()) {
<div class="store-layout__category-menu">
<button <button
type="button" type="button"
class="btn btn-link d-inline-flex align-items-center gap-2 px-0 py-3 text-uppercase fw-bold text-decoration-none store-layout__category-trigger" class="btn btn-link d-inline-flex align-items-center gap-2 px-0 py-2 text-uppercase fw-bold text-decoration-none store-layout__category-trigger"
aria-label="Categorias" aria-label="Categorias"
aria-controls="store-category-dropdown"
[attr.aria-expanded]="isCategoryDropdownOpen()"
(click)="toggleCategoryDropdown()"
> >
<span>Categorias</span> <span>Categorias</span>
<i class="fa-solid fa-chevron-down" aria-hidden="true"></i> <i class="fa-solid fa-chevron-down" aria-hidden="true"></i>
</button> </button>
<app-category-dropdown
id="store-category-dropdown"
[categories]="categories()"
[open]="isCategoryDropdownOpen()"
(categorySelect)="onCategorySelect($event)"
/>
</div>
}
@if (checkoutRemainingTime(); as remainingTime) {
<div
class="store-layout__checkout-timer ms-auto d-none d-md-flex align-items-baseline gap-3"
role="timer"
aria-label="Tiempo restante de compra"
>
<span class="store-layout__checkout-timer-label">Tiempo restante de compra:</span>
<span class="store-layout__checkout-timer-value">{{ remainingTime }}</span>
</div>
}
</div> </div>
</div> </div>
</header> </header>

View File

@@ -1,41 +1,16 @@
.store-layout__header { .store-layout__header {
color: #ffffff; color: #ffffff;
background-color: var(--tenant-header-bg); background-color: var(--tenant-header-bg);
background-repeat: no-repeat; box-shadow: inset 0 -1px 0 rgba(255, 255, 255, 0.08);
background-position: center;
background-size: cover;
} }
.store-layout__header-main { .store-layout__header-main {
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.store-layout__categories {
height: 3.5rem;
}
.store-layout__checkout-timer {
color: var(--tenant-primary);
font-weight: 700;
white-space: nowrap;
}
.store-layout__checkout-timer-label {
font-size: 13px;
}
.store-layout__checkout-timer-value {
font-size: 20px;
line-height: 1;
font-variant-numeric: tabular-nums;
} }
.store-layout__brand-slot { .store-layout__brand-slot {
min-width: 150px; min-width: 150px;
} max-width:40px;
.store-layout__brand-column {
max-width: 12rem;
} }
.store-layout__brand-logo-shell { .store-layout__brand-logo-shell {
@@ -66,40 +41,14 @@
animation: store-header-logo-skeleton 1.2s ease-in-out infinite; animation: store-header-logo-skeleton 1.2s ease-in-out infinite;
} }
.store-layout__search-wrapper {
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 { .store-layout__search {
width: 100%; width: min(100%, 260px);
} min-width: 0;
.store-layout__search-error {
margin-top: 0.25rem;
font-size: 0.75rem;
line-height: 1.2;
color: var(--tenant-danger, #dc3545);
} }
.store-layout__search-input { .store-layout__search-input {
color: #666666; color: #666666;
border-color: var(--border-color); border-color: #cccccc;
background-color: rgba(255, 255, 255, 0.96); background-color: rgba(255, 255, 255, 0.96);
} }
@@ -108,21 +57,21 @@
} }
.store-layout__search-input:focus { .store-layout__search-input:focus {
border-color: var(--border-color); border-color: #cccccc;
box-shadow: none; box-shadow: none;
outline: none; outline: none;
} }
.store-layout__search-button { .store-layout__search-button {
color: #a0a0a0; color: #a0a0a0;
border-color: var(--border-color); border-color: #cccccc;
background-color: rgba(255, 255, 255, 0.96); background-color: rgba(255, 255, 255, 0.96);
} }
.store-layout__search-button:hover, .store-layout__search-button:hover,
.store-layout__search-button:focus-visible { .store-layout__search-button:focus-visible {
color: #8a8a8a; color: #8a8a8a;
border-color: var(--border-color); border-color: #cccccc;
background-color: #ffffff; background-color: #ffffff;
} }
@@ -151,19 +100,6 @@
color: #8a8a8a; color: #8a8a8a;
} }
.store-layout__category-menu {
position: relative;
display: inline-flex;
flex-direction: column;
}
.store-layout__category-menu app-category-dropdown {
position: absolute;
z-index: 1050;
top: 100%;
left: 0;
}
@keyframes store-header-logo-skeleton { @keyframes store-header-logo-skeleton {
from { from {
background-position: 200% 0; background-position: 200% 0;
@@ -177,70 +113,5 @@
@media (max-width: 767.98px) { @media (max-width: 767.98px) {
.store-layout__brand-slot { .store-layout__brand-slot {
min-width: 0; 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,108 +1,44 @@
import { import { Component, ElementRef, HostListener, inject, input, output, signal } from '@angular/core';
Component, import { RouterLink } from '@angular/router';
computed,
ElementRef,
HostListener,
inject,
input,
output,
signal,
} from '@angular/core';
import { Router, RouterLink } from '@angular/router';
import { AuthUser } from '../../../services/auth/auth.interfaces'; import { AuthUser } from '../../../services/auth/auth.interfaces';
import { CartIconComponent } from '../../../../shared/components/cart-icon/cart-icon.component'; import { CartIconComponent } from '../../../../shared/components/cart-icon/cart-icon.component';
import { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.component'; import { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.component';
import { UserDropdownComponent } from './user-dropdown/user-dropdown.component'; import { UserDropdownComponent } from './user-dropdown/user-dropdown.component';
import { Menu } from '../../../services/tenant.interface';
import { ButtonComponent } from '../../../../shared/components/button/button.component';
import { ReactiveFormsModule, FormControl } from '@angular/forms';
import { Category } from '../../../services/tenant.interface';
import { CategoryDropdownComponent } from './category-dropdown/category-dropdown.component';
@Component({ @Component({
selector: 'app-store-header', selector: 'app-store-header',
imports: [ imports: [CartIconComponent, IconButtonComponent, RouterLink, UserDropdownComponent],
ButtonComponent,
CartIconComponent,
IconButtonComponent,
RouterLink,
ReactiveFormsModule,
UserDropdownComponent,
CategoryDropdownComponent,
],
templateUrl: './store-header.component.html', templateUrl: './store-header.component.html',
styleUrl: './store-header.component.scss', styleUrl: './store-header.component.scss'
}) })
export class StoreHeaderComponent { export class StoreHeaderComponent {
private readonly elementRef = inject(ElementRef<HTMLElement>); private readonly elementRef = inject(ElementRef<HTMLElement>);
private readonly router = inject(Router);
readonly logoUrl = input<string | null>(null); readonly logoUrl = input<string | null>(null);
readonly backgroundImageUrl = input<string | null>(null);
readonly cartQuantity = input<number>(0); readonly cartQuantity = input<number>(0);
readonly user = input<AuthUser | null>(null); readonly user = input<AuthUser | null>(null);
readonly isAuthenticated = input<boolean>(false); readonly isAuthenticated = input<boolean>(false);
readonly accountNavigationMenus = input<readonly Menu[]>([]);
readonly ticketsMenu = input<Menu | null>(null);
readonly categories = input<readonly Category[]>([]);
readonly displayCategories = input(true);
readonly displaySeachBar = input(true);
readonly displayCart = input(true);
readonly cartDisabled = input(false);
readonly checkoutRemainingSeconds = input<number | null>(null);
readonly cartClick = output<void>(); readonly cartClick = output<void>();
readonly ticketsClick = output<void>();
readonly loginClick = output<void>(); readonly loginClick = output<void>();
readonly logoutClick = output<void>(); readonly logoutClick = output<void>();
readonly searchSubmit = output<string>();
readonly categorySelect = output<Category>();
protected readonly isUserDropdownOpen = signal(false); protected readonly isUserDropdownOpen = signal(false);
protected readonly isCategoryDropdownOpen = signal(false);
protected readonly isMobileMenuOpen = signal(false);
protected readonly minSearchLength = 3;
protected readonly showSearchError = signal(false);
protected readonly searchControl = new FormControl('', { nonNullable: true });
protected readonly checkoutRemainingTime = computed(() => {
const remainingSeconds = this.checkoutRemainingSeconds();
if (remainingSeconds === null) {
return null;
}
const minutes = Math.floor(remainingSeconds / 60);
const seconds = remainingSeconds % 60;
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
});
protected onCartClick(): void {
if (this.cartDisabled()) {
return;
}
this.cartClick.emit();
}
@HostListener('document:click', ['$event']) @HostListener('document:click', ['$event'])
protected onDocumentClick(event: MouseEvent): void { protected onDocumentClick(event: MouseEvent): void {
if (!this.isUserDropdownOpen() && !this.isCategoryDropdownOpen() && !this.isMobileMenuOpen()) { if (!this.isUserDropdownOpen()) {
return; return;
} }
const target = event.target; const target = event.target;
if (target instanceof Node && !this.elementRef.nativeElement.contains(target)) { if (target instanceof Node && !this.elementRef.nativeElement.contains(target)) {
this.isUserDropdownOpen.set(false); this.isUserDropdownOpen.set(false);
this.isCategoryDropdownOpen.set(false);
this.isMobileMenuOpen.set(false);
} }
} }
@HostListener('document:keydown.escape') @HostListener('document:keydown.escape')
protected onEscapeKey(): void { protected onEscapeKey(): void {
this.isUserDropdownOpen.set(false); this.isUserDropdownOpen.set(false);
this.isCategoryDropdownOpen.set(false);
this.isMobileMenuOpen.set(false);
} }
protected toggleUserDropdown(): void { protected toggleUserDropdown(): void {
@@ -123,50 +59,4 @@ export class StoreHeaderComponent {
this.isUserDropdownOpen.set(false); this.isUserDropdownOpen.set(false);
this.logoutClick.emit(); this.logoutClick.emit();
} }
protected submitSearch(event?: Event): void {
event?.preventDefault();
const term = this.searchControl.value.trim();
if (term.length < this.minSearchLength) {
this.showSearchError.set(true);
return;
}
this.showSearchError.set(false);
this.searchSubmit.emit(term);
}
protected clearSearchError(): void {
this.showSearchError.set(false);
}
protected toggleCategoryDropdown(): void {
this.isCategoryDropdownOpen.update((isOpen) => !isOpen);
}
protected onCategorySelect(category: Category): void {
this.isCategoryDropdownOpen.set(false);
this.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> <div class="user-dropdown__divider"></div>
@for (menu of accountNavigationMenus(); track menu.code) { <a routerLink="/mi-cuenta/datos-personales" class="user-dropdown__item" role="menuitem" data-testid="user-dropdown-account">
<a Mi cuenta
[routerLink]="menu.route" </a>
class="user-dropdown__item" <a routerLink="/mi-cuenta/compras" class="user-dropdown__item" role="menuitem" data-testid="user-dropdown-purchases">
role="menuitem" Mis compras
[attr.data-testid]="'user-dropdown-' + menu.code"
>
{{ menu.label }}
</a> </a>
}
<button <button
type="button" type="button"
class="user-dropdown__item" class="user-dropdown__item"

View File

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

View File

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

View File

@@ -1,28 +1,15 @@
<div class="store-layout d-flex flex-column flex-grow-1"> <div class="store-layout d-flex flex-column flex-grow-1">
<app-store-header <app-store-header
ngSkipHydration
[cartQuantity]="cartQuantity()" [cartQuantity]="cartQuantity()"
[logoUrl]="tenant()?.header_logo ?? null" [logoUrl]="tenant()?.header_logo ?? null"
[backgroundImageUrl]="tenant()?.header_bg_image ?? null"
[user]="user()" [user]="user()"
[isAuthenticated]="isAuthenticated()" [isAuthenticated]="isAuthenticated()"
[accountNavigationMenus]="accountNavigationMenus()" (cartClick)="isCartOpen.set(!isCartOpen())"
[ticketsMenu]="ticketsMenu() ?? null"
[categories]="tenant()?.categories ?? []"
[displayCategories]="tenant()?.display_categories ?? true"
[displaySeachBar]="tenant()?.display_seach_bar ?? true"
[displayCart]="displayCart()"
[cartDisabled]="isCheckoutRoute()"
[checkoutRemainingSeconds]="checkoutRemainingSeconds()"
(cartClick)="onCartClick()"
(ticketsClick)="onTicketsClick()"
(loginClick)="onLoginClick()" (loginClick)="onLoginClick()"
(logoutClick)="onLogoutClick()" (logoutClick)="onLogoutClick()"
(searchSubmit)="onSearch($event)"
(categorySelect)="onCategorySelect($event)"
/> />
@if (displayCart() && !isCheckoutRoute() && isCartOpen()) { @if (isCartOpen()) {
<div class="store-layout__cart-overlay" (click)="isCartOpen.set(false)"></div> <div class="store-layout__cart-overlay" (click)="isCartOpen.set(false)"></div>
<div class="store-layout__cart-dropdown card border-0 shadow-lg"> <div class="store-layout__cart-dropdown card border-0 shadow-lg">
<app-cart <app-cart
@@ -31,25 +18,11 @@
[subtotal]="cartSubtotal()" [subtotal]="cartSubtotal()"
[discount]="cartDiscount()" [discount]="cartDiscount()"
[total]="cartTotal()" [total]="cartTotal()"
[readonly]="!canModifyCart()"
[allowModify]="canModifyCart()"
[allowUpdateQuantity]="canUpdateCartQuantity()"
[allowUpdateVariant]="canUpdateCartVariant()"
[allowDelete]="canDeleteCartItems()"
[backgroundColor]="'#ffffff'" [backgroundColor]="'#ffffff'"
(closed)="isCartOpen.set(false)" (closed)="isCartOpen.set(false)"
> >
<app-button variant="secondary" class="flex-grow-1" (click)="isCartOpen.set(false)" <app-button variant="secondary" class="flex-grow-1" (click)="isCartOpen.set(false)">Seguir comprando</app-button>
>Seguir comprando</app-button <app-button variant="primary" class="flex-grow-1" (click)="onCheckoutClick()">Comprar</app-button>
>
<app-button
variant="primary"
class="flex-grow-1"
[disabled]="isCreatingPurchase()"
(click)="onCheckoutClick()"
>
Comprar
</app-button>
</app-cart> </app-cart>
</div> </div>
} }
@@ -64,13 +37,8 @@
<app-store-footer <app-store-footer
[currentYear]="currentYear" [currentYear]="currentYear"
[footerSections]="footerSections()" [footerSections]="footerSections"
[socialMedia]="tenant()?.social_media ?? []" [socialLinks]="socialLinks"
[logoUrl]="tenant()?.footer_logo ?? null" [logoUrl]="tenant()?.footer_logo ?? null"
[backgroundImageUrl]="tenant()?.footer_bg_image ?? null"
[storeName]="tenant()?.nombre ?? null"
[address]="tenant()?.address ?? null"
[phone]="tenant()?.phone ?? null"
(logoutClick)="onLogoutClick()"
/> />
</div> </div>

View File

@@ -21,25 +21,18 @@
.store-layout__cart-dropdown { .store-layout__cart-dropdown {
position: absolute; position: absolute;
display: flex; top: 76px;
flex-direction: column;
top: 100px;
right: calc((100% - 1320px) / 2 + 1.5rem); right: calc((100% - 1320px) / 2 + 1.5rem);
width: 100%;
max-width: 400px;
max-height: calc(100vh - 120px); max-height: calc(100vh - 120px);
z-index: 1050; z-index: 1050;
background-color: #ffffff; background-color: #ffffff;
border-radius: 8px; 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); box-shadow: 0 10px 25px rgba(0, 0, 0, 0.08);
overflow: hidden; overflow: hidden;
animation: store-layout-slide-down 0.2s ease-out; animation: store-layout-slide-down 0.2s ease-out;
> app-cart {
min-height: 0;
}
@media (max-width: 1400px) { @media (max-width: 1400px) {
right: 1.5rem; right: 1.5rem;
} }
@@ -47,7 +40,6 @@
right: 1rem; right: 1rem;
left: 1rem; left: 1rem;
width: auto; width: auto;
max-width: none;
} }
} }

View File

@@ -1,39 +1,23 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'; import { describe, it, expect, beforeEach, vi } from 'vitest';
import { signal } from '@angular/core'; import { signal } from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http';
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser'; import { provideRouter, Router } from '@angular/router';
import {
ActivatedRoute,
convertToParamMap,
ParamMap,
provideRouter,
Router,
UrlSerializer,
} from '@angular/router';
import { BehaviorSubject, of } from 'rxjs'; import { of } from 'rxjs';
import { Tenant } from '../../services/tenant.interface'; import { Tenant } from '../../services/tenant.interface';
import { TenantService } from '../../services/tenant.service'; import { TenantService } from '../../services/tenant.service';
import { CartService } from '../../services/cart/cart.service'; import { CartService } from '../../services/cart/cart.service';
import { Cart } from '../../services/cart/cart.interface';
import { ToastService } from '../../services/toast.service'; import { ToastService } from '../../services/toast.service';
import { AuthService } from '../../services/auth/auth.service'; import { AuthService } from '../../services/auth/auth.service';
import { AuthUser } from '../../services/auth/auth.interfaces'; import { AuthUser } from '../../services/auth/auth.interfaces';
import { StoreLayoutComponent } from './store-layout.component'; import { StoreLayoutComponent } from './store-layout.component';
import { StoreHeaderComponent } from './store-header/store-header.component';
import { CheckoutService } from '../../services/checkout.service';
import { CheckoutCountdownService } from '../../services/checkout-countdown.service';
import { TenantUrlSerializer } from '../../services/tenant-url.serializer';
const tenant: Tenant = { const tenant: Tenant = {
id: 1, id: 1,
codigo: 'test', codigo: 'test',
nombre: 'Test Tenant', nombre: 'Test Tenant',
dominio: 'localhost', dominio: 'localhost',
address: 'Calle Test 123, Rosario',
phone: '+54 341 555 1234',
primary_color: '#6376F3', primary_color: '#6376F3',
secondary_color: '#A0A0A0', secondary_color: '#A0A0A0',
danger_color: '#FF8888', danger_color: '#FF8888',
@@ -41,160 +25,36 @@ const tenant: Tenant = {
header_bg_color: '#313131', header_bg_color: '#313131',
footer_bg_color: '#313131', footer_bg_color: '#313131',
header_logo: 'https://example.com/header.png', header_logo: 'https://example.com/header.png',
footer_logo: 'https://example.com/footer.png', footer_logo: 'https://example.com/footer.png'
header_bg_image: 'https://example.com/header-background.png',
footer_bg_image: 'https://example.com/footer-background.png',
cart_editing_policy: {
code: 'full',
allow_modify: true,
allow_delete: true,
allow_update_quantity: true,
allow_update_variant: true,
},
categories: [],
menues: [
{
id: 4,
code: 'account',
label: 'Mi cuenta',
parent_menu_code: null,
content_type: 'dynamic',
route: '/mi-cuenta',
submenues: [
{
id: 5,
code: 'account.profile',
label: 'Datos personales',
parent_menu_code: 'account',
content_type: 'dynamic',
route: '/mi-cuenta/datos-personales',
submenues: [],
},
{
id: 6,
code: 'account.purchases',
label: 'Mis compras',
parent_menu_code: 'account',
content_type: 'dynamic',
route: '/mi-cuenta/compras',
submenues: [],
},
{
id: 7,
code: 'account.tickets',
label: 'Mis tickets',
parent_menu_code: 'account',
content_type: 'dynamic',
route: '/mi-cuenta/tickets',
submenues: [],
},
],
},
{
id: 1,
code: 'help',
label: 'Ayuda',
parent_menu_code: null,
content_type: 'dynamic',
route: '/ayuda',
submenues: [
{
id: 2,
code: 'help.contact',
label: 'Contacto',
parent_menu_code: 'help',
content_type: 'static',
route: '/ayuda/contacto',
submenues: [],
},
{
id: 3,
code: 'help.faq',
label: 'Preguntas frecuentes',
parent_menu_code: 'help',
content_type: 'static',
route: '/ayuda/preguntas-frecuentes',
submenues: [],
},
],
},
],
social_media: [
{
code: 'instagram',
icon: 'instagram',
name: 'Instagram',
url: 'https://instagram.com/test',
},
{
code: 'whatsapp',
icon: 'whatsapp',
name: 'WhatsApp',
url: 'https://wa.me/5493410000000',
},
{
code: 'facebook',
icon: 'facebook',
name: 'Facebook',
url: 'https://facebook.com/test',
},
{
code: 'linkedin',
icon: 'fa-brands fa-linkedin-in',
name: 'LinkedIn',
url: 'https://linkedin.com/company/test',
},
],
}; };
describe('StoreLayoutComponent', () => { describe('StoreLayoutComponent', () => {
let tenantState = signal<Tenant | null>(tenant);
let cartState = signal<Cart | null>(null);
let authUserState = signal<AuthUser | null>(null);
let checkoutRemainingSecondsState = signal<number | null>(null);
let checkoutServiceStub: { startCheckout: ReturnType<typeof vi.fn> };
let queryParamMapState: BehaviorSubject<ParamMap>;
beforeEach(async () => { beforeEach(async () => {
tenantState = signal<Tenant | null>(tenant); const tenantState = signal(tenant);
cartState = signal<Cart | null>(null); const authUserState = signal<AuthUser | null>(null);
authUserState = signal<AuthUser | null>(null);
checkoutRemainingSecondsState = signal<number | null>(null);
queryParamMapState = new BehaviorSubject(convertToParamMap({}));
const isAuthenticatedState = signal(false); const isAuthenticatedState = signal(false);
checkoutServiceStub = {
startCheckout: vi.fn().mockResolvedValue({ id: 55 }),
};
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [StoreLayoutComponent], imports: [StoreLayoutComponent],
providers: [ providers: [
provideRouter([]), provideRouter([]),
{ provide: UrlSerializer, useClass: TenantUrlSerializer },
{
provide: ActivatedRoute,
useValue: { queryParamMap: queryParamMapState.asObservable() },
},
{ {
provide: TenantService, provide: TenantService,
useValue: { useValue: {
tenant: tenantState.asReadonly(), tenant: tenantState.asReadonly(),
status: signal<'ready'>('ready').asReadonly(), status: signal<'ready'>('ready').asReadonly(),
getTenant: () => tenantState(), getTenant: () => tenantState(),
bootstrap: vi.fn().mockResolvedValue(undefined), bootstrap: vi.fn().mockResolvedValue(undefined)
}, }
}, },
{ {
provide: CartService, provide: CartService,
useValue: { useValue: {
cart: cartState.asReadonly(), cart: signal(null).asReadonly(),
loadCart: () => of({ id: 1, items: [], subtotal: '0' }), loadCart: () => of({ id: 1, items: [], subtotal: '0' }),
updateItemQuantity: vi updateItemQuantity: vi.fn().mockReturnValue(of({ data: { id: 1, items: [], subtotal: '0' } })),
.fn() removeItem: vi.fn().mockReturnValue(of({ data: { id: 1, items: [], subtotal: '0' } }))
.mockReturnValue(of({ data: { id: 1, items: [], subtotal: '0' } })), }
removeItem: vi.fn().mockReturnValue(of({ data: { id: 1, items: [], subtotal: '0' } })),
clearCart: vi.fn(() => cartState.set(null)),
},
}, },
{ {
provide: ToastService, provide: ToastService,
@@ -203,28 +63,18 @@ describe('StoreLayoutComponent', () => {
info: vi.fn(), info: vi.fn(),
danger: vi.fn(), danger: vi.fn(),
success: vi.fn(), success: vi.fn(),
dismiss: vi.fn(), dismiss: vi.fn()
}, }
}, },
{ {
provide: AuthService, provide: AuthService,
useValue: { useValue: {
user: authUserState, user: authUserState,
isAuthenticated: isAuthenticatedState, isAuthenticated: isAuthenticatedState,
logout: vi.fn().mockReturnValue(of(void 0)), logout: vi.fn().mockReturnValue(of(void 0))
}, }
}, }
{ ]
provide: CheckoutService,
useValue: checkoutServiceStub,
},
{
provide: CheckoutCountdownService,
useValue: {
remainingSeconds: checkoutRemainingSecondsState.asReadonly(),
},
},
],
}).compileComponents(); }).compileComponents();
}); });
@@ -240,93 +90,6 @@ describe('StoreLayoutComponent', () => {
expect(compiled.querySelector('app-store-footer .store-layout__footer')).not.toBeNull(); expect(compiled.querySelector('app-store-footer .store-layout__footer')).not.toBeNull();
}); });
it('opens the cart when requested through the openCart query parameter', () => {
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
expect((fixture.componentInstance as any).isCartOpen()).toBe(false);
queryParamMapState.next(convertToParamMap({ openCart: 'true' }));
fixture.detectChanges();
expect((fixture.componentInstance as any).isCartOpen()).toBe(true);
expect((fixture.nativeElement as HTMLElement).querySelector('app-cart')).not.toBeNull();
});
it('disables the cart icon and prevents opening the popup during checkout with a tenant base path', () => {
tenantState.set({ ...tenant, base_path: 'fiesta' });
const router = TestBed.inject(Router);
vi.spyOn(router, 'url', 'get').mockReturnValue('/fiesta/checkout/25');
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const cartButton = element.querySelector<HTMLButtonElement>('app-cart-icon button');
const header = fixture.debugElement.query(By.directive(StoreHeaderComponent));
expect(cartButton?.disabled).toBe(true);
expect(cartButton?.getAttribute('aria-label')).toBe(
'Carrito no disponible durante el checkout',
);
expect(element.querySelector('[data-testid="cart-badge"]')).toBeNull();
header.componentInstance.cartClick.emit();
queryParamMapState.next(convertToParamMap({ openCart: 'true' }));
fixture.detectChanges();
expect((fixture.componentInstance as any).isCartOpen()).toBe(false);
expect(element.querySelector('app-cart')).toBeNull();
expect(element.querySelector('.store-layout__cart-overlay')).toBeNull();
});
it('shows the synchronized countdown in the header whenever one is active', () => {
checkoutRemainingSecondsState.set(587);
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('.store-layout__checkout-timer-label')?.textContent).toContain(
'Tiempo restante de compra:',
);
expect(element.querySelector('.store-layout__checkout-timer-value')?.textContent?.trim()).toBe(
'09:47',
);
});
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', () => { it('renders the tenant branding and action icons', () => {
const fixture = TestBed.createComponent(StoreLayoutComponent); const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges(); fixture.detectChanges();
@@ -336,154 +99,17 @@ describe('StoreLayoutComponent', () => {
expect(compiled.querySelector('[data-testid="store-header-brand-slot"]')).not.toBeNull(); expect(compiled.querySelector('[data-testid="store-header-brand-slot"]')).not.toBeNull();
expect(compiled.querySelector('[data-testid="store-footer-brand-slot"]')).not.toBeNull(); expect(compiled.querySelector('[data-testid="store-footer-brand-slot"]')).not.toBeNull();
expect( expect(
compiled.querySelector( compiled.querySelector(`app-store-header img.store-layout__brand-logo[src="${tenant.header_logo}"]`)
`app-store-header img.store-layout__brand-logo[src="${tenant.header_logo}"]`,
),
).not.toBeNull(); ).not.toBeNull();
expect( expect(
compiled.querySelector( compiled.querySelector(`app-store-footer img.store-layout__brand-logo[src="${tenant.footer_logo}"]`)
`app-store-footer img.store-layout__brand-logo[src="${tenant.footer_logo}"]`,
),
).not.toBeNull(); ).not.toBeNull();
expect(
(compiled.querySelector('.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-cart-shopping')).not.toBeNull();
expect(compiled.querySelector('.fa-circle-user')).not.toBeNull(); expect(compiled.querySelector('.fa-circle-user')).not.toBeNull();
expect(compiled.querySelector('.fa-instagram')).not.toBeNull(); expect(compiled.querySelector('.fa-instagram')).not.toBeNull();
expect(compiled.querySelector('.fa-whatsapp')).not.toBeNull(); expect(compiled.querySelector('.fa-whatsapp')).not.toBeNull();
expect(compiled.querySelector('.fa-facebook')).not.toBeNull(); expect(compiled.querySelector('.fa-facebook')).not.toBeNull();
expect(compiled.querySelector('.fa-linkedin-in')).not.toBeNull(); expect(compiled.querySelector('.fa-linkedin-in')).not.toBeNull();
expect(compiled.querySelector('.store-layout__contact-details')?.textContent).toContain(
tenant.address,
);
expect(compiled.querySelector('.store-layout__contact-details')?.textContent).toContain(
tenant.phone,
);
});
it('navigates to search results when the search button is clicked', () => {
const router = TestBed.inject(Router);
vi.spyOn(router, 'navigate');
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
const header = fixture.debugElement.query(By.directive(StoreHeaderComponent));
const searchButton = header.query(By.css('.store-layout__search-button'));
(header.componentInstance as any).searchControl.setValue(' zapatillas ');
searchButton.triggerEventHandler('click');
fixture.detectChanges();
expect(router.navigate).toHaveBeenCalledWith(['/buscar'], {
queryParams: { q: 'zapatillas', page: 1 },
});
});
it('prevents the native submit and searches when Enter is pressed', () => {
const router = TestBed.inject(Router);
vi.spyOn(router, 'navigate');
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
const header = fixture.debugElement.query(By.directive(StoreHeaderComponent));
const input = header.query(By.css('#store-search-input'));
const keyboardEvent = {
preventDefault: vi.fn(),
};
(header.componentInstance as any).searchControl.setValue('remera');
input.triggerEventHandler('keydown.enter', keyboardEvent);
expect(keyboardEvent.preventDefault).toHaveBeenCalled();
expect(router.navigate).toHaveBeenCalledWith(['/buscar'], {
queryParams: { q: 'remera', page: 1 },
});
});
it('requires at least three characters to search', () => {
const router = TestBed.inject(Router);
vi.spyOn(router, 'navigate');
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
const header = fixture.debugElement.query(By.directive(StoreHeaderComponent));
const input = header.query(By.css('#store-search-input'));
const searchButton = header.query(By.css('.store-layout__search-button'));
expect(input.nativeElement.getAttribute('minlength')).toBe('3');
(header.componentInstance as any).searchControl.setValue('ab');
searchButton.triggerEventHandler('click');
fixture.detectChanges();
expect(router.navigate).not.toHaveBeenCalled();
expect((fixture.nativeElement as HTMLElement).textContent).toContain(
'Ingresá al menos 3 caracteres para buscar.',
);
expect(input.nativeElement.getAttribute('aria-invalid')).toBe('true');
});
it('renders the primary tickets action when the tenant has the tickets menu', () => {
const router = TestBed.inject(Router);
vi.spyOn(router, 'navigate');
const configuredTicketsLabel = 'Entradas digitales';
tenantState.set({
...tenant,
menues: tenant.menues?.map((menu) =>
menu.code === 'account'
? {
...menu,
submenues: menu.submenues.map((submenu) =>
submenu.code === 'account.tickets'
? { ...submenu, label: configuredTicketsLabel }
: submenu,
),
}
: menu,
),
});
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
const ticketsAction = (fixture.nativeElement as HTMLElement).querySelector<HTMLElement>(
'app-button[data-testid="store-header-tickets"]',
);
const ticketsButton = ticketsAction?.querySelector('button');
expect(ticketsAction).not.toBeNull();
expect(ticketsButton).not.toBeNull();
expect(ticketsButton?.textContent?.trim()).toBe(configuredTicketsLabel);
expect(ticketsButton?.classList).toContain('btn-primary');
ticketsButton?.click();
expect(router.navigate).toHaveBeenCalledWith(['/mi-cuenta/tickets']);
});
it('does not render the tickets action when the tenant lacks the tickets menu', () => {
tenantState.set({
...tenant,
menues: tenant.menues?.map((menu) =>
menu.code === 'account'
? {
...menu,
submenues: menu.submenues.filter((submenu) => submenu.code !== 'account.tickets'),
}
: menu,
),
});
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
expect(
(fixture.nativeElement as HTMLElement).querySelector('[data-testid="store-header-tickets"]'),
).toBeNull();
}); });
it('redirects to /login when user icon is clicked and user is not logged in', () => { it('redirects to /login when user icon is clicked and user is not logged in', () => {
@@ -497,9 +123,7 @@ describe('StoreLayoutComponent', () => {
fixture.detectChanges(); fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement; const compiled = fixture.nativeElement as HTMLElement;
const userButton = compiled.querySelector( const userButton = compiled.querySelector('app-icon-button[variant="user"] button') as HTMLButtonElement;
'app-icon-button[variant="user"] button',
) as HTMLButtonElement;
expect(userButton).not.toBeNull(); expect(userButton).not.toBeNull();
userButton.click(); userButton.click();
fixture.detectChanges(); fixture.detectChanges();
@@ -517,33 +141,25 @@ describe('StoreLayoutComponent', () => {
(authService.user as any).set({ (authService.user as any).set({
id: 1, id: 1,
nombre_apellido: 'Ada Lovelace', nombre_apellido: 'Ada Lovelace',
email: 'ada@example.com', email: 'ada@example.com'
}); });
const fixture = TestBed.createComponent(StoreLayoutComponent); const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges(); fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement; const compiled = fixture.nativeElement as HTMLElement;
const userButton = compiled.querySelector( const userButton = compiled.querySelector('app-icon-button[variant="user"] button') as HTMLButtonElement;
'app-icon-button[variant="user"] button',
) as HTMLButtonElement;
expect(userButton).not.toBeNull(); expect(userButton).not.toBeNull();
userButton.click(); userButton.click();
fixture.detectChanges(); fixture.detectChanges();
expect(compiled.querySelector('app-user-dropdown')).not.toBeNull(); expect(compiled.querySelector('app-user-dropdown')).not.toBeNull();
expect(compiled.textContent).toContain('Ada Lovelace'); expect(compiled.textContent).toContain('Ada Lovelace');
expect(compiled.querySelector('[data-testid="user-dropdown-account"]')).not.toBeNull();
expect(
compiled.querySelector('[data-testid="user-dropdown-account.purchases"]'),
).not.toBeNull();
expect(compiled.querySelector('[data-testid="user-dropdown-account.tickets"]')).toBeNull();
expect(router.navigate).not.toHaveBeenCalled(); expect(router.navigate).not.toHaveBeenCalled();
}); });
it('logs out from the authenticated user dropdown, clears the cart and returns home', () => { it('logs out from the authenticated user dropdown', () => {
const authService = TestBed.inject(AuthService); const authService = TestBed.inject(AuthService);
const cartService = TestBed.inject(CartService);
const router = TestBed.inject(Router); const router = TestBed.inject(Router);
vi.spyOn(router, 'navigate'); vi.spyOn(router, 'navigate');
@@ -551,324 +167,45 @@ describe('StoreLayoutComponent', () => {
(authService.user as any).set({ (authService.user as any).set({
id: 1, id: 1,
nombre_apellido: 'Ada Lovelace', nombre_apellido: 'Ada Lovelace',
email: 'ada@example.com', email: 'ada@example.com'
}); });
const fixture = TestBed.createComponent(StoreLayoutComponent); const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges(); fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement; const compiled = fixture.nativeElement as HTMLElement;
const userButton = compiled.querySelector( const userButton = compiled.querySelector('app-icon-button[variant="user"] button') as HTMLButtonElement;
'app-icon-button[variant="user"] button',
) as HTMLButtonElement;
userButton.click(); userButton.click();
fixture.detectChanges(); fixture.detectChanges();
const logoutButton = compiled.querySelector( const logoutButton = compiled.querySelector('[data-testid="user-dropdown-logout"]') as HTMLButtonElement;
'[data-testid="user-dropdown-logout"]',
) as HTMLButtonElement;
expect(logoutButton).not.toBeNull(); expect(logoutButton).not.toBeNull();
logoutButton.click(); logoutButton.click();
fixture.detectChanges(); fixture.detectChanges();
expect(authService.logout).toHaveBeenCalled(); expect(authService.logout).toHaveBeenCalled();
expect(cartService.clearCart).toHaveBeenCalled(); expect(router.navigate).toHaveBeenCalledWith(['/login']);
expect(router.navigate).toHaveBeenCalledWith(['/']);
}); });
it('provides account actions from the footer', () => { it('redirects to /checkout when cart buy button is clicked', () => {
const authService = TestBed.inject(AuthService);
const cartService = TestBed.inject(CartService);
const router = TestBed.inject(Router); const router = TestBed.inject(Router);
vi.spyOn(router, 'navigate'); vi.spyOn(router, 'navigate');
const fixture = TestBed.createComponent(StoreLayoutComponent); const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges(); fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const footerLinks = Array.from(
compiled.querySelectorAll<HTMLAnchorElement>('app-store-footer a.store-layout__footer-link'),
);
const profileLink = footerLinks.find((link) => link.textContent?.trim() === 'Mi cuenta');
const purchasesLink = footerLinks.find((link) => link.textContent?.trim() === 'Mis compras');
const ticketsLink = footerLinks.find((link) => link.textContent?.trim() === 'Mis tickets');
const logoutButton = Array.from(
compiled.querySelectorAll<HTMLButtonElement>('app-store-footer button'),
).find((button) => button.textContent?.trim() === 'Cerrar sesion');
expect(profileLink?.getAttribute('href')).toBe('/mi-cuenta/datos-personales');
expect(purchasesLink?.getAttribute('href')).toBe('/mi-cuenta/compras');
expect(ticketsLink).toBeUndefined();
expect(logoutButton).toBeDefined();
logoutButton!.click();
fixture.detectChanges();
expect(authService.logout).toHaveBeenCalled();
expect(cartService.clearCart).toHaveBeenCalled();
expect(router.navigate).toHaveBeenCalledWith(['/']);
});
it('leaves checkout before logging out so the pending purchase can be cancelled', async () => {
const authService = TestBed.inject(AuthService);
const cartService = TestBed.inject(CartService);
const router = TestBed.inject(Router);
vi.spyOn(router, 'url', 'get').mockReturnValue('/checkout/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,
nombre: 'Producto',
imagen: null,
variant: null,
},
],
});
authUserState.set({
id: 7,
nombre_apellido: 'Juan Perez',
email: 'juan@example.com',
dni: '12345678',
telefono: '3415555555',
});
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
(fixture.componentInstance as any).isCartOpen.set(true); (fixture.componentInstance as any).isCartOpen.set(true);
fixture.detectChanges(); fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement; const compiled = fixture.nativeElement as HTMLElement;
const buyButton = Array.from(compiled.querySelectorAll('app-button button')).find( const buyButton = Array.from(compiled.querySelectorAll('app-button button'))
(button) => button.textContent?.trim() === 'Comprar', .find((button) => button.textContent?.trim() === 'Comprar') as HTMLButtonElement | undefined;
) as HTMLButtonElement | undefined;
expect(buyButton).toBeDefined(); expect(buyButton).toBeDefined();
buyButton!.click(); buyButton!.click();
await Promise.resolve();
fixture.detectChanges(); fixture.detectChanges();
expect(checkoutServiceStub.startCheckout).toHaveBeenCalledWith('test', { expect(router.navigate).toHaveBeenCalledWith(['/checkout']);
cart_id: 1,
});
expect(router.navigate).toHaveBeenCalledWith(['/checkout', 55]);
expect((fixture.componentInstance as any).isCartOpen()).toBe(false); expect((fixture.componentInstance as any).isCartOpen()).toBe(false);
}); });
it('shows the backend message and refreshes the cart when its reservation expired', async () => {
const message = 'La reserva de stock venció. Usá el carrito activo para continuar.';
checkoutServiceStub.startCheckout.mockRejectedValue(
new HttpErrorResponse({
status: 422,
error: { code: 'stock_reservation.expired', message },
}),
);
cartState.set({
id: 1,
tenant_codigo: tenant.codigo,
status: 'active',
subtotal: '100.00',
items: [],
});
authUserState.set({
id: 7,
nombre_apellido: 'Juan Perez',
email: 'juan@example.com',
dni: '12345678',
telefono: '3415555555',
});
const fixture = TestBed.createComponent(StoreLayoutComponent);
const loadCart = vi.spyOn(TestBed.inject(CartService), 'loadCart');
fixture.detectChanges();
loadCart.mockClear();
await (fixture.componentInstance as any).onCheckoutClick();
expect(TestBed.inject(ToastService).danger).toHaveBeenCalledWith(message);
expect(loadCart).toHaveBeenCalledOnce();
});
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,
nombre: 'Producto',
imagen: null,
variant: 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();
});
it('hides quantity selectors when the tenant disables cart editing', () => {
tenantState.set({
...tenant,
cart_editing_policy: {
code: 'disabled',
allow_modify: false,
allow_delete: false,
allow_update_quantity: false,
allow_update_variant: false,
},
});
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,
nombre: 'Producto',
imagen: null,
variant: null,
},
],
});
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
(fixture.componentInstance as any).isCartOpen.set(true);
fixture.detectChanges();
const cartItem = fixture.debugElement.query(By.css('app-cart-item'));
expect(cartItem.componentInstance.readonly()).toBe(true);
expect(cartItem.nativeElement.querySelector('app-quantity-selector')).toBeNull();
});
it('shows variant selectors only for the full cart editing policy', () => {
const variantCart: Cart = {
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: 10,
nombre: 'Producto',
imagen: null,
variant: { id: 10, precio: '100.00', stock_tecnico: 5, values: { talle: 'M' } },
variants: [
{ id: 10, precio: '100.00', stock_tecnico: 5, values: { talle: 'M' } },
{ id: 11, precio: '100.00', stock_tecnico: 5, values: { talle: 'L' } },
],
},
],
};
tenantState.set({
...tenant,
cart_editing_policy: {
code: 'quantity_and_remove',
allow_modify: true,
allow_delete: true,
allow_update_quantity: true,
allow_update_variant: false,
},
});
cartState.set(variantCart);
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
(fixture.componentInstance as any).isCartOpen.set(true);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('app-variant-selector')).toBeNull();
expect(fixture.nativeElement.querySelector('app-quantity-selector')).not.toBeNull();
tenantState.set({
...tenant,
cart_editing_policy: {
code: 'full',
allow_modify: true,
allow_delete: true,
allow_update_quantity: true,
allow_update_variant: true,
},
});
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('app-variant-selector')).not.toBeNull();
});
}); });

View File

@@ -1,30 +1,17 @@
import { HttpErrorResponse } from '@angular/common/http'; import { Component, computed, inject, OnInit, signal } from '@angular/core';
import { Component, computed, DestroyRef, inject, OnInit, signal } from '@angular/core'; import { Router, RouterOutlet } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import {
ActivatedRoute,
NavigationEnd,
PRIMARY_OUTLET,
Router,
RouterOutlet,
} from '@angular/router';
import { filter } from 'rxjs';
import { TenantService } from '../../services/tenant.service'; import { TenantService } from '../../services/tenant.service';
import { CartService } from '../../services/cart/cart.service'; import { CartService } from '../../services/cart/cart.service';
import { StoreFooterComponent, StoreFooterSection } from './store-footer/store-footer.component'; import {
StoreFooterComponent,
StoreFooterSection,
StoreSocialLink,
} from './store-footer/store-footer.component';
import { StoreHeaderComponent } from './store-header/store-header.component'; import { StoreHeaderComponent } from './store-header/store-header.component';
import { CartComponent, CartItemMock } from '../../../shared/components/cart/cart.component'; import { CartComponent, CartItemMock } from '../../../shared/components/cart/cart.component';
import { ButtonComponent } from '../../../shared/components/button/button.component'; import { ButtonComponent } from '../../../shared/components/button/button.component';
import { CartItem, CartItemVariantValue } from '../../services/cart/cart.interface'; import { CartItem } from '../../services/cart/cart.interface';
import { AuthService } from '../../services/auth/auth.service'; import { AuthService } from '../../services/auth/auth.service';
import { findMenu } from '../../services/menu.utils';
import {
CheckoutService,
isExpiredStockReservationResponse,
} from '../../services/checkout.service';
import { ToastService } from '../../services/toast.service';
import { Category } from '../../services/tenant.interface';
import { CheckoutCountdownService } from '../../services/checkout-countdown.service';
@Component({ @Component({
selector: 'app-store-layout', selector: 'app-store-layout',
@@ -42,31 +29,9 @@ export class StoreLayoutComponent implements OnInit {
private readonly tenantService = inject(TenantService); private readonly tenantService = inject(TenantService);
private readonly cartService = inject(CartService); private readonly cartService = inject(CartService);
private readonly authService = inject(AuthService); private readonly authService = inject(AuthService);
private readonly checkoutService = inject(CheckoutService);
private readonly checkoutCountdownService = inject(CheckoutCountdownService);
private readonly toastService = inject(ToastService);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
private readonly destroyRef = inject(DestroyRef);
protected readonly isCartOpen = signal(false); protected readonly isCartOpen = signal(false);
protected readonly isCheckoutRoute = signal(this.isCheckoutUrl(this.router.url));
protected readonly checkoutRemainingSeconds = this.checkoutCountdownService.remainingSeconds;
protected readonly isCreatingPurchase = signal(false);
protected readonly displayCart = computed(() => this.tenant()?.display_cart ?? true);
protected readonly cartEditingPolicy = computed(() => this.tenant()?.cart_editing_policy ?? null);
protected readonly canModifyCart = computed(
() => this.cartEditingPolicy()?.allow_modify ?? false,
);
protected readonly canDeleteCartItems = computed(
() => this.cartEditingPolicy()?.allow_delete ?? false,
);
protected readonly canUpdateCartQuantity = computed(
() => this.cartEditingPolicy()?.allow_update_quantity ?? false,
);
protected readonly canUpdateCartVariant = computed(
() => this.cartEditingPolicy()?.allow_update_variant ?? false,
);
protected readonly cartSubtotal = computed(() => { protected readonly cartSubtotal = computed(() => {
const cart = this.cartService.cart(); const cart = this.cartService.cart();
@@ -84,7 +49,7 @@ export class StoreLayoutComponent implements OnInit {
}); });
private mapCartItemToMock(item: CartItem): CartItemMock { private mapCartItemToMock(item: CartItem): CartItemMock {
const fullName = item.nombre ?? ''; const fullName = item.product?.nombre ?? '';
let product = fullName; let product = fullName;
let attributes: { label: string; value: string }[] = []; let attributes: { label: string; value: string }[] = [];
@@ -101,61 +66,22 @@ export class StoreLayoutComponent implements OnInit {
}); });
} }
const selectedVariant = item.variant;
if (selectedVariant) {
attributes = Object.entries(selectedVariant.values).map(([label, value]) => ({
label: this.formatAttributeLabel(label),
value: this.formatAttributeValue(value),
}));
}
return { return {
cartItemId: item.id, cartItemId: item.id,
imageUrl: item.imagen, imageUrl: item.product?.imagen ?? null,
product, product,
originalPrice: null, originalPrice: null,
discountedPrice: parseFloat(item.precio_unitario), discountedPrice: parseFloat(item.precio_unitario),
discountPercentage: null, discountPercentage: null,
attributes, attributes,
quantity: item.cantidad, quantity: item.cantidad,
variantId: item.variant_id,
variants: item.variants,
}; };
} }
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 currentYear = new Date().getFullYear();
protected readonly tenant = this.tenantService.tenant; protected readonly tenant = this.tenantService.tenant;
protected readonly user = this.authService.user; protected readonly user = this.authService.user;
protected readonly isAuthenticated = this.authService.isAuthenticated; protected readonly isAuthenticated = this.authService.isAuthenticated;
protected readonly ticketsMenu = computed(() =>
findMenu(this.tenant()?.menues ?? [], 'account.tickets'),
);
protected readonly accountNavigationMenus = computed(() => {
const accountMenu = findMenu(this.tenant()?.menues ?? [], 'account');
if (!accountMenu) {
return [];
}
const profile = accountMenu.submenues.find((menu) => menu.code === 'account.profile');
const purchases = accountMenu.submenues.find((menu) => menu.code === 'account.purchases');
return [
...(profile ? [{ ...accountMenu, route: profile.route }] : []),
...(purchases ? [purchases] : []),
];
});
protected readonly cartQuantity = computed(() => { protected readonly cartQuantity = computed(() => {
const cart = this.cartService.cart(); const cart = this.cartService.cart();
@@ -164,162 +90,54 @@ export class StoreLayoutComponent implements OnInit {
}); });
ngOnInit(): void { ngOnInit(): void {
this.router.events
.pipe(
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((event) => {
const isCheckoutRoute = this.isCheckoutUrl(event.urlAfterRedirects);
this.isCheckoutRoute.set(isCheckoutRoute);
if (isCheckoutRoute) {
this.isCartOpen.set(false);
}
});
this.route.queryParamMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => {
if (params.get('openCart') === 'true' && !this.isCheckoutRoute()) {
this.isCartOpen.set(true);
}
});
this.cartService.loadCart().subscribe({ this.cartService.loadCart().subscribe({
error: (err) => console.error('Error loading cart', err), error: (err) => console.error('Error loading cart', err),
}); });
} }
private isCheckoutUrl(url: string): boolean {
const primarySegments = this.router.parseUrl(url).root.children[PRIMARY_OUTLET]?.segments ?? [];
return primarySegments[0]?.path === 'checkout';
}
protected onLoginClick(): void { protected onLoginClick(): void {
void this.router.navigate(['/login']); void this.router.navigate(['/login']);
} }
protected onCartClick(): void { protected onLogoutClick(): void {
if (this.isCheckoutRoute()) {
return;
}
this.isCartOpen.update((isOpen) => !isOpen);
}
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');
// Leave checkout before closing the authenticated session so its component
// can stop payment polling cleanly. The checkout itself remains pending.
if (isLeavingCheckout) {
const navigationSucceeded = await this.router.navigate(['/']);
if (!navigationSucceeded) {
return;
}
}
this.authService.logout().subscribe({ this.authService.logout().subscribe({
next: () => { next: () => void this.router.navigate(['/login']),
this.cartService.clearCart();
this.isCartOpen.set(false);
if (!isLeavingCheckout) {
void this.router.navigate(['/']);
}
},
error: (err) => console.error('Error logging out', err), error: (err) => console.error('Error logging out', err),
}); });
} }
protected async onCheckoutClick(): Promise<void> { protected onCheckoutClick(): 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); this.isCartOpen.set(false);
void this.router.navigate(['/login'], { queryParams: { returnUrl: '/' } }); void this.router.navigate(['/checkout']);
return;
} }
if (!cart?.id || !tenant) { protected readonly footerSections: StoreFooterSection[] = [
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.isCartOpen.set(false);
await this.router.navigate(['/checkout', purchase.id]);
} catch (error) {
console.error('Failed to create cart purchase:', error);
const message =
error instanceof HttpErrorResponse && typeof error.error?.message === 'string'
? error.error.message
: 'No se pudo iniciar la compra.';
this.toastService.danger(message);
if (error instanceof HttpErrorResponse && isExpiredStockReservationResponse(error.error)) {
this.cartService.loadCart(true).subscribe({
error: (refreshError) => console.error('Error refreshing expired cart', refreshError),
});
}
} finally {
this.isCreatingPurchase.set(false);
}
}
protected readonly footerSections = computed<StoreFooterSection[]>(() => {
const helpSubmenues = findMenu(this.tenant()?.menues ?? [], 'help')?.submenues ?? [];
return [
{ {
heading: 'Cuenta', heading: 'Cuenta',
links: [ links: ['Mi cuenta', 'Mis compras', 'Cerrar sesion'],
...this.accountNavigationMenus().map((menu) => ({
label: menu.label,
routerLink: menu.route,
})),
{ label: 'Cerrar sesion', action: 'logout' },
],
}, },
{ {
heading: 'Ayuda', heading: 'Ayuda',
links: helpSubmenues.map((menu) => ({ links: ['Contacto', 'Ayuda', 'Preguntas frecuentes'],
label: menu.label, },
routerLink: menu.route, ];
})),
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() }] providers: [provideRouter([]), { provide: AuthService, useValue: createAuthServiceStub() }]
}); });
const result = TestBed.runInInjectionContext(() => const result = TestBed.runInInjectionContext(() => authGuard(null as never, null as never));
authGuard(null as never, { url: '/checkout/25' } as never),
);
expect(result instanceof UrlTree).toBe(true); expect(result instanceof UrlTree).toBe(true);
expect(TestBed.inject(Router).serializeUrl(result as UrlTree)).toBe( expect(TestBed.inject(Router).serializeUrl(result as UrlTree)).toBe('/login');
'/login?returnUrl=%2Fcheckout%2F25',
);
}); });
it('allows authenticated users through authGuard', () => { it('allows authenticated users through authGuard', () => {
@@ -38,9 +34,7 @@ describe('auth guards', () => {
providers: [provideRouter([]), { provide: AuthService, useValue: createAuthServiceStub(true) }] providers: [provideRouter([]), { provide: AuthService, useValue: createAuthServiceStub(true) }]
}); });
const result = TestBed.runInInjectionContext(() => const result = TestBed.runInInjectionContext(() => authGuard(null as never, null as never));
authGuard(null as never, { url: '/checkout/25' } as never),
);
expect(result).toBe(true); expect(result).toBe(true);
}); });

View File

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

View File

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

View File

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

View File

@@ -1,40 +1,30 @@
import { DOCUMENT } from '@angular/common'; import { HttpClient } from '@angular/common/http';
import { HttpErrorResponse, HttpResponse } from '@angular/common/http';
import { isPlatformBrowser, isPlatformServer } from '@angular/common'; import { isPlatformBrowser, isPlatformServer } from '@angular/common';
import { computed, inject, Injectable, PLATFORM_ID, signal, TransferState, makeStateKey } from '@angular/core'; import { computed, inject, Injectable, PLATFORM_ID, signal, TransferState, makeStateKey } from '@angular/core';
import { firstValueFrom, map, Observable, of, tap, timeout } from 'rxjs'; import { firstValueFrom, map, Observable, of, tap } from 'rxjs';
import { environment } from '../../../../environments/environment'; import { environment } from '../../../../environments/environment';
import { BaseApiService } from '../base-api.service';
import { import {
AuthUser, AuthUser,
LoginPayload, LoginPayload,
LoginResponse, LoginResponse,
RegisterPayload, RegisterPayload,
RegisterResponse, RegisterResponse,
ResetPasswordPayload, UpdateProfilePayload
ResetPasswordAttemptResponse,
ResetPasswordResponse,
UpdateProfilePayload,
ValidateResetPasswordAttemptResponse
} from './auth.interfaces'; } from './auth.interfaces';
import { CookieService } from '../cookie/cookie.service'; import { CookieService } from '../cookie/cookie.service';
import { TenantService } from '../tenant.service';
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'); const AUTH_USER_SSR_STATE_KEY = makeStateKey<AuthUser>('shopit.auth.user');
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
}) })
export class AuthService extends BaseApiService { export class AuthService {
private readonly document = inject(DOCUMENT); private readonly http = inject(HttpClient);
private readonly platformId = inject(PLATFORM_ID); private readonly platformId = inject(PLATFORM_ID);
private readonly cookieService = inject(CookieService); private readonly cookieService = inject(CookieService);
private readonly transferState = inject(TransferState); private readonly transferState = inject(TransferState);
private readonly tenantService = inject(TenantService);
private readonly bootstrapRequestTimeoutMs = inject(BOOTSTRAP_REQUEST_TIMEOUT_MS);
private readonly userState = signal<AuthUser | null>(null); private readonly userState = signal<AuthUser | null>(null);
private readonly tokenState = signal<string | null>(null); private readonly tokenState = signal<string | null>(null);
@@ -44,115 +34,20 @@ export class AuthService extends BaseApiService {
readonly isAuthenticated = computed(() => this.tokenState() !== null); readonly isAuthenticated = computed(() => this.tokenState() !== null);
login(payload: LoginPayload): Observable<AuthUser> { login(payload: LoginPayload): Observable<AuthUser> {
const tenant = this.tenantService.getTenant(); return this.http.post<LoginResponse>(`${environment.url}login`, payload).pipe(
if (!tenant) {
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)), tap((response) => this.applyAuthenticatedState(response.token, response.user)),
map((response) => response.user) map((response) => response.user)
); );
} }
register(payload: RegisterPayload): Observable<RegisterResponse> { register(payload: RegisterPayload): Observable<RegisterResponse> {
const tenantCode = this.tenantService.getTenant()?.codigo; return this.http.post<RegisterResponse>(`${environment.url}register`, payload);
return this.http.post<RegisterResponse>(
`${environment.url}register`,
{
...payload,
...(tenantCode ? { tenant_codigo: tenantCode } : {})
},
);
}
requestPasswordReset(
email: string
): Observable<HttpResponse<ResetPasswordAttemptResponse>> {
const tenantCode = this.tenantService.getTenant()?.codigo;
return this.http.post<ResetPasswordAttemptResponse>(
`${environment.url}password/reset-attempts`,
{
email,
...(tenantCode ? { tenant_codigo: tenantCode } : {})
},
{
observe: 'response'
}
);
}
validatePasswordResetCode(
email: string,
codigo: string
): Observable<HttpResponse<ValidateResetPasswordAttemptResponse>> {
return this.http.post<ValidateResetPasswordAttemptResponse>(
`${environment.url}password/reset-attempts/validate`,
{ email, codigo },
{
observe: 'response'
}
);
}
resetPassword(payload: ResetPasswordPayload): Observable<HttpResponse<ResetPasswordResponse>> {
return this.http.post<ResetPasswordResponse>(`${environment.url}password/reset`, payload, {
observe: 'response'
});
}
loginWithGoogle(): void {
const tenant = this.tenantService.getTenant();
if (!tenant) {
throw new Error('No se pudo resolver el tenant activo.');
}
const apiUrl = new URL(environment.url);
const authorizationUrl = new URL('/auth/google/redirect', apiUrl.origin);
const basePath = tenant.base_path && tenant.base_path !== '/' ? tenant.base_path : '';
authorizationUrl.searchParams.set('tenant', tenant.codigo);
authorizationUrl.searchParams.set(
'return_url',
`${this.document.location.origin}${basePath}`,
);
this.document.location.assign(authorizationUrl.toString());
}
completeGoogleLogin(oauthCode: string): Observable<AuthUser> {
const tenant = this.tenantService.getTenant();
if (!tenant) {
throw new Error('No se pudo resolver el tenant activo.');
}
return this.http
.post<LoginResponse>(
`${environment.url}auth/google/exchange`,
{ oauth_code: oauthCode, tenant_codigo: tenant.codigo },
{
withCredentials: true
}
)
.pipe(
tap((response) => this.applyAuthenticatedState(response.token, response.user)),
map((response) => response.user)
);
} }
updateProfile(payload: UpdateProfilePayload): Observable<AuthUser> { updateProfile(payload: UpdateProfilePayload): Observable<AuthUser> {
return this.http return this.http.put<AuthUser>(`${environment.url}me`, payload).pipe(
.put<AuthUser>(`${environment.url}me`, payload) tap((user) => this.userState.set(user))
.pipe(tap((user) => this.userState.set(user))); );
} }
logout(): Observable<void> { logout(): Observable<void> {
@@ -161,9 +56,9 @@ export class AuthService extends BaseApiService {
return of(void 0); return of(void 0);
} }
return this.http return this.http.post<void>(`${environment.url}logout`, {}).pipe(
.post<void>(`${environment.url}logout`, {}) tap(() => this.clearSession())
.pipe(tap(() => this.clearSession())); );
} }
async bootstrap(): Promise<void> { async bootstrap(): Promise<void> {
@@ -180,33 +75,16 @@ export class AuthService extends BaseApiService {
return; return;
} }
try { const user = await firstValueFrom(this.loadCurrentUser());
const user = await firstValueFrom(
this.loadCurrentUser().pipe(timeout(this.bootstrapRequestTimeoutMs)),
);
if (isPlatformServer(this.platformId)) { if (isPlatformServer(this.platformId)) {
this.transferState.set(AUTH_USER_SSR_STATE_KEY, user); this.transferState.set(AUTH_USER_SSR_STATE_KEY, user);
} }
} catch (error) {
// An expired token is an expected state, including while rendering on the
// server. Do not let it reject the application initializer: Angular's SSR
// pipeline can otherwise attempt to serialize HttpErrorResponse internals
// as HTTP headers.
if (error instanceof HttpErrorResponse && error.status === 401) {
this.clearSession();
return;
}
throw error;
}
} }
loadCurrentUser(): Observable<AuthUser> { loadCurrentUser(): Observable<AuthUser> {
return this.http return this.http.get<AuthUser>(`${environment.url}me`).pipe(
.get<AuthUser>(`${environment.url}me`) tap((user) => this.userState.set(user))
.pipe(tap((user) => this.userState.set(user))); );
} }
hydrateSession(): void { hydrateSession(): void {

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,18 +1,6 @@
export interface CartItemVariantOption { export interface CartItemProduct {
value: string; nombre: string;
label: string; imagen: string | null;
}
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 { export interface CartItem {
@@ -21,10 +9,7 @@ export interface CartItem {
precio_unitario: string; precio_unitario: string;
catalog_item_id: number; catalog_item_id: number;
variant_id: number | null; variant_id: number | null;
nombre: string | null; product: CartItemProduct | null;
imagen: string | null;
variant: CartItemVariant | null;
variants?: CartItemVariant[];
} }
export interface Cart { export interface Cart {

View File

@@ -6,14 +6,11 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { CartService } from './cart.service'; import { CartService } from './cart.service';
import { TenantService } from '../tenant.service'; import { TenantService } from '../tenant.service';
import { Cart } from './cart.interface'; import { Cart } from './cart.interface';
import { LOADING_MODE } from '../global-loading/loading-mode';
import { CatalogAvailabilityService } from '../catalog/catalog-availability.service';
describe('CartService', () => { describe('CartService', () => {
let service: CartService; let service: CartService;
let httpMock: HttpTestingController; let httpMock: HttpTestingController;
let tenantServiceMock: any; let tenantServiceMock: any;
let catalogAvailabilityService: CatalogAvailabilityService;
const mockCart: Cart = { const mockCart: Cart = {
id: 123, id: 123,
@@ -26,13 +23,9 @@ describe('CartService', () => {
precio_unitario: '10.00', precio_unitario: '10.00',
catalog_item_id: 5, catalog_item_id: 5,
variant_id: 10, variant_id: 10,
product: {
nombre: 'Test Product (Size: M)', nombre: 'Test Product (Size: M)',
imagen: null, imagen: null,
variant: {
id: 10,
precio: '10.00',
stock_tecnico: 1,
values: { size: 'M' },
}, },
}, },
], ],
@@ -55,7 +48,6 @@ describe('CartService', () => {
}); });
service = TestBed.inject(CartService); service = TestBed.inject(CartService);
catalogAvailabilityService = TestBed.inject(CatalogAvailabilityService);
httpMock = TestBed.inject(HttpTestingController); httpMock = TestBed.inject(HttpTestingController);
}); });
@@ -83,28 +75,6 @@ describe('CartService', () => {
req.flush({ data: mockCart }); req.flush({ data: mockCart });
}); });
it('refreshes catalog availability only after the expired cart has been reloaded', () => {
const availabilityChanged = vi.fn();
catalogAvailabilityService.availabilityChanged$.subscribe(availabilityChanged);
service.loadCart(true).subscribe();
expect(availabilityChanged).not.toHaveBeenCalled();
const req = httpMock.expectOne('http://api.test/tenants/acme/cart');
req.flush({ data: mockCart });
expect(service.cart()).toEqual(mockCart);
expect(availabilityChanged).toHaveBeenCalledOnce();
});
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', () => { it('should clear cart state locally', () => {
service.clearCart(); service.clearCart();
@@ -118,9 +88,6 @@ describe('CartService', () => {
}); });
it('should add item and update signal', () => { it('should add item and update signal', () => {
const availabilityChanged = vi.fn();
catalogAvailabilityService.availabilityChanged$.subscribe(availabilityChanged);
service.addItem(5, 10, 2).subscribe((res) => { service.addItem(5, 10, 2).subscribe((res) => {
expect(res.data).toEqual(mockCart); expect(res.data).toEqual(mockCart);
expect(service.cart()).toEqual(mockCart); expect(service.cart()).toEqual(mockCart);
@@ -135,13 +102,9 @@ describe('CartService', () => {
}); });
expect(req.request.withCredentials).toBe(true); expect(req.request.withCredentials).toBe(true);
req.flush({ data: mockCart }); req.flush({ data: mockCart });
expect(availabilityChanged).toHaveBeenCalledOnce();
}); });
it('should update item quantity and update signal', () => { it('should update item quantity and update signal', () => {
const availabilityChanged = vi.fn();
catalogAvailabilityService.availabilityChanged$.subscribe(availabilityChanged);
const updatedCart = { ...mockCart, subtotal: '30.00' }; const updatedCart = { ...mockCart, subtotal: '30.00' };
updatedCart.items[0].cantidad = 3; updatedCart.items[0].cantidad = 3;
@@ -155,13 +118,9 @@ describe('CartService', () => {
expect(req.request.body).toEqual({ cantidad: 3 }); expect(req.request.body).toEqual({ cantidad: 3 });
expect(req.request.withCredentials).toBe(true); expect(req.request.withCredentials).toBe(true);
req.flush({ data: updatedCart }); req.flush({ data: updatedCart });
expect(availabilityChanged).toHaveBeenCalledOnce();
}); });
it('should remove item and update signal', () => { it('should remove item and update signal', () => {
const availabilityChanged = vi.fn();
catalogAvailabilityService.availabilityChanged$.subscribe(availabilityChanged);
const emptyCart: Cart = { const emptyCart: Cart = {
id: 123, id: 123,
tenant_codigo: 'acme', tenant_codigo: 'acme',
@@ -179,18 +138,5 @@ describe('CartService', () => {
expect(req.request.method).toBe('DELETE'); expect(req.request.method).toBe('DELETE');
expect(req.request.withCredentials).toBe(true); expect(req.request.withCredentials).toBe(true);
req.flush({ data: emptyCart }); req.flush({ data: emptyCart });
expect(availabilityChanged).toHaveBeenCalledOnce();
});
it('does not notify an availability change when a cart mutation fails', () => {
const availabilityChanged = vi.fn();
catalogAvailabilityService.availabilityChanged$.subscribe(availabilityChanged);
service.removeItem(10).subscribe({ error: vi.fn() });
const req = httpMock.expectOne('http://api.test/tenants/acme/cart/items/10');
req.flush({ message: 'Error' }, { status: 500, statusText: 'Server Error' });
expect(availabilityChanged).not.toHaveBeenCalled();
}); });
}); });

View File

@@ -1,17 +1,16 @@
import { inject, Injectable, signal } from '@angular/core'; import { inject, Injectable, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { catchError, map, Observable, tap } from 'rxjs'; import { catchError, map, Observable, tap } from 'rxjs';
import { ApiResponse } from '../api-response.interface'; import { ApiResponse } from '../api-response.interface';
import { BaseApiService } from '../base-api.service';
import { CatalogAvailabilityService } from '../catalog/catalog-availability.service';
import { TenantService } from '../tenant.service'; import { TenantService } from '../tenant.service';
import { Cart } from './cart.interface'; import { Cart } from './cart.interface';
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
}) })
export class CartService extends BaseApiService { export class CartService {
private readonly catalogAvailabilityService = inject(CatalogAvailabilityService); private readonly http = inject(HttpClient);
private readonly tenantService = inject(TenantService); private readonly tenantService = inject(TenantService);
private readonly cartState = signal<Cart | null>(null); private readonly cartState = signal<Cart | null>(null);
@@ -34,19 +33,14 @@ export class CartService extends BaseApiService {
}); });
} }
loadCart(refreshCatalogAvailability = false): Observable<Cart> { loadCart(): Observable<Cart> {
return this.http return this.http
.get<ApiResponse<Cart>>(`${this.tenantApiUrl}/cart`, { .get<ApiResponse<Cart>>(`${this.tenantApiUrl}/cart`, {
withCredentials: true, withCredentials: true,
}) })
.pipe( .pipe(
map((response) => response.data), map((response) => response.data),
tap((cart) => { tap((cart) => this.cartState.set(cart)),
this.cartState.set(cart);
if (refreshCatalogAvailability) {
this.catalogAvailabilityService.notifyAvailabilityChanged();
}
}),
); );
} }
@@ -57,17 +51,12 @@ export class CartService extends BaseApiService {
): Observable<ApiResponse<Cart>> { ): Observable<ApiResponse<Cart>> {
this.isUpdatingState.set(true); this.isUpdatingState.set(true);
return this.http return this.http
.post<ApiResponse<Cart>>( .post<
`${this.tenantApiUrl}/cart/items`, ApiResponse<Cart>
{ catalog_item_id: catalogItemId, variant_id: variantId, cantidad }, >(`${this.tenantApiUrl}/cart/items`, { catalog_item_id: catalogItemId, variant_id: variantId, cantidad }, { withCredentials: true })
{
withCredentials: true,
},
)
.pipe( .pipe(
tap((response) => { tap((response) => {
this.cartState.set(response.data); this.cartState.set(response.data);
this.catalogAvailabilityService.notifyAvailabilityChanged();
this.isUpdatingState.set(false); this.isUpdatingState.set(false);
}), }),
catchError((error) => { catchError((error) => {
@@ -78,30 +67,14 @@ export class CartService extends BaseApiService {
} }
updateItemQuantity(cartItemId: number, cantidad: number): Observable<ApiResponse<Cart>> { 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>> {
this.isUpdatingState.set(true); this.isUpdatingState.set(true);
return this.http return this.http
.patch<ApiResponse<Cart>>(`${this.tenantApiUrl}/cart/items/${cartItemId}`, payload, { .patch<
withCredentials: true, ApiResponse<Cart>
}) >(`${this.tenantApiUrl}/cart/items/${cartItemId}`, { cantidad }, { withCredentials: true })
.pipe( .pipe(
tap((response) => { tap((response) => {
this.cartState.set(response.data); this.cartState.set(response.data);
this.catalogAvailabilityService.notifyAvailabilityChanged();
this.isUpdatingState.set(false); this.isUpdatingState.set(false);
}), }),
catchError((error) => { catchError((error) => {
@@ -114,13 +87,12 @@ export class CartService extends BaseApiService {
removeItem(cartItemId: number): Observable<ApiResponse<Cart>> { removeItem(cartItemId: number): Observable<ApiResponse<Cart>> {
this.isUpdatingState.set(true); this.isUpdatingState.set(true);
return this.http return this.http
.delete<ApiResponse<Cart>>(`${this.tenantApiUrl}/cart/items/${cartItemId}`, { .delete<
withCredentials: true, ApiResponse<Cart>
}) >(`${this.tenantApiUrl}/cart/items/${cartItemId}`, { withCredentials: true })
.pipe( .pipe(
tap((response) => { tap((response) => {
this.cartState.set(response.data); this.cartState.set(response.data);
this.catalogAvailabilityService.notifyAvailabilityChanged();
this.isUpdatingState.set(false); this.isUpdatingState.set(false);
}), }),
catchError((error) => { catchError((error) => {

View File

@@ -1,15 +0,0 @@
import { Injectable } from '@angular/core';
import { Observable, Subject } from 'rxjs';
@Injectable({
providedIn: 'root',
})
export class CatalogAvailabilityService {
private readonly availabilityChangedSubject = new Subject<void>();
readonly availabilityChanged$: Observable<void> = this.availabilityChangedSubject.asObservable();
notifyAvailabilityChanged(): void {
this.availabilityChangedSubject.next();
}
}

View File

@@ -1,10 +1,7 @@
import { ApiPaginatedResponse } from '../api-paginated-response.interface'; import { ApiPaginatedResponse } from '../api-paginated-response.interface';
export type CatalogItemType = 'product' | 'bundle';
export interface Product { export interface Product {
id: number; id: number;
type: CatalogItemType;
category_id: number; category_id: number;
brand_id: number | null; brand_id: number | null;
slug: string; slug: string;
@@ -18,61 +15,28 @@ export interface Product {
export interface ProductAttributeOption { export interface ProductAttributeOption {
id: number; id: number;
validity_time_id?: number | null;
value: string; value: string;
label: string; label: string;
sort_order: number; sort_order: number;
metadata: Record<string, unknown> | null; metadata: Record<string, unknown> | null;
} }
export type ProductAttributeType =
| 'string'
| 'number'
| 'boolean'
| 'select'
| 'multiselect'
| 'color'
| 'image'
| 'event_date';
export interface ProductAttribute { export interface ProductAttribute {
id: number; id: number;
codigo: string; codigo: string;
nombre: string; nombre: string;
is_required: boolean; is_required: boolean;
allow_multi_select?: boolean;
show_in_selector?: boolean;
metadata_schema: Record<string, unknown> | null; metadata_schema: Record<string, unknown> | null;
type: ProductAttributeType; type: string;
options: ProductAttributeOption[]; options: ProductAttributeOption[];
} }
export type InventoryPolicy = 'tracked' | 'unlimited'; 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 CatalogItemVariant {
id: number; id: number;
descripcion?: string | null; stock_tecnico: number | null;
precio?: string; values: Record<string, string>;
event_date_id?: number | null;
event_date_ids?: number[];
event_dates?: string[];
maximum_addable_quantity?: number | null;
unavailable_message?: string | 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 { export interface SelectedCatalogItemVariant extends CatalogItemVariant {
@@ -81,8 +45,6 @@ export interface SelectedCatalogItemVariant extends CatalogItemVariant {
export interface CatalogItemDetail { export interface CatalogItemDetail {
id: number; id: number;
type: CatalogItemType;
purpose?: 'product' | 'entry';
category_id: number | null; category_id: number | null;
brand_id: number | null; brand_id: number | null;
slug: string; slug: string;
@@ -92,88 +54,38 @@ export interface CatalogItemDetail {
category: string | null; category: string | null;
brand: string | null; brand: string | null;
inventory_policy: InventoryPolicy; inventory_policy: InventoryPolicy;
max_units_per_user?: number | null;
has_tickets: boolean; has_tickets: boolean;
minimum_use_date: string | null; minimum_use_date: string | null;
maximum_use_date: string | null; maximum_use_date: string | null;
attributes: ProductAttribute[]; attributes: ProductAttribute[];
variants: CatalogItemVariant[]; variants: CatalogItemVariant[];
selected_variant?: SelectedCatalogItemVariant; selected_variant?: SelectedCatalogItemVariant;
maximum_addable_quantity?: number | null; stock_tecnico?: number | null;
images?: string[]; images?: string[];
} }
export type CatalogProductLayout = export type CatalogProductLayout = 'row' | 'column_with_image' | 'column_with_cart';
| 'row'
| 'column_with_image'
| 'column_with_cart'
| 'ticket_selector';
export type CatalogGroupLayout = 'paginated' | 'simple' | 'simple_vertical' | 'carousel' | 'single';
export interface CatalogFeaturedItemVariant { export interface CatalogFeaturedItemVariant {
id: number; id: number;
descripcion?: string | null; stock_tecnico: number | null;
precio?: string; values: Record<string, string>;
maximum_addable_quantity?: number | null;
unavailable_message?: string | null;
values: Record<string, CatalogVariantValue>;
}
export interface CatalogVariantOptionsResponse {
variants: CatalogFeaturedItemVariant[];
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 { export interface CatalogFeaturedItem {
id: number; id: number;
type: CatalogItemType;
nombre: string; nombre: string;
descripcion?: string | null; descripcion?: string | null;
precio: number | string; precio: number | string;
image?: string | null; image?: string | null;
maximum_addable_quantity?: number | null; stock_tecnico?: number | null;
unavailable_message?: string | null;
variants?: CatalogFeaturedItemVariant[]; variants?: CatalogFeaturedItemVariant[];
} }
export type CatalogFeaturedItems =
| ApiPaginatedResponse<CatalogFeaturedItem[]>
| CatalogFeaturedItem[];
export interface CatalogFeaturedGroup { export interface CatalogFeaturedGroup {
id: number; id: number;
code: string;
title: string; title: string;
layout: CatalogProductLayout; layout: CatalogProductLayout;
group_layout: CatalogGroupLayout;
group_order: number; group_order: number;
items: CatalogFeaturedItems; items: ApiPaginatedResponse<CatalogFeaturedItem[]>;
}
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,70 +1,48 @@
import { HttpParams } from '@angular/common/http';
import { inject, Injectable } from '@angular/core'; import { inject, Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { map, Observable } from 'rxjs'; import { map, Observable } from 'rxjs';
import { ApiPaginationQueryParams } from '../api-pagination-query-params.interface'; import { ApiPaginationQueryParams } from '../api-pagination-query-params.interface';
import { ApiPaginatedResponse } from '../api-paginated-response.interface'; import { ApiPaginatedResponse } from '../api-paginated-response.interface';
import { ApiResponse } from '../api-response.interface'; import { ApiResponse } from '../api-response.interface';
import { BaseApiService } from '../base-api.service';
import { TenantService } from '../tenant.service'; import { TenantService } from '../tenant.service';
import { import {
CatalogFeaturedGroup, CatalogFeaturedGroup,
CatalogFeaturedItem, CatalogFeaturedItem,
CatalogFeaturedItems,
CatalogItemDetail, CatalogItemDetail,
CatalogVariantOptionsResponse, Product,
CategoryItemsResponse,
} from './catalog.interface'; } from './catalog.interface';
type HttpParamValue = string | number | boolean | readonly (string | number | boolean)[]; type HttpParamValue = string | number | boolean | readonly (string | number | boolean)[];
export interface CatalogSearchQueryParams extends ApiPaginationQueryParams {
q: string;
}
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
}) })
export class CatalogService extends BaseApiService { export class CatalogService {
private readonly http = inject(HttpClient);
private readonly tenantService = inject(TenantService); private readonly tenantService = inject(TenantService);
private get tenantApiUrl(): string { private get tenantApiUrl(): string {
return this.tenantService.getTenantApiUrl(); return this.tenantService.getTenantApiUrl();
} }
getCatalog(): Observable<CatalogFeaturedGroup[]> { getProductos(params?: ApiPaginationQueryParams): Observable<ApiPaginatedResponse<Product[]>> {
return this.http.get<CatalogFeaturedGroup[]>(`${this.tenantApiUrl}/catalog`); return this.http.get<ApiPaginatedResponse<Product[]>>(`${this.tenantApiUrl}/productos`, {
}
searchCatalog(
params: CatalogSearchQueryParams,
): Observable<ApiPaginatedResponse<CatalogFeaturedItem[]>> {
return this.http.get<ApiPaginatedResponse<CatalogFeaturedItem[]>>(
`${this.tenantApiUrl}/catalog-items`,
{
params: this.buildHttpParams(params),
},
);
}
getCategoryItems(
categoryId: number,
params?: ApiPaginationQueryParams,
): Observable<CategoryItemsResponse> {
return this.http.get<CategoryItemsResponse>(`${this.tenantApiUrl}/categories/${categoryId}`, {
params: this.buildHttpParams(params), params: this.buildHttpParams(params),
}); });
} }
getCatalog(): Observable<CatalogFeaturedGroup[]> {
return this.http.get<CatalogFeaturedGroup[]>(`${this.tenantApiUrl}/catalog`);
}
getFeaturedGroupItems( getFeaturedGroupItems(
featuredGroupId: number, featuredGroupId: number,
params?: ApiPaginationQueryParams, params?: ApiPaginationQueryParams,
): Observable<CatalogFeaturedItems> { ): Observable<ApiPaginatedResponse<CatalogFeaturedItem[]>> {
return this.http.get<CatalogFeaturedItems>( return this.http.get<ApiPaginatedResponse<CatalogFeaturedItem[]>>(
`${this.tenantApiUrl}/catalog/featured-groups/${featuredGroupId}/items`, `${this.tenantApiUrl}/catalog/featured-groups/${featuredGroupId}/items`,
{ { params: this.buildHttpParams(params) },
params: this.buildHttpParams(params),
},
); );
} }
@@ -75,23 +53,7 @@ export class CatalogService extends BaseApiService {
} }
return this.http return this.http
.get<ApiResponse<CatalogItemDetail>>(`${this.tenantApiUrl}/catalog-items/${id}`, { .get<ApiResponse<CatalogItemDetail>>(`${this.tenantApiUrl}/catalog-items/${id}`, { params })
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 })
.pipe(map((response) => response.data)); .pipe(map((response) => response.data));
} }

View File

@@ -1,69 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { CheckoutCountdownService } from './checkout-countdown.service';
describe('CheckoutCountdownService', () => {
let service: CheckoutCountdownService;
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-08-27T12:00:00.000Z'));
TestBed.configureTestingModule({ providers: [CheckoutCountdownService] });
service = TestBed.inject(CheckoutCountdownService);
});
afterEach(() => {
service.clear();
vi.useRealTimers();
});
it('counts down from the checkout server timing without depending on the client clock', () => {
service.synchronize({
expires_at: '2026-08-27T15:10:00.000Z',
expires_in_seconds: 600,
server_time: '2026-08-27T15:00:00.000Z',
});
expect(service.remainingSeconds()).toBe(600);
vi.advanceTimersByTime(1_000);
expect(service.remainingSeconds()).toBe(599);
});
it('recalculates against the deadline after a delayed browser interval', () => {
service.synchronize({
expires_at: null,
expires_in_seconds: 10,
server_time: '2026-08-27T15:00:00.000Z',
});
vi.setSystemTime(new Date('2026-08-27T12:00:07.000Z'));
vi.advanceTimersByTime(1_000);
expect(service.remainingSeconds()).toBe(2);
});
it('clears the countdown when checkout has no expiration', () => {
service.synchronize({
expires_at: null,
expires_in_seconds: null,
server_time: '2026-08-27T15:00:00.000Z',
});
expect(service.remainingSeconds()).toBeNull();
});
it('keeps the active countdown when a partial checkout response omits timing fields', () => {
service.synchronize({
expires_at: null,
expires_in_seconds: 10,
server_time: '2026-08-27T15:00:00.000Z',
});
service.synchronize({});
expect(service.remainingSeconds()).toBe(10);
});
});

View File

@@ -1,97 +0,0 @@
import { Injectable, OnDestroy, signal } from '@angular/core';
export interface CheckoutTiming {
expires_at: string | null;
expires_in_seconds: number | null;
server_time: string;
}
@Injectable({
providedIn: 'root',
})
export class CheckoutCountdownService implements OnDestroy {
private readonly remainingSecondsState = signal<number | null>(null);
private deadlineMs: number | null = null;
private intervalId: ReturnType<typeof setInterval> | null = null;
readonly remainingSeconds = this.remainingSecondsState.asReadonly();
synchronize(timing: Partial<CheckoutTiming>): void {
const remainingSeconds = this.resolveRemainingSeconds(timing);
if (remainingSeconds === undefined) {
return;
}
if (remainingSeconds === null) {
this.clear();
return;
}
this.stopInterval();
this.deadlineMs = Date.now() + remainingSeconds * 1_000;
this.updateRemainingSeconds();
if (remainingSeconds > 0) {
this.intervalId = setInterval(() => this.updateRemainingSeconds(), 1_000);
}
}
clear(): void {
this.stopInterval();
this.deadlineMs = null;
this.remainingSecondsState.set(null);
}
ngOnDestroy(): void {
this.clear();
}
private resolveRemainingSeconds(timing: Partial<CheckoutTiming>): number | null | undefined {
if (timing.expires_at === null && timing.expires_in_seconds === null) {
return null;
}
const expiresAt =
typeof timing.expires_at === 'string' ? Date.parse(timing.expires_at) : Number.NaN;
const serverTime =
typeof timing.server_time === 'string' ? Date.parse(timing.server_time) : Number.NaN;
if (Number.isFinite(expiresAt) && Number.isFinite(serverTime)) {
return Math.max(0, Math.ceil((expiresAt - serverTime) / 1_000));
}
if (
typeof timing.expires_in_seconds === 'number' &&
Number.isFinite(timing.expires_in_seconds)
) {
return Math.max(0, Math.ceil(timing.expires_in_seconds));
}
if (Number.isFinite(expiresAt)) {
return Math.max(0, Math.ceil((expiresAt - Date.now()) / 1_000));
}
return undefined;
}
private updateRemainingSeconds(): void {
if (this.deadlineMs === null) {
return;
}
const remainingSeconds = Math.max(0, Math.ceil((this.deadlineMs - Date.now()) / 1_000));
this.remainingSecondsState.set(remainingSeconds);
if (remainingSeconds === 0) {
this.stopInterval();
}
}
private stopInterval(): void {
if (this.intervalId !== null) {
clearInterval(this.intervalId);
this.intervalId = null;
}
}
}

View File

@@ -1,97 +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, vi } from 'vitest';
import { environment } from '../../../environments/environment';
import { CatalogAvailabilityService } from './catalog/catalog-availability.service';
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 });
});
it('refreshes catalog availability when starting checkout fails', async () => {
const catalogAvailabilityService = TestBed.inject(CatalogAvailabilityService);
const notifyAvailabilityChanged = vi.spyOn(
catalogAvailabilityService,
'notifyAvailabilityChanged',
);
const purchasePromise = service.startCheckout('desfile', { cart_id: 12 });
const request = httpMock.expectOne(`${environment.url}tenants/desfile/compras/start-checkout`);
request.flush(
{ message: 'No hay stock disponible.' },
{ status: 422, statusText: 'Unprocessable Entity' },
);
await expect(purchasePromise).rejects.toBeTruthy();
expect(notifyAvailabilityChanged).toHaveBeenCalledOnce();
});
it('waits for the expired cart refresh before refreshing catalog availability', async () => {
const catalogAvailabilityService = TestBed.inject(CatalogAvailabilityService);
const notifyAvailabilityChanged = vi.spyOn(
catalogAvailabilityService,
'notifyAvailabilityChanged',
);
const purchasePromise = service.startCheckout('desfile', { cart_id: 12 });
const request = httpMock.expectOne(`${environment.url}tenants/desfile/compras/start-checkout`);
request.flush(
{
code: 'stock_reservation.expired',
message: 'La reserva de stock venció.',
},
{ status: 422, statusText: 'Unprocessable Entity' },
);
await expect(purchasePromise).rejects.toBeTruthy();
expect(notifyAvailabilityChanged).not.toHaveBeenCalled();
});
it('preserves checkout timing fields when completing a purchase', async () => {
const purchasePromise = service.completePurchase('desfile', 55);
const request = httpMock.expectOne(`${environment.url}tenants/desfile/compras/55/complete`);
const response = {
status: 'pending_payment',
expires_at: '2026-08-26T20:00:00.000Z',
expires_in_seconds: 900,
server_time: '2026-08-26T19:45:00.000Z',
};
expect(request.request.method).toBe('POST');
request.flush({ data: response });
await expect(purchasePromise).resolves.toEqual(response);
});
});

View File

@@ -1,79 +1,19 @@
import { inject, Injectable } from '@angular/core'; import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
import { ApiPaginatedResponse } from './api-paginated-response.interface';
import { ApiPaginationQueryParams } from './api-pagination-query-params.interface';
import { BaseApiService } from './base-api.service';
import { CatalogAvailabilityService } from './catalog/catalog-availability.service';
export interface UpdatePurchaseCustomerPayload { export interface CreatePurchasePayload {
cart_id: number;
dni: string; dni: string;
telefono: string; telefono: string;
nombre_apellido: string; nombre_apellido: string;
email: string; email: string;
} }
export 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 interface ExpiredStockReservationResponse {
code: 'stock_reservation.expired';
message: string;
}
export function isExpiredStockReservationResponse(
value: unknown,
): value is ExpiredStockReservationResponse {
return (
typeof value === 'object' &&
value !== null &&
(value as Partial<ExpiredStockReservationResponse>).code === 'stock_reservation.expired'
);
}
export type StartCheckoutPayload =
| {
cart_id: number;
}
| {
direct_items: DirectCheckoutItem[];
};
export interface PurchaseStatusResponse { export interface PurchaseStatusResponse {
status: string | null; status: string | null;
expires_at: string | null;
expires_in_seconds: number | null;
server_time: string;
} }
export interface PurchaseSummaryResponse extends PurchaseStatusResponse { export interface PurchaseSummaryResponse extends PurchaseStatusResponse {
@@ -117,61 +57,37 @@ export interface PurchaseDetailResponse extends PurchaseStatusResponse {
email: string | null; email: string | null;
items_source: 'purchase'; items_source: 'purchase';
items: PurchaseDetailItemResponse[]; items: PurchaseDetailItemResponse[];
tickets_count?: number;
has_generated_tickets?: boolean;
subtotal: string; subtotal: string;
total: string; total: string;
} }
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root'
}) })
export class CheckoutService extends BaseApiService { export class CheckoutService {
private readonly catalogAvailabilityService = inject(CatalogAvailabilityService); private readonly http = inject(HttpClient);
async startCheckout( async createPurchase(tenantCode: string, payload: CreatePurchasePayload): Promise<{ id: number }> {
tenantCode: string,
payload: StartCheckoutPayload,
): Promise<PurchaseDetailResponse> {
try {
const response = await firstValueFrom( const response = await firstValueFrom(
this.http.post<{ data?: PurchaseDetailResponse } | PurchaseDetailResponse>( this.http.post<{ data?: { id: number }; id?: number }>(`${environment.url}tenants/${tenantCode}/compras`, payload)
`${environment.url}tenants/${tenantCode}/compras/start-checkout`,
payload,
),
); );
const purchase = this.extractResponseData<PurchaseDetailResponse>(response); const purchase = this.extractResponseData<{ id: number }>(response);
if (!purchase?.id) { if (!purchase?.id) {
throw new Error('Error al crear la compra.'); throw new Error('Error al crear la compra.');
} }
return purchase; return purchase;
} catch (error) {
const responseBody = (error as { error?: unknown } | null)?.error;
if (!isExpiredStockReservationResponse(responseBody)) {
this.catalogAvailabilityService.notifyAvailabilityChanged();
}
throw error;
}
} }
async generatePaymentIntent( async generatePaymentIntent(tenantCode: string, purchaseId: number, method: 'qr' | 'transfer' | 'telepagos', payerDni?: string): Promise<any> {
tenantCode: string,
purchaseId: number,
method: 'qr' | 'transfer',
payerDni?: string,
): Promise<any> {
const payload: any = { method }; const payload: any = { method };
if (payerDni) { if (payerDni) {
payload.transfer_payer_dni = payerDni; payload.transfer_payer_dni = payerDni;
} }
const response = await firstValueFrom( const response = await firstValueFrom(
this.http.post<any>( this.http.post<any>(`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/payment-intent`, { method })
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/payment-intent`,
payload,
),
); );
if (!response) { if (!response) {
throw new Error('Error al generar la intención de pago.'); throw new Error('Error al generar la intención de pago.');
@@ -179,77 +95,29 @@ export class CheckoutService extends BaseApiService {
return response; return response;
} }
async updateCustomerData( async completePurchase(tenantCode: string, purchaseId: number): Promise<PurchaseStatusResponse> {
tenantCode: string,
purchaseId: number,
payload: UpdatePurchaseCustomerPayload,
): Promise<PurchaseDetailResponse> {
const response = await firstValueFrom( const response = await firstValueFrom(
this.http.patch<{ data?: PurchaseDetailResponse } | PurchaseDetailResponse>( this.http.post<{ data?: PurchaseStatusResponse } | PurchaseStatusResponse>(`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/complete`, {})
`${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 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); const purchase = this.extractResponseData<PurchaseStatusResponse>(response);
if (!purchase) { if (!purchase) {
throw new Error('Error al enviar la compra a revisi\u00f3n.'); throw new Error('Error al finalizar la compra.');
} }
return purchase; return {
status: purchase.status ?? null
};
} }
async cancelPurchase(tenantCode: string, purchaseId: number): Promise<PurchaseStatusResponse> { async getPurchases(tenantCode: string, status?: string): Promise<{ data: PurchaseSummaryResponse[] }> {
let url = `${environment.url}tenants/${tenantCode}/compras`;
if (status) {
url += `?status=${status}`;
}
const response = await firstValueFrom( const response = await firstValueFrom(
this.http.post<{ data?: PurchaseStatusResponse } | PurchaseStatusResponse>( this.http.get<{ data: PurchaseSummaryResponse[] }>(url)
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/cancel`,
{},
),
);
const purchase = this.extractResponseData<PurchaseStatusResponse>(response);
if (!purchase) {
throw new Error('Error al cancelar la compra.');
}
return purchase;
}
async getPurchases(
tenantCode: string,
status?: string,
pagination: ApiPaginationQueryParams = {},
): Promise<ApiPaginatedResponse<PurchaseSummaryResponse[]>> {
const params: Record<string, string | number> = {};
if (status) params['status'] = status;
if (pagination.page) params['page'] = pagination.page;
if (pagination.per_page) params['per_page'] = pagination.per_page;
const response = await firstValueFrom(
this.http.get<ApiPaginatedResponse<PurchaseSummaryResponse[]>>(
`${environment.url}tenants/${tenantCode}/compras`,
{ params },
),
); );
if (!response) { if (!response) {
throw new Error('Error al obtener las compras.'); throw new Error('Error al obtener las compras.');
@@ -257,14 +125,9 @@ export class CheckoutService extends BaseApiService {
return response; return response;
} }
async getPurchase( async getPurchase(tenantCode: string, purchaseId: string | number): Promise<PurchaseDetailResponse> {
tenantCode: string,
purchaseId: string | number,
): Promise<PurchaseDetailResponse> {
const response = await firstValueFrom( const response = await firstValueFrom(
this.http.get<{ data?: PurchaseDetailResponse } | PurchaseDetailResponse>( this.http.get<{ data?: PurchaseDetailResponse } | PurchaseDetailResponse>(`${environment.url}tenants/${tenantCode}/compras/${purchaseId}`)
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}`,
),
); );
const purchase = this.extractResponseData<PurchaseDetailResponse>(response); const purchase = this.extractResponseData<PurchaseDetailResponse>(response);

View File

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

View File

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

View File

@@ -1,50 +0,0 @@
import '@angular/compiler';
import { DefaultUrlSerializer } from '@angular/router';
import { describe, expect, it } from 'vitest';
import { Tenant } from './tenant.interface';
import { TenantService } from './tenant.service';
import { TenantUrlSerializer } from './tenant-url.serializer';
describe('TenantUrlSerializer', () => {
const defaultSerializer = new DefaultUrlSerializer();
function createSerializer(basePath: string): TenantUrlSerializer {
const tenantService = {
getTenant: () => ({ base_path: basePath }) as Tenant,
} as TenantService;
return new TenantUrlSerializer(tenantService);
}
it('keeps root tenants unchanged', () => {
const serializer = createSerializer('/');
const tree = serializer.parse('/producto/123?ref=home');
expect(defaultSerializer.serialize(tree)).toBe('/producto/123?ref=home');
expect(serializer.serialize(tree)).toBe('/producto/123?ref=home');
});
it('removes the tenant base path when parsing and restores it when serializing', () => {
const serializer = createSerializer('/desfile');
const tree = serializer.parse('/desfile/producto/123?ref=home#detalle');
expect(defaultSerializer.serialize(tree)).toBe('/producto/123?ref=home#detalle');
expect(serializer.serialize(tree)).toBe('/desfile/producto/123?ref=home#detalle');
});
it('maps the tenant base path to the application root', () => {
const serializer = createSerializer('/desfile/');
const tree = serializer.parse('/desfile');
expect(defaultSerializer.serialize(tree)).toBe('/');
expect(serializer.serialize(tree)).toBe('/desfile');
});
it('does not strip partial path segment matches', () => {
const serializer = createSerializer('/desfile');
const tree = serializer.parse('/desfile-shop/producto/123');
expect(defaultSerializer.serialize(tree)).toBe('/desfile-shop/producto/123');
});
});

View File

@@ -1,66 +0,0 @@
import { Injectable } from '@angular/core';
import { DefaultUrlSerializer, UrlSerializer, UrlTree } from '@angular/router';
import { TenantService } from './tenant.service';
@Injectable()
export class TenantUrlSerializer extends UrlSerializer {
private readonly defaultSerializer = new DefaultUrlSerializer();
constructor(private readonly tenantService: TenantService) {
super();
}
override parse(url: string): UrlTree {
return this.defaultSerializer.parse(this.removeBasePath(url));
}
override serialize(tree: UrlTree): string {
const url = this.defaultSerializer.serialize(tree);
const basePath = this.basePath();
if (basePath === '/') {
return url;
}
return url === '/' ? basePath : `${basePath}${url}`;
}
private removeBasePath(url: string): string {
const basePath = this.basePath();
if (basePath === '/' || !this.startsWithCompletePathSegment(url, basePath)) {
return url;
}
const remainder = url.slice(basePath.length);
if (remainder === '') {
return '/';
}
return remainder.startsWith('?') || remainder.startsWith('#')
? `/${remainder}`
: remainder;
}
private basePath(): string {
const configuredPath = this.tenantService.getTenant()?.base_path?.trim() ?? '/';
if (configuredPath === '' || configuredPath === '/') {
return '/';
}
return `/${configuredPath.replace(/^\/+|\/+$/g, '')}`;
}
private startsWithCompletePathSegment(url: string, basePath: string): boolean {
if (!url.startsWith(basePath)) {
return false;
}
const boundary = url.charAt(basePath.length);
return boundary === '' || boundary === '/' || boundary === '?' || boundary === '#';
}
}

View File

@@ -1,15 +1,5 @@
import { ApiResponse } from './api-response.interface'; import { ApiResponse } from './api-response.interface';
export type CartEditingPolicyCode = 'disabled' | 'quantity_and_remove' | 'full';
export interface CartEditingPolicy {
code: CartEditingPolicyCode;
allow_modify: boolean;
allow_delete: boolean;
allow_update_quantity: boolean;
allow_update_variant: boolean;
}
export interface BankAccount { export interface BankAccount {
id: number; id: number;
tenant_code: string; tenant_code: string;
@@ -19,91 +9,24 @@ export interface BankAccount {
cvu: string; 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 { export interface HeroConfig {
background_image_id?: string | ResponsiveImage | null; background_image?: string | null;
title_html?: string | null; title_html?: string | null;
description_html?: string | null; description_html?: string | null;
button_text?: string | null; button_text?: string | null;
button_href?: string | null; button_href?: string | null;
} }
export interface ResponsiveImage {
desktop: string;
mobile: string;
}
export interface AdditionalInfoConfig {
description?: string | null;
}
export interface EventDate {
id?: number;
date: string;
start_time: string;
end_time: string;
}
export interface EventConfig { export interface EventConfig {
id?: number;
title?: string | null; title?: string | null;
location?: string | null; location?: string | null;
dates_text?: string | null; dates?: 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 { export interface Menu {
id: number; id: number;
code: string; code: string;
label: string;
parent_menu_code: string | null;
content_type: 'static' | 'dynamic';
route: string; route: string;
static_content?: unknown;
submenues: Menu[];
}
export interface SocialMedia {
code: string;
icon: string;
name: string;
url: string;
}
export interface Category {
id: number;
nombre: string;
subcategories: Category[];
} }
export interface Tenant { export interface Tenant {
@@ -111,11 +34,6 @@ export interface Tenant {
codigo: string; codigo: string;
nombre: string; nombre: string;
dominio: string; dominio: string;
base_path?: string;
site_title?: string | null;
address?: string | null;
phone?: string | null;
favicon?: string | null;
primary_color: string; primary_color: string;
secondary_color: string; secondary_color: string;
danger_color: string; danger_color: string;
@@ -124,27 +42,12 @@ export interface Tenant {
footer_bg_color: string; footer_bg_color: string;
header_logo: string; header_logo: string;
footer_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_id?: number | null;
selected_bank_account?: BankAccount | null; selected_bank_account?: BankAccount | null;
search_product_layout?: 'row' | 'column_with_image' | 'column_with_cart' | 'ticket_selector'; hero_config?: HeroConfig | null;
search_group_layout?: 'paginated' | 'simple' | 'simple_vertical' | 'carousel' | 'single'; event_config?: EventConfig | null;
search_items_per_page?: number;
display_categories?: boolean;
display_seach_bar?: boolean;
display_cart?: boolean;
cart_editing_policy?: CartEditingPolicy;
checkout_editing_policy?: CartEditingPolicy;
display_cart_item_images?: boolean;
social_media?: SocialMedia[];
menues?: Menu[]; menues?: Menu[];
categories: Category[];
} }
export type TenantBootstrapResponse = ApiResponse<Tenant>; 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 { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http'; import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TimeoutError } from 'rxjs';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
import { BOOTSTRAP_REQUEST_TIMEOUT_MS } from './bootstrap-request-timeout';
import { Tenant, TenantBootstrapResponse } from './tenant.interface'; import { Tenant, TenantBootstrapResponse } from './tenant.interface';
import { TENANT_SSR_STATE_KEY } from './tenant-ssr-cache.store'; import { TENANT_SSR_STATE_KEY } from './tenant-ssr-cache.store';
import { TenantService } from './tenant.service'; import { TenantService } from './tenant.service';
@@ -22,24 +20,11 @@ const tenant: Tenant = {
header_bg_color: '#313131', header_bg_color: '#313131',
footer_bg_color: '#313131', footer_bg_color: '#313131',
header_logo: 'https://example.com/header.png', header_logo: 'https://example.com/header.png',
footer_logo: 'https://example.com/footer.png', footer_logo: 'https://example.com/footer.png'
categories: [
{
id: 1,
nombre: 'Remeras',
subcategories: [
{
id: 2,
nombre: 'Manga corta',
subcategories: [],
},
],
},
],
}; };
const tenantResponse: TenantBootstrapResponse = { const tenantResponse: TenantBootstrapResponse = {
data: tenant, data: tenant
}; };
describe('TenantService', () => { describe('TenantService', () => {
@@ -53,16 +38,14 @@ describe('TenantService', () => {
it('requests the tenant bootstrap endpoint using the current hostname and stores the response', async () => { it('requests the tenant bootstrap endpoint using the current hostname and stores the response', async () => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting(), TenantService], providers: [provideHttpClient(), provideHttpClientTesting(), TenantService]
}); });
const service = TestBed.inject(TenantService); const service = TestBed.inject(TenantService);
const httpController = TestBed.inject(HttpTestingController); const httpController = TestBed.inject(HttpTestingController);
const bootstrapPromise = service.bootstrap(); const bootstrapPromise = service.bootstrap();
const request = httpController.expectOne( const request = httpController.expectOne(`${environment.url}tenants/bootstrap/localhost`);
`${environment.url}tenants/bootstrap?dominio=localhost&path=/`,
);
expect(request.request.method).toBe('GET'); 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 () => { it('hydrates the tenant from TransferState without performing HTTP on the browser', async () => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting(), TenantService], providers: [provideHttpClient(), provideHttpClientTesting(), TenantService]
}); });
const service = TestBed.inject(TenantService); const service = TestBed.inject(TenantService);
@@ -87,7 +70,7 @@ describe('TenantService', () => {
transferState.set(TENANT_SSR_STATE_KEY, { transferState.set(TENANT_SSR_STATE_KEY, {
status: 'ready', status: 'ready',
tenant, tenant
}); });
await expect(service.bootstrap()).resolves.toBeUndefined(); await expect(service.bootstrap()).resolves.toBeUndefined();
@@ -113,12 +96,12 @@ describe('TenantService', () => {
useValue: new Request('https://internal.example/render', { useValue: new Request('https://internal.example/render', {
headers: { headers: {
'x-forwarded-host': 'STORE.EXAMPLE.COM:443, proxy.internal', '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); const service = TestBed.inject(TenantService);
@@ -126,9 +109,7 @@ describe('TenantService', () => {
const httpController = TestBed.inject(HttpTestingController); const httpController = TestBed.inject(HttpTestingController);
const bootstrapPromise = service.bootstrap(); const bootstrapPromise = service.bootstrap();
const request = httpController.expectOne( const request = httpController.expectOne(`${environment.url}tenants/bootstrap/store.example.com`);
`${environment.url}tenants/bootstrap?dominio=store.example.com&path=/render`,
);
request.flush(tenantResponse); request.flush(tenantResponse);
@@ -137,7 +118,7 @@ describe('TenantService', () => {
expect(service.tenant()).toEqual(tenant); expect(service.tenant()).toEqual(tenant);
expect(transferState.get(TENANT_SSR_STATE_KEY, null)).toEqual({ expect(transferState.get(TENANT_SSR_STATE_KEY, null)).toEqual({
status: 'ready', status: 'ready',
tenant, tenant
}); });
expect(responseInit.status).toBeUndefined(); expect(responseInit.status).toBeUndefined();
@@ -157,12 +138,12 @@ describe('TenantService', () => {
provide: REQUEST, provide: REQUEST,
useValue: new Request('https://internal.example/render', { useValue: new Request('https://internal.example/render', {
headers: { 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); const service = TestBed.inject(TenantService);
@@ -170,9 +151,7 @@ describe('TenantService', () => {
const httpController = TestBed.inject(HttpTestingController); const httpController = TestBed.inject(HttpTestingController);
const bootstrapPromise = service.bootstrap(); const bootstrapPromise = service.bootstrap();
const request = httpController.expectOne( const request = httpController.expectOne(`${environment.url}tenants/bootstrap/missing.example.com`);
`${environment.url}tenants/bootstrap?dominio=missing.example.com&path=/render`,
);
request.flush({ message: 'Not Found' }, { status: 404, statusText: 'Not Found' }); request.flush({ message: 'Not Found' }, { status: 404, statusText: 'Not Found' });
@@ -181,7 +160,7 @@ describe('TenantService', () => {
expect(service.tenant()).toBeNull(); expect(service.tenant()).toBeNull();
expect(service.getTenant()).toBeNull(); expect(service.getTenant()).toBeNull();
expect(transferState.get(TENANT_SSR_STATE_KEY, null)).toEqual({ expect(transferState.get(TENANT_SSR_STATE_KEY, null)).toEqual({
status: 'not-found', status: 'not-found'
}); });
expect(responseInit.status).toBe(404); expect(responseInit.status).toBe(404);
@@ -190,16 +169,14 @@ describe('TenantService', () => {
it('rejects when the bootstrap response does not contain tenant data', async () => { it('rejects when the bootstrap response does not contain tenant data', async () => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting(), TenantService], providers: [provideHttpClient(), provideHttpClientTesting(), TenantService]
}); });
const service = TestBed.inject(TenantService); const service = TestBed.inject(TenantService);
const httpController = TestBed.inject(HttpTestingController); const httpController = TestBed.inject(HttpTestingController);
const bootstrapPromise = service.bootstrap(); const bootstrapPromise = service.bootstrap();
const request = httpController.expectOne( const request = httpController.expectOne(`${environment.url}tenants/bootstrap/localhost`);
`${environment.url}tenants/bootstrap?dominio=localhost&path=/`,
);
request.flush({}); request.flush({});
@@ -223,21 +200,19 @@ describe('TenantService', () => {
provide: REQUEST, provide: REQUEST,
useValue: new Request('https://internal.example/render', { useValue: new Request('https://internal.example/render', {
headers: { 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 service = TestBed.inject(TenantService);
const httpController = TestBed.inject(HttpTestingController); const httpController = TestBed.inject(HttpTestingController);
const bootstrapPromise = service.bootstrap(); const bootstrapPromise = service.bootstrap();
const request = httpController.expectOne( const request = httpController.expectOne(`${environment.url}tenants/bootstrap/broken.example.com`);
`${environment.url}tenants/bootstrap?dominio=broken.example.com&path=/render`,
);
request.flush({ message: 'Boom' }, { status: 500, statusText: 'Server Error' }); request.flush({ message: 'Boom' }, { status: 500, statusText: 'Server Error' });
@@ -247,39 +222,4 @@ describe('TenantService', () => {
httpController.verify(); 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 { isPlatformBrowser, isPlatformServer } from '@angular/common';
import { HttpErrorResponse } from '@angular/common/http'; import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { import {
inject, inject,
Injectable, Injectable,
@@ -7,31 +7,29 @@ import {
REQUEST, REQUEST,
RESPONSE_INIT, RESPONSE_INIT,
signal, signal,
TransferState, TransferState
} from '@angular/core'; } from '@angular/core';
import { IS_DISCOVERING_ROUTES } from '@angular/ssr'; import { IS_DISCOVERING_ROUTES } from '@angular/ssr';
import { firstValueFrom, timeout } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import { environment } from '../../../environments/environment'; 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, TenantBootstrapResponse } from './tenant.interface';
import { import {
TENANT_SSR_STATE_KEY, TENANT_SSR_STATE_KEY,
TenantBootstrapStatus, TenantBootstrapStatus,
TenantSsrState, TenantSsrState
} from './tenant-ssr-cache.store'; } from './tenant-ssr-cache.store';
@Injectable({ @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 platformId = inject(PLATFORM_ID);
private readonly request = inject(REQUEST, { optional: true }); private readonly request = inject(REQUEST, { optional: true });
private readonly responseInit = inject(RESPONSE_INIT, { optional: true }); private readonly responseInit = inject(RESPONSE_INIT, { optional: true });
private readonly transferState = inject(TransferState); private readonly transferState = inject(TransferState);
private readonly isDiscoveringRoutes = inject(IS_DISCOVERING_ROUTES, { optional: true }) ?? false; 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 tenantState = signal<Tenant | null>(null);
private readonly statusState = signal<TenantBootstrapStatus>('idle'); private readonly statusState = signal<TenantBootstrapStatus>('idle');
@@ -50,6 +48,7 @@ export class TenantService extends BaseApiService {
return `${environment.url}tenants/${tenant.codigo}`; return `${environment.url}tenants/${tenant.codigo}`;
} }
async bootstrap(): Promise<void> { async bootstrap(): Promise<void> {
if (this.statusState() !== 'idle') { if (this.statusState() !== 'idle') {
return; return;
@@ -70,18 +69,11 @@ export class TenantService extends BaseApiService {
return; return;
} }
const { domain, path } = this.resolveRequestContext(); const domain = this.resolveDomain();
try { try {
const response = await firstValueFrom( const response = await firstValueFrom(
this.http this.http.get<TenantBootstrapResponse>(`${environment.url}tenants/bootstrap/${domain}`)
.get<TenantBootstrapResponse>(`${environment.url}tenants/bootstrap`, {
params: {
dominio: domain,
path,
},
})
.pipe(timeout(this.bootstrapRequestTimeoutMs)),
); );
if (!response?.data) { if (!response?.data) {
@@ -91,7 +83,7 @@ export class TenantService extends BaseApiService {
this.setReady(response.data); this.setReady(response.data);
this.persistSsrState({ this.persistSsrState({
status: 'ready', status: 'ready',
tenant: response.data, tenant: response.data
}); });
} catch (error) { } catch (error) {
if (this.isNotFoundError(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)) { if (isPlatformBrowser(this.platformId)) {
const domain = window.location.hostname; 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.'); throw new Error('Tenant bootstrap could not resolve the current domain.');
} }
return { return this.normalizeHost(domain);
domain: this.normalizeHost(domain),
path: this.normalizePath(window.location.pathname),
};
} }
const requestHost = const requestHost =
@@ -132,14 +121,7 @@ export class TenantService extends BaseApiService {
throw new Error('Tenant bootstrap could not resolve the current domain.'); throw new Error('Tenant bootstrap could not resolve the current domain.');
} }
const forwardedPath = return this.normalizeHost(requestHost);
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),
};
} }
private normalizeHost(host: string): string { private normalizeHost(host: string): string {
@@ -161,16 +143,6 @@ export class TenantService extends BaseApiService {
return normalizedHost.replace(/:\d+$/, ''); 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 { private applyState(state: TenantSsrState): void {
if (state.status === 'ready') { if (state.status === 'ready') {
this.setReady(state.tenant); this.setReady(state.tenant);

View File

@@ -395,44 +395,6 @@
<hr class="section-divider" /> <hr class="section-divider" />
<div class="accordion-showcase">
<h2>Accordion</h2>
<p class="text-muted mb-4">
Accordion reutilizable basado en las clases globales de Bootstrap. Por defecto mantiene un
solo item abierto.
</p>
<app-accordion>
<app-accordion-item title="¿Cómo realizo una compra?" [open]="true">
Elegí el producto, seleccioná sus variantes y agregalo al carrito. Desde allí podés
revisar el pedido y continuar al checkout.
</app-accordion-item>
<app-accordion-item title="¿Qué medios de pago están disponibles?">
Los medios de pago dependen de la configuración de cada tienda y se muestran durante el
checkout.
</app-accordion-item>
<app-accordion-item title="Item deshabilitado" [disabled]="true">
Este contenido no puede abrirse.
</app-accordion-item>
</app-accordion>
<h3 class="h5 mt-5 mb-3">Variante múltiple y flush</h3>
<app-accordion [allowMultiple]="true" [flush]="true">
<app-accordion-item title="Envíos">
La tienda informa los métodos y costos de envío disponibles para cada pedido.
</app-accordion-item>
<app-accordion-item title="Cambios y devoluciones">
Las condiciones de cambio y devolución son definidas por cada comercio.
</app-accordion-item>
</app-accordion>
</div>
<hr class="section-divider" />
<div class="cart-showcase"> <div class="cart-showcase">
<div class="cart-showcase__header"> <div class="cart-showcase__header">
<div> <div>
@@ -612,55 +574,20 @@
</div> </div>
</section> </section>
<section class="component-demo card shadow-sm mt-4" data-testid="main-carousel"> <section class="component-demo card shadow-sm mt-4">
<div class="card-body"> <div class="card-body">
<span class="eyebrow">Reusable Component</span> <span class="eyebrow">Reusable Component</span>
<h2 class="mb-3">Main Carousel</h2> <h2 class="mb-4">Ticket</h2>
<p class="text-muted mb-4"> <p class="text-muted mb-4">Ticket responsive que ocupa el 100% del ancho disponible.</p>
Carrusel panorámico compuesto únicamente por imágenes, con autoslide e indicadores.
</p>
<app-main-carousel <app-ticket
[images]="mainCarouselImages" title="ENTRADA GENERAL"
[imageAlts]="mainCarouselImageAlts" ticketId="0000000000"
[autoSlideInterval]="4000" date="09 de Octubre"
ariaLabel="Promociones de la tienda" (viewQr)="triggerToast('info')"
/> (download)="triggerToast('success')"
</div> (share)="triggerToast('info')"
</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> </div>
</section> </section>
@@ -692,23 +619,6 @@
</div> </div>
</section> </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"> <section class="component-demo card shadow-sm mt-4">
<div class="card-body"> <div class="card-body">
<span class="eyebrow">Reusable Component</span> <span class="eyebrow">Reusable Component</span>
@@ -754,29 +664,6 @@
</div> </div>
</section> </section>
<section
class="component-demo component-demo--ticket-selector card shadow-sm mt-4"
data-testid="ticket-selector-demo"
>
<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"> <app-store-section title="PRODUCTOS" class="component-demo store-section-demo mt-5">
<div class="row"> <div class="row">
@for (product of testProducts; track product.title) { @for (product of testProducts; track product.title) {

View File

@@ -15,40 +15,10 @@
border: 0; border: 0;
} }
.component-demo--ticket-selector {
width: min(100%, 76rem);
}
.store-section-demo { .store-section-demo {
width: min(100%, 64rem); width: min(100%, 64rem);
} }
.carousel-product {
position: relative;
width: clamp(15rem, 30vw, 18rem);
height: 100%;
&__mock-id {
position: absolute;
top: 0.75rem;
left: 0.75rem;
z-index: 2;
padding: 0.35rem 0.55rem;
color: #fff;
font-size: 0.7rem;
font-weight: 700;
line-height: 1;
letter-spacing: 0.08em;
background: rgb(20 20 20 / 82%);
border-radius: 999px;
}
app-product-column-with-image {
display: block;
height: 100%;
}
}
.eyebrow { .eyebrow {
display: inline-block; display: inline-block;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
@@ -81,7 +51,7 @@ h1 {
.modal-showcase__result { .modal-showcase__result {
padding: 0.875rem 1rem; padding: 0.875rem 1rem;
border: 1px dashed var(--border-color); border: 1px dashed rgba(32, 32, 32, 0.14);
border-radius: 0.875rem; border-radius: 0.875rem;
background: linear-gradient(180deg, rgba(248, 248, 248, 0.92), rgba(255, 255, 255, 0.98)); background: linear-gradient(180deg, rgba(248, 248, 248, 0.92), rgba(255, 255, 255, 0.98));
color: #495057; color: #495057;
@@ -185,6 +155,7 @@ h1 {
gap: 0.75rem; gap: 0.75rem;
} }
.file-field { .file-field {
display: grid; display: grid;
gap: 0.75rem; gap: 0.75rem;
@@ -197,10 +168,6 @@ h1 {
} }
@media (max-width: 767.98px) { @media (max-width: 767.98px) {
.carousel-product {
width: min(18rem, calc(100vw - 5rem));
}
.input-grid { .input-grid {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
} }
@@ -235,19 +202,15 @@ h1 {
justify-content: center; justify-content: center;
height: 120px; height: 120px;
border-radius: 8px; border-radius: 8px;
border: 1px solid var(--border-color); border: 1px solid #dee2e6;
padding: 1rem; padding: 1rem;
overflow: hidden; overflow: hidden;
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.05); box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.05);
transition: transition: transform 0.2s ease, box-shadow 0.2s ease;
transform 0.2s ease,
box-shadow 0.2s ease;
&:hover { &:hover {
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.05), 0 4px 12px rgba(0, 0, 0, 0.08);
inset 0 2px 4px rgba(0, 0, 0, 0.05),
0 4px 12px rgba(0, 0, 0, 0.08);
} }
} }
@@ -285,12 +248,10 @@ h1 {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
border-radius: 8px; border-radius: 8px;
border: 1px solid var(--border-color); border: 1px solid #dee2e6;
overflow: hidden; overflow: hidden;
background-color: #ffffff; background-color: #ffffff;
transition: transition: transform 0.2s ease, box-shadow 0.2s ease;
transform 0.2s ease,
box-shadow 0.2s ease;
&:hover { &:hover {
transform: translateY(-3px); transform: translateY(-3px);
@@ -300,7 +261,7 @@ h1 {
&__preview { &__preview {
height: 80px; height: 80px;
width: 100%; width: 100%;
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid #dee2e6;
transition: opacity 0.2s ease; transition: opacity 0.2s ease;
&:hover { &:hover {
@@ -341,7 +302,7 @@ h1 {
background-color: #eaeaea; background-color: #eaeaea;
padding: 1.5rem; padding: 1.5rem;
border-radius: 8px; border-radius: 8px;
border: 1px solid var(--border-color); border: 1px solid #dee2e6;
} }
&__row { &__row {

View File

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

View File

@@ -7,7 +7,6 @@ import { InputComponent } from '../../../../shared/components/input/input.compon
import { PaginatorComponent } from '../../../../shared/components/paginator/paginator.component'; import { PaginatorComponent } from '../../../../shared/components/paginator/paginator.component';
import { ProductColumnWithImageComponent } from '../../../../shared/components/product-column-with-image/product-column-with-image.component'; import { 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 { 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 { ProductVerticalWithCartCardComponent } from '../../../../shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component';
import { StoreSectionComponent } from '../../../../shared/components/store-section/store-section.component'; import { StoreSectionComponent } from '../../../../shared/components/store-section/store-section.component';
import { ModalService } from '../../../../core/services/modal.service'; import { ModalService } from '../../../../core/services/modal.service';
@@ -15,28 +14,7 @@ import { TenantService } from '../../../../core/services/tenant.service';
import { ToastService } from '../../../../core/services/toast.service'; import { ToastService } from '../../../../core/services/toast.service';
import { StepperComponent } from '../../../../shared/components/stepper/stepper.component'; import { StepperComponent } from '../../../../shared/components/stepper/stepper.component';
import { StepComponent } from '../../../../shared/components/stepper/step.component'; import { StepComponent } from '../../../../shared/components/stepper/step.component';
import { CarouselComponent } from '../../../../shared/components/carousel/carousel.component'; import { TicketComponent } from '../../../../shared/components/ticket/ticket.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({ @Component({
selector: 'app-reutilizables-test-page', selector: 'app-reutilizables-test-page',
@@ -47,18 +25,13 @@ const ticketOption = (value: string, label: string = value): { value: string; la
PaginatorComponent, PaginatorComponent,
ProductColumnWithImageComponent, ProductColumnWithImageComponent,
ProductRowCardComponent, ProductRowCardComponent,
ProductTicketSelectorComponent,
ProductVerticalWithCartCardComponent, ProductVerticalWithCartCardComponent,
StoreSectionComponent, StoreSectionComponent,
IconButtonComponent, IconButtonComponent,
CartIconComponent, CartIconComponent,
StepperComponent, StepperComponent,
StepComponent, StepComponent,
CarouselComponent, TicketComponent,
MainCarouselComponent,
AccordionComponent,
AccordionItemComponent,
LocationMapComponent,
], ],
templateUrl: './reutilizables-test-page.component.html', templateUrl: './reutilizables-test-page.component.html',
styleUrl: './reutilizables-test-page.component.scss', styleUrl: './reutilizables-test-page.component.scss',
@@ -91,39 +64,6 @@ export class ReutilizablesTestPageComponent {
protected lastModalResult = 'Todavia no se abrio ningun modal.'; protected lastModalResult = 'Todavia no se abrio ningun modal.';
protected readonly paginatorTotalPages = 8; protected readonly paginatorTotalPages = 8;
protected readonly cartBackgroundColor = '#ffffff'; protected readonly cartBackgroundColor = '#ffffff';
protected readonly mapLocations: readonly MapLocation[] = [
{
id: 'gigante-de-arroyito',
label: 'Gigante de Arroyito',
address: 'Av. Génova 640, Rosario',
latitude: -32.913997,
longitude: -60.674567,
},
{
id: 'punto-centro',
label: 'Punto Centro',
address: 'Centro, Rosario',
latitude: -32.94682,
longitude: -60.63932,
},
{
id: 'punto-sur',
label: 'Punto Sur',
address: 'Zona sur, Rosario',
latitude: -32.97151,
longitude: -60.65064,
},
];
protected readonly mainCarouselImages = [
'https://images.unsplash.com/photo-1441986300917-64674bd600d8?auto=format&fit=crop&w=1600&q=80',
'https://images.unsplash.com/photo-1529139574466-a303027c1d8b?auto=format&fit=crop&w=1600&q=80',
'https://images.unsplash.com/photo-1483985988355-763728e1935b?auto=format&fit=crop&w=1600&q=80',
];
protected readonly mainCarouselImageAlts = [
'Interior de una tienda de indumentaria',
'Colección de moda en exteriores',
'Percheros con prendas de distintos colores',
];
protected readonly testProducts = [ protected readonly testProducts = [
{ {
@@ -149,71 +89,14 @@ export class ReutilizablesTestPageComponent {
}, },
]; ];
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 = { protected readonly testRowProduct = {
title: 'ENTRADA GENERAL', title: 'ENTRADA GENERAL',
description: description:
'Acceso total al predio. No incluye acceso a estacionamiento. Niños menores de 5 años ingresan gratis.', 'Acceso total al predio. No incluye acceso a estacionamiento. Niños menores de 5 años ingresan gratis.',
price: 10000, price: 10000,
variants: [ variants: [
{ id: '10-oct-manana', values: { fecha: '10 de Octubre', turno: 'Mañana' } }, { label: '10 de Octubre', value: '10-oct' },
{ id: '10-oct-tarde', values: { fecha: '10 de Octubre', turno: 'Tarde' } }, { label: '11 de Octubre', value: '11-oct' },
{ id: '11-oct-tarde', values: { fecha: '11 de Octubre', turno: 'Tarde' } },
], ],
}; };
@@ -223,125 +106,6 @@ export class ReutilizablesTestPageComponent {
price: 11500, 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,
maximum_addable_quantity: 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,
maximum_addable_quantity: 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,
maximum_addable_quantity: 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,
maximum_addable_quantity: 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,
maximum_addable_quantity: 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,
maximum_addable_quantity: 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,
maximum_addable_quantity: 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,
maximum_addable_quantity: 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,
maximum_addable_quantity: 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,
maximum_addable_quantity: 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[] = [ protected readonly cartMockItems: CartItemMock[] = [
{ {
imageUrl: null, imageUrl: null,
@@ -398,12 +162,6 @@ export class ReutilizablesTestPageComponent {
alert(`Comprando: ${productTitle}`); 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 { protected onFileChange(file: File | null): void {
this.fileName = file?.name ?? ''; this.fileName = file?.name ?? '';
} }

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

View File

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

View File

@@ -31,7 +31,7 @@ describe('ProductAttributeSelectorComponent', () => {
fixture.componentRef.setInput('variants', [ fixture.componentRef.setInput('variants', [
{ {
id: 1, id: 1,
maximum_addable_quantity: null, stock_tecnico: null,
values: { size: 'S' }, values: { size: 'S' },
}, },
]); ]);
@@ -51,12 +51,12 @@ describe('ProductAttributeSelectorComponent', () => {
fixture.componentRef.setInput('variants', [ fixture.componentRef.setInput('variants', [
{ {
id: 1, id: 1,
maximum_addable_quantity: 0, stock_tecnico: 0,
values: { size: 'S' }, values: { size: 'S' },
}, },
{ {
id: 2, id: 2,
maximum_addable_quantity: 2, stock_tecnico: 2,
values: { size: 'M' }, values: { size: 'M' },
}, },
]); ]);
@@ -68,85 +68,4 @@ describe('ProductAttributeSelectorComponent', () => {
expect(buttons[0].disabled).toBe(true); expect(buttons[0].disabled).toBe(true);
expect(buttons[1].disabled).toBe(false); 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, maximum_addable_quantity: null, values: { event_date: '1' } },
{ id: 2, maximum_addable_quantity: null, values: { event_date: '2' } },
{ id: 3, maximum_addable_quantity: 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, maximum_addable_quantity: null, values: { size: 'S', internal_type: 'adult' } },
{ id: 2, maximum_addable_quantity: 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

@@ -32,69 +32,48 @@ export class ProductAttributeSelectorComponent {
public variantChange = output<CatalogItemVariant | null>(); public variantChange = output<CatalogItemVariant | null>();
protected readonly visibleAttributes = computed(() => protected readonly selectedAttributeOptions = signal<Record<number, number>>({});
this.attributes().filter((attribute) => attribute.show_in_selector !== false),
);
protected readonly selectedAttributeOptions = signal<Record<string, number[]>>({});
protected readonly availableOptions = computed(() => { protected readonly availableOptions = computed(() => {
const selections = this.selectedAttributeOptions(); const selections = this.selectedAttributeOptions();
const variants = this.variants(); const variants = this.variants();
const attributes = this.visibleAttributes(); const attributes = this.attributes();
const availability: Record<string, Record<number, boolean>> = {}; const availability: Record<number, Record<number, boolean>> = {};
for (const attribute of attributes) { for (const attribute of attributes) {
availability[attribute.codigo] = {}; availability[attribute.id] = {};
for (const option of attribute.options) { for (const option of attribute.options) {
const optionNormalized = this.normalizeText(option.value || option.label); const optionNormalized = this.normalizeText(option.value || option.label);
const selectedForAttribute = selections[attribute.codigo] ?? [];
if (
!attribute.allow_multi_select &&
selectedForAttribute.length >= 1 &&
!selectedForAttribute.includes(option.id)
) {
availability[attribute.codigo][option.id] = false;
continue;
}
const isAvailable = variants.some((variant) => { const isAvailable = variants.some((variant) => {
if (!this.isVariantAvailable(variant)) return false; if (!this.isVariantAvailable(variant)) return false;
const variantAttrValues = this.getVariantAttributeValues(attribute, variant.values); const variantAttrValue = this.getDefaultVariantAttributeValue(attribute, variant.values);
const desiredOptionIds = selectedForAttribute.includes(option.id) if (!variantAttrValue || this.normalizeText(variantAttrValue) !== optionNormalized) {
? 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; return false;
} }
for (const otherAttr of attributes) { for (const otherAttr of attributes) {
if (otherAttr.codigo === attribute.codigo) continue; if (otherAttr.id === attribute.id) continue;
const selectedOptionIds = selections[otherAttr.codigo] ?? []; const selectedOptionId = selections[otherAttr.id];
const selectedValues = selectedOptionIds if (selectedOptionId !== undefined) {
.map((id) => otherAttr.options.find((candidate) => candidate.id === id)) const selectedOption = otherAttr.options.find((o) => o.id === selectedOptionId);
.filter((candidate): candidate is ProductAttributeOption => candidate !== undefined) if (selectedOption) {
.map((candidate) => this.normalizeText(candidate.value || candidate.label)); const selectedNormalized = this.normalizeText(
const variantValues = this.getVariantAttributeValues(otherAttr, variant.values); selectedOption.value || selectedOption.label,
if (!selectedValues.every((value) => variantValues.includes(value))) { );
const vAttrValue = this.getDefaultVariantAttributeValue(otherAttr, variant.values);
if (!vAttrValue || this.normalizeText(vAttrValue) !== selectedNormalized) {
return false; return false;
} }
} }
}
}
return true; return true;
}); });
availability[attribute.codigo][option.id] = isAvailable; availability[attribute.id][option.id] = isAvailable;
} }
} }
@@ -107,7 +86,7 @@ export class ProductAttributeSelectorComponent {
constructor() { constructor() {
effect(() => { effect(() => {
const attributes = this.visibleAttributes(); const attributes = this.attributes();
const defaultVariant = this.selectedVariant(); const defaultVariant = this.selectedVariant();
untracked(() => { untracked(() => {
@@ -118,7 +97,7 @@ export class ProductAttributeSelectorComponent {
effect(() => { effect(() => {
const selections = this.selectedAttributeOptions(); const selections = this.selectedAttributeOptions();
const variants = this.variants(); const variants = this.variants();
const attributes = this.visibleAttributes(); const attributes = this.attributes();
untracked(() => { untracked(() => {
this.emitMatchingVariant(selections, variants, attributes); this.emitMatchingVariant(selections, variants, attributes);
@@ -130,28 +109,17 @@ export class ProductAttributeSelectorComponent {
attribute: ProductAttribute, attribute: ProductAttribute,
option: ProductAttributeOption, option: ProductAttributeOption,
): boolean { ): boolean {
return (this.selectedAttributeOptions()[attribute.codigo] ?? []).includes(option.id); return this.selectedAttributeOptions()[attribute.id] === option.id;
} }
protected selectAttributeOption( protected selectAttributeOption(
attribute: ProductAttribute, attribute: ProductAttribute,
option: ProductAttributeOption, option: ProductAttributeOption,
): void { ): void {
this.selectedAttributeOptions.update((current) => { this.selectedAttributeOptions.update((current) => ({
const selected = current[attribute.codigo] ?? []; ...current,
const isMultiple = attribute.allow_multi_select ?? false; [attribute.id]: option.id,
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 };
});
} }
protected isColorOption(option: ProductAttributeOption): boolean { protected isColorOption(option: ProductAttributeOption): boolean {
@@ -166,44 +134,51 @@ export class ProductAttributeSelectorComponent {
attributes: ProductAttribute[], attributes: ProductAttribute[],
defaultVariant: CatalogItemVariant | null, defaultVariant: CatalogItemVariant | null,
): void { ): void {
const selections: Record<string, number[]> = {}; const selections: Record<number, number> = {};
for (const attribute of attributes) { for (const attribute of attributes) {
const optionIds = this.findDefaultOptionIds(attribute, defaultVariant); const optionId = this.findDefaultOptionId(attribute, defaultVariant);
if (optionIds.length > 0) { if (optionId !== null) {
selections[attribute.codigo] = optionIds; selections[attribute.id] = optionId;
} }
} }
this.selectedAttributeOptions.set(selections); this.selectedAttributeOptions.set(selections);
} }
private findDefaultOptionIds( private findDefaultOptionId(
attribute: ProductAttribute, attribute: ProductAttribute,
variant: CatalogItemVariant | null, variant: CatalogItemVariant | null,
): number[] { ): number | null {
if (!variant) { if (!variant) {
return []; return null;
} }
const defaultValues = this.getVariantAttributeValues(attribute, variant.values); const defaultValue = this.getDefaultVariantAttributeValue(attribute, variant.values);
if (defaultValues.length === 0) { if (!defaultValue) {
return []; return null;
} }
return attribute.options const normalizedValue = this.normalizeText(defaultValue);
.filter( const matchByValue = attribute.options.find(
(option) => (option) => this.normalizeText(option.value) === normalizedValue,
defaultValues.includes(this.normalizeText(option.value)) || );
defaultValues.includes(this.normalizeText(option.label)),
) if (matchByValue) {
.map((option) => option.id); return matchByValue.id;
} }
private getVariantAttributeValues( const matchByLabel = attribute.options.find(
(option) => this.normalizeText(option.label) === normalizedValue,
);
return matchByLabel?.id ?? null;
}
private getDefaultVariantAttributeValue(
attribute: ProductAttribute, attribute: ProductAttribute,
variantAttributes: Record<string, string | string[]>, variantAttributes: Record<string, string>,
): string[] { ): string | null {
const normalizedCodigo = this.normalizeText(attribute.codigo); const normalizedCodigo = this.normalizeText(attribute.codigo);
const normalizedNombre = this.normalizeText(attribute.nombre); const normalizedNombre = this.normalizeText(attribute.nombre);
@@ -211,11 +186,11 @@ export class ProductAttributeSelectorComponent {
const normalizedKey = this.normalizeText(key); const normalizedKey = this.normalizeText(key);
if (normalizedKey === normalizedCodigo || normalizedKey === normalizedNombre) { 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 { private normalizeText(value: string | null | undefined): string {
@@ -227,7 +202,7 @@ export class ProductAttributeSelectorComponent {
} }
private isVariantAvailable(variant: CatalogItemVariant): boolean { private isVariantAvailable(variant: CatalogItemVariant): boolean {
return variant.maximum_addable_quantity !== 0; return this.inventoryPolicy() === 'unlimited' || (variant.stock_tecnico ?? 0) > 0;
} }
private findFirstHexValue(value: unknown): string | null { private findFirstHexValue(value: unknown): string | null {
@@ -259,7 +234,7 @@ export class ProductAttributeSelectorComponent {
} }
private emitMatchingVariant( private emitMatchingVariant(
selections: Record<string, number[]>, selections: Record<number, number>,
variants: CatalogItemVariant[], variants: CatalogItemVariant[],
attributes: ProductAttribute[], attributes: ProductAttribute[],
): void { ): void {
@@ -268,22 +243,18 @@ export class ProductAttributeSelectorComponent {
return; return;
} }
const selectedValuesByCode: Record<string, string[]> = {}; const selectedValuesById: Record<number, string> = {};
let allSelected = true; let allSelected = true;
for (const attr of attributes) { for (const attr of attributes) {
const selectedOptionIds = selections[attr.codigo] ?? []; const selectedOptionId = selections[attr.id];
if ( if (selectedOptionId === undefined) {
selectedOptionIds.length === 0 ||
(!attr.allow_multi_select && selectedOptionIds.length !== 1)
) {
allSelected = false; allSelected = false;
break; break;
} }
selectedValuesByCode[attr.codigo] = selectedOptionIds const option = attr.options.find((o) => o.id === selectedOptionId);
.map((id) => attr.options.find((option) => option.id === id)) if (option) {
.filter((option): option is ProductAttributeOption => option !== undefined) selectedValuesById[attr.id] = this.normalizeText(option.value || option.label);
.map((option) => this.normalizeText(option.value || option.label)) }
.sort();
} }
if (!allSelected) { if (!allSelected) {
@@ -293,12 +264,10 @@ export class ProductAttributeSelectorComponent {
const matchingVariant = variants.find((variant) => { const matchingVariant = variants.find((variant) => {
return attributes.every((attr) => { return attributes.every((attr) => {
const selectedValues = selectedValuesByCode[attr.codigo]; const selectedValue = selectedValuesById[attr.id];
const variantValues = this.getVariantAttributeValues(attr, variant.values).sort(); const variantValue = this.getDefaultVariantAttributeValue(attr, variant.values);
return ( if (!variantValue) return false;
selectedValues.length === variantValues.length && return this.normalizeText(variantValue) === selectedValue;
selectedValues.every((value, index) => value === variantValues[index])
);
}); });
}); });

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; flex: 1;
display: block; display: block;
} }
@media (max-width: 767.98px) {
.form-actions {
flex-direction: column;
}
.action-btn {
width: 100%;
}
}

View File

@@ -1,6 +1,6 @@
:host { :host {
display: block; display: block;
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid #dddddd;
} }
:host(:last-child) { :host(:last-child) {
@@ -24,7 +24,7 @@
aspect-ratio: 4 / 3; aspect-ratio: 4 / 3;
border-radius: 5px 0 0 5px; border-radius: 5px 0 0 5px;
background-color: #ffffff; background-color: #ffffff;
border: 1px solid var(--border-color); border: 1px solid #eaeaea;
overflow: hidden; overflow: hidden;
img { img {

View File

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

View File

@@ -2,57 +2,33 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 16px;
padding: 16px; padding: 16px;
padding-left:0; padding-left:0;
transition: transition: box-shadow 0.2s, border-radius 0.2s;
box-shadow 0.2s,
border-radius 0.2s;
cursor: pointer; cursor: pointer;
} }
.purchase-item:hover { .purchase-item:hover {
box-shadow: 0px 0px 30px 0px rgba(0, 0, 0, 0.07); box-shadow: 0px 0px 30px 0px rgba(0, 0, 0, 0.07);
border-radius: 8px; border-radius: 8px;
} }
.purchase-item:hover .arrow-icon { .purchase-item:hover .arrow-icon {
color: var(--color-primary, var(--tenant-primary, #5b75ff)); color: #5b75ff; /* Blue on hover */
} }
.purchase-info { .purchase-info {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 4px;
} }
.purchase-id { .purchase-id {
font-weight: bold; font-weight: bold;
color: #666666; color: #666666;
font-size: 13px; font-size: 13px;
} }
.purchase-date { .purchase-date {
font-weight: 325; font-weight: 325;
color: #666666; color: #666666;
font-size: 10px; font-size: 10px;
} }
.purchase-status {
margin-left: auto;
margin-right: 20px;
color: var(--color-primary, var(--tenant-primary, #5b75ff));
font-size: 16px;
font-weight: 700;
text-align: right;
}
.purchase-action {
display: flex;
flex-shrink: 0;
align-items: center;
}
.arrow-icon { .arrow-icon {
width: 8px; width: 8px;
height: 14px; height: 14px;

View File

@@ -9,5 +9,6 @@ import { RouterLink } from '@angular/router';
styleUrl: './purchase-list-item.scss', styleUrl: './purchase-list-item.scss',
}) })
export class PurchaseListItem { export class PurchaseListItem {
@Input() purchase!: { id: number; date: string; statusMessage?: string | null }; @Input() purchase!: { id: number; date: string };
} }

View File

@@ -1,36 +1,29 @@
<div class="purchase-list-container"> <div class="purchase-list-container">
@if (isLoading()) { <ng-container *ngIf="isLoading(); else contentTpl">
@for (i of [1, 2, 3, 4]; track i) { <!-- Skeleton -->
<div class="skeleton-item"> <div class="skeleton-item" *ngFor="let i of [1, 2, 3, 4]">
<div class="skeleton-info"> <div class="skeleton-info">
<div class="skeleton-id"></div> <div class="skeleton-id"></div>
<div class="skeleton-date"></div> <div class="skeleton-date"></div>
</div> </div>
<div class="skeleton-action"></div> <div class="skeleton-action"></div>
</div> </div>
} </ng-container>
} @else {
@if (purchases().length > 0) {
@for (purchase of purchases(); track purchase.id; let last = $last) {
<app-purchase-list-item [purchase]="purchase"></app-purchase-list-item>
@if (!last) {
<div class="purchase-divider"></div>
}
}
} @else {
<div class="empty-state">Aún no hay compras realizadas</div>
}
@if (pagination(); as paginationData) { <ng-template #contentTpl>
@if (paginationData.last_page > 1) { <ng-container *ngIf="purchases().length > 0; else emptyTpl">
<app-paginator <ng-container *ngFor="let purchase of purchases(); let last = last">
class="purchase-list__paginator" <app-purchase-list-item
[page]="paginationData.current_page" [purchase]="purchase">
[totalPages]="paginationData.last_page" </app-purchase-list-item>
[disabled]="isLoading()" <div class="purchase-divider" *ngIf="!last"></div>
(pageChange)="onPageChange($event)" </ng-container>
></app-paginator> </ng-container>
} </ng-template>
}
} <ng-template #emptyTpl>
<div class="empty-state">
Aún no hay compras realizadas
</div>
</ng-template>
</div> </div>

View File

@@ -4,14 +4,9 @@
width: 100%; width: 100%;
} }
.purchase-list__paginator {
align-self: center;
margin-top: 1.5rem;
}
.purchase-divider { .purchase-divider {
height: 1px; height: 1px;
background-color: #dddddd; background-color: #DDDDDD;
width: 100%; width: 100%;
} }
@@ -28,7 +23,7 @@
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 1rem 0; padding: 1rem 0;
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid #DDDDDD;
} }
.skeleton-item:last-child { .skeleton-item:last-child {
@@ -44,7 +39,7 @@
.skeleton-id { .skeleton-id {
width: 100px; width: 100px;
height: 1rem; height: 1rem;
background-color: #eeeeee; background-color: #EEEEEE;
border-radius: 4px; border-radius: 4px;
animation: pulse 1.5s infinite ease-in-out; animation: pulse 1.5s infinite ease-in-out;
} }
@@ -52,7 +47,7 @@
.skeleton-date { .skeleton-date {
width: 150px; width: 150px;
height: 0.8rem; height: 0.8rem;
background-color: #eeeeee; background-color: #EEEEEE;
border-radius: 4px; border-radius: 4px;
animation: pulse 1.5s infinite ease-in-out; animation: pulse 1.5s infinite ease-in-out;
} }
@@ -60,19 +55,19 @@
.skeleton-action { .skeleton-action {
width: 24px; width: 24px;
height: 24px; height: 24px;
background-color: #eeeeee; background-color: #EEEEEE;
border-radius: 50%; border-radius: 50%;
animation: pulse 1.5s infinite ease-in-out; animation: pulse 1.5s infinite ease-in-out;
} }
@keyframes pulse { @keyframes pulse {
0% { 0% {
background-color: #eeeeee; background-color: #EEEEEE;
} }
50% { 50% {
background-color: #e0e0e0; background-color: #E0E0E0;
} }
100% { 100% {
background-color: #eeeeee; background-color: #EEEEEE;
} }
} }

View File

@@ -1,24 +1,19 @@
import { Component, OnInit, inject, signal } from '@angular/core'; import { Component, OnInit, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { PurchaseListItem } from '../purchase-list-item/purchase-list-item'; import { PurchaseListItem } from '../purchase-list-item/purchase-list-item';
import { import { CheckoutService, PurchaseSummaryResponse } from '../../../../../../core/services/checkout.service';
CheckoutService,
PurchaseSummaryResponse,
} from '../../../../../../core/services/checkout.service';
import { ToastService } from '../../../../../../core/services/toast.service'; import { ToastService } from '../../../../../../core/services/toast.service';
import { TenantService } from '../../../../../../core/services/tenant.service'; import { TenantService } from '../../../../../../core/services/tenant.service';
import { ApiPaginationMeta } from '../../../../../../core/services/api-paginated-response.interface';
import { PaginatorComponent } from '../../../../../../shared/components/paginator/paginator.component';
type PurchaseListViewModel = { type PurchaseListViewModel = {
id: number; id: number;
date: string; date: string;
statusMessage: string | null;
}; };
@Component({ @Component({
selector: 'app-purchase-list', selector: 'app-purchase-list',
standalone: true, standalone: true,
imports: [PurchaseListItem, PaginatorComponent], imports: [CommonModule, PurchaseListItem],
templateUrl: './purchase-list.html', templateUrl: './purchase-list.html',
styleUrl: './purchase-list.scss', styleUrl: './purchase-list.scss',
}) })
@@ -27,28 +22,19 @@ export class PurchaseList implements OnInit {
private readonly toastService = inject(ToastService); private readonly toastService = inject(ToastService);
private readonly tenantService = inject(TenantService); private readonly tenantService = inject(TenantService);
protected readonly purchases = signal<PurchaseListViewModel[]>([]); purchases = signal<PurchaseListViewModel[]>([]);
protected readonly pagination = signal<ApiPaginationMeta | null>(null); isLoading = signal<boolean>(true);
protected readonly isLoading = signal(true);
async ngOnInit(): Promise<void> { async ngOnInit(): Promise<void> {
await this.loadPurchases();
}
protected async onPageChange(page: number): Promise<void> {
await this.loadPurchases(page);
}
private async loadPurchases(page = 1): Promise<void> {
this.isLoading.set(true);
try { try {
const response = await this.checkoutService const tenantCode = this.tenantService.tenant()?.codigo || '';
.withCustomLoading()
.getPurchases(this.tenantCode(), 'paid,in_review', { page });
this.purchases.set(this.mapPurchases(response.data)); const response = await this.checkoutService.getPurchases(tenantCode, 'paid');
this.pagination.set(response.meta); const mappedPurchases = response.data.map((purchase: PurchaseSummaryResponse) => ({
id: purchase.id,
date: this.formatDate(purchase.created_at),
}));
this.purchases.set(mappedPurchases);
} catch (error) { } catch (error) {
this.toastService.danger('Hubo un error al cargar las compras'); this.toastService.danger('Hubo un error al cargar las compras');
} finally { } finally {
@@ -56,18 +42,6 @@ export class PurchaseList implements OnInit {
} }
} }
private tenantCode(): string {
return this.tenantService.tenant()?.codigo || '';
}
private mapPurchases(purchases: PurchaseSummaryResponse[]): PurchaseListViewModel[] {
return purchases.map((purchase) => ({
id: purchase.id,
date: this.formatDate(purchase.created_at),
statusMessage: purchase.status === 'in_review' ? 'Esperando confirmación' : null,
}));
}
private formatDate(value: string | null): string { private formatDate(value: string | null): string {
if (!value) { if (!value) {
return '-'; return '-';

View File

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

View File

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

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