Compare commits

...

9 Commits

34 changed files with 1361 additions and 131 deletions

View File

@@ -30,16 +30,11 @@
"input": "public" "input": "public"
} }
], ],
"styles": [ "styles": ["node_modules/leaflet/dist/leaflet.css", "src/styles.scss"],
"src/styles.scss"
],
"server": "src/main.server.ts", "server": "src/main.server.ts",
"outputMode": "server", "outputMode": "server",
"security": { "security": {
"allowedHosts": [ "allowedHosts": ["localhost", "127.0.0.1"]
"localhost",
"127.0.0.1"
]
}, },
"ssr": { "ssr": {
"entry": "src/server.ts" "entry": "src/server.ts"

25
package-lock.json generated
View File

@@ -19,6 +19,7 @@
"@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"
@@ -28,6 +29,7 @@
"@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",
@@ -3816,6 +3818,13 @@
"@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",
@@ -3823,6 +3832,16 @@
"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",
@@ -5936,6 +5955,12 @@
], ],
"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,6 +23,7 @@
"@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"
@@ -32,6 +33,7 @@
"@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",

View File

@@ -1,6 +1,8 @@
<header class="store-layout__header"> <header class="store-layout__header">
<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 d-flex flex-row flex-wrap align-items-center justify-content-between gap-3"> <div
class="container-xl px-3 px-md-4 py-3 py-lg-4 d-flex flex-row flex-wrap align-items-center justify-content-between gap-3"
>
<a <a
routerLink="/" routerLink="/"
class="store-layout__brand-slot d-inline-flex align-items-center gap-3 flex-shrink-0 text-decoration-none" class="store-layout__brand-slot d-inline-flex align-items-center gap-3 flex-shrink-0 text-decoration-none"
@@ -16,7 +18,9 @@
} }
</a> </a>
<div class="d-flex flex-row align-items-center gap-3 ms-auto flex-nowrap flex-grow-1 justify-content-between justify-content-md-end"> <div
class="d-flex flex-row align-items-center gap-3 ms-auto flex-nowrap flex-grow-1 justify-content-between justify-content-md-end"
>
<div class="input-group store-layout__search" role="search"> <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
@@ -33,7 +37,21 @@
</button> </button>
</div> </div>
<div class="d-flex align-items-center justify-content-end gap-1" aria-label="Acciones de usuario"> @if (ticketsMenu(); as menu) {
<app-button
variant="primary"
hostClass="flex-shrink-0"
data-testid="store-header-tickets"
(click)="ticketsClick.emit()"
>
{{ menu.label }}
</app-button>
}
<div
class="d-flex align-items-center justify-content-end gap-1"
aria-label="Acciones de usuario"
>
<app-cart-icon <app-cart-icon
[quantity]="cartQuantity()" [quantity]="cartQuantity()"
ariaLabel="Carrito de compras" ariaLabel="Carrito de compras"
@@ -54,6 +72,7 @@
<app-user-dropdown <app-user-dropdown
id="store-user-dropdown" id="store-user-dropdown"
[user]="user()" [user]="user()"
[accountNavigationMenus]="accountNavigationMenus()"
(logoutClick)="onLogoutClick()" (logoutClick)="onLogoutClick()"
/> />
} }

View File

@@ -4,12 +4,20 @@ 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';
@Component({ @Component({
selector: 'app-store-header', selector: 'app-store-header',
imports: [CartIconComponent, IconButtonComponent, RouterLink, UserDropdownComponent], imports: [
ButtonComponent,
CartIconComponent,
IconButtonComponent,
RouterLink,
UserDropdownComponent,
],
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>);
@@ -18,7 +26,10 @@ export class StoreHeaderComponent {
readonly cartQuantity = input<number>(0); readonly cartQuantity = input<number>(0);
readonly user = input<AuthUser | null>(null); readonly user = input<AuthUser | null>(null);
readonly isAuthenticated = input<boolean>(false); readonly isAuthenticated = input<boolean>(false);
readonly accountNavigationMenus = input<readonly Menu[]>([]);
readonly ticketsMenu = input<Menu | null>(null);
readonly 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>();

View File

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

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

View File

@@ -4,7 +4,10 @@
[logoUrl]="tenant()?.header_logo ?? null" [logoUrl]="tenant()?.header_logo ?? null"
[user]="user()" [user]="user()"
[isAuthenticated]="isAuthenticated()" [isAuthenticated]="isAuthenticated()"
[accountNavigationMenus]="accountNavigationMenus()"
[ticketsMenu]="ticketsMenu() ?? null"
(cartClick)="isCartOpen.set(!isCartOpen())" (cartClick)="isCartOpen.set(!isCartOpen())"
(ticketsClick)="onTicketsClick()"
(loginClick)="onLoginClick()" (loginClick)="onLoginClick()"
(logoutClick)="onLogoutClick()" (logoutClick)="onLogoutClick()"
/> />
@@ -21,8 +24,12 @@
[backgroundColor]="'#ffffff'" [backgroundColor]="'#ffffff'"
(closed)="isCartOpen.set(false)" (closed)="isCartOpen.set(false)"
> >
<app-button variant="secondary" class="flex-grow-1" (click)="isCartOpen.set(false)">Seguir comprando</app-button> <app-button variant="secondary" class="flex-grow-1" (click)="isCartOpen.set(false)"
<app-button variant="primary" class="flex-grow-1" (click)="onCheckoutClick()">Comprar</app-button> >Seguir comprando</app-button
>
<app-button variant="primary" class="flex-grow-1" (click)="onCheckoutClick()"
>Comprar</app-button
>
</app-cart> </app-cart>
</div> </div>
} }

View File

@@ -8,6 +8,7 @@ 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';
@@ -27,9 +28,47 @@ const tenant: Tenant = {
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',
menues: [ 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, id: 1,
code: 'help', code: 'help',
label: 'Ayuda',
parent_menu_code: null, parent_menu_code: null,
content_type: 'dynamic', content_type: 'dynamic',
route: '/ayuda', route: '/ayuda',
@@ -37,6 +76,7 @@ const tenant: Tenant = {
{ {
id: 2, id: 2,
code: 'help.contact', code: 'help.contact',
label: 'Contacto',
parent_menu_code: 'help', parent_menu_code: 'help',
content_type: 'static', content_type: 'static',
route: '/ayuda/contacto', route: '/ayuda/contacto',
@@ -45,6 +85,7 @@ const tenant: Tenant = {
{ {
id: 3, id: 3,
code: 'help.faq', code: 'help.faq',
label: 'Preguntas frecuentes',
parent_menu_code: 'help', parent_menu_code: 'help',
content_type: 'static', content_type: 'static',
route: '/ayuda/preguntas-frecuentes', route: '/ayuda/preguntas-frecuentes',
@@ -82,8 +123,12 @@ const tenant: Tenant = {
}; };
describe('StoreLayoutComponent', () => { describe('StoreLayoutComponent', () => {
let tenantState = signal<Tenant | null>(tenant);
let cartState = signal<Cart | null>(null);
beforeEach(async () => { beforeEach(async () => {
const tenantState = signal(tenant); tenantState = signal<Tenant | null>(tenant);
cartState = signal<Cart | null>(null);
const authUserState = signal<AuthUser | null>(null); const authUserState = signal<AuthUser | null>(null);
const isAuthenticatedState = signal(false); const isAuthenticatedState = signal(false);
@@ -97,17 +142,19 @@ describe('StoreLayoutComponent', () => {
tenant: tenantState.asReadonly(), tenant: tenantState.asReadonly(),
status: signal<'ready'>('ready').asReadonly(), status: signal<'ready'>('ready').asReadonly(),
getTenant: () => tenantState(), getTenant: () => tenantState(),
bootstrap: vi.fn().mockResolvedValue(undefined) bootstrap: vi.fn().mockResolvedValue(undefined),
} },
}, },
{ {
provide: CartService, provide: CartService,
useValue: { useValue: {
cart: signal(null).asReadonly(), cart: cartState.asReadonly(),
loadCart: () => of({ id: 1, items: [], subtotal: '0' }), loadCart: () => of({ id: 1, items: [], subtotal: '0' }),
updateItemQuantity: vi.fn().mockReturnValue(of({ data: { id: 1, items: [], subtotal: '0' } })), updateItemQuantity: vi
removeItem: vi.fn().mockReturnValue(of({ data: { id: 1, items: [], subtotal: '0' } })) .fn()
} .mockReturnValue(of({ data: { id: 1, items: [], subtotal: '0' } })),
removeItem: vi.fn().mockReturnValue(of({ data: { id: 1, items: [], subtotal: '0' } })),
},
}, },
{ {
provide: ToastService, provide: ToastService,
@@ -116,18 +163,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)),
} },
} },
] ],
}).compileComponents(); }).compileComponents();
}); });
@@ -152,10 +199,14 @@ 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(`app-store-header img.store-layout__brand-logo[src="${tenant.header_logo}"]`) compiled.querySelector(
`app-store-header img.store-layout__brand-logo[src="${tenant.header_logo}"]`,
),
).not.toBeNull(); ).not.toBeNull();
expect( expect(
compiled.querySelector(`app-store-footer img.store-layout__brand-logo[src="${tenant.footer_logo}"]`) compiled.querySelector(
`app-store-footer img.store-layout__brand-logo[src="${tenant.footer_logo}"]`,
),
).not.toBeNull(); ).not.toBeNull();
expect(compiled.querySelector('.fa-cart-shopping')).not.toBeNull(); expect(compiled.querySelector('.fa-cart-shopping')).not.toBeNull();
expect(compiled.querySelector('.fa-circle-user')).not.toBeNull(); expect(compiled.querySelector('.fa-circle-user')).not.toBeNull();
@@ -165,6 +216,69 @@ describe('StoreLayoutComponent', () => {
expect(compiled.querySelector('.fa-linkedin-in')).not.toBeNull(); expect(compiled.querySelector('.fa-linkedin-in')).not.toBeNull();
}); });
it('renders the primary tickets action when the tenant has the tickets menu', () => {
const router = TestBed.inject(Router);
vi.spyOn(router, 'navigate');
const configuredTicketsLabel = 'Entradas digitales';
tenantState.set({
...tenant,
menues: tenant.menues?.map((menu) =>
menu.code === 'account'
? {
...menu,
submenues: menu.submenues.map((submenu) =>
submenu.code === 'account.tickets'
? { ...submenu, label: configuredTicketsLabel }
: submenu,
),
}
: menu,
),
});
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
const ticketsAction = (fixture.nativeElement as HTMLElement).querySelector<HTMLElement>(
'app-button[data-testid="store-header-tickets"]',
);
const ticketsButton = ticketsAction?.querySelector('button');
expect(ticketsAction).not.toBeNull();
expect(ticketsButton).not.toBeNull();
expect(ticketsButton?.textContent?.trim()).toBe(configuredTicketsLabel);
expect(ticketsButton?.classList).toContain('btn-primary');
ticketsButton?.click();
expect(router.navigate).toHaveBeenCalledWith(['/mi-cuenta/tickets']);
});
it('does not render the tickets action when the tenant lacks the tickets menu', () => {
tenantState.set({
...tenant,
menues: tenant.menues?.map((menu) =>
menu.code === 'account'
? {
...menu,
submenues: menu.submenues.filter(
(submenu) => submenu.code !== 'account.tickets',
),
}
: menu,
),
});
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
expect(
(fixture.nativeElement as HTMLElement).querySelector(
'[data-testid="store-header-tickets"]',
),
).toBeNull();
});
it('redirects to /login when user icon is clicked and user is not logged in', () => { it('redirects to /login when user icon is clicked and user is not logged in', () => {
const authService = TestBed.inject(AuthService); const authService = TestBed.inject(AuthService);
const router = TestBed.inject(Router); const router = TestBed.inject(Router);
@@ -176,7 +290,9 @@ describe('StoreLayoutComponent', () => {
fixture.detectChanges(); fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement; const compiled = fixture.nativeElement as HTMLElement;
const userButton = compiled.querySelector('app-icon-button[variant="user"] button') as HTMLButtonElement; const userButton = compiled.querySelector(
'app-icon-button[variant="user"] button',
) as HTMLButtonElement;
expect(userButton).not.toBeNull(); expect(userButton).not.toBeNull();
userButton.click(); userButton.click();
fixture.detectChanges(); fixture.detectChanges();
@@ -194,20 +310,27 @@ 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('app-icon-button[variant="user"] button') as HTMLButtonElement; const userButton = compiled.querySelector(
'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();
}); });
@@ -220,18 +343,22 @@ 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('app-icon-button[variant="user"] button') as HTMLButtonElement; const userButton = compiled.querySelector(
'app-icon-button[variant="user"] button',
) as HTMLButtonElement;
userButton.click(); userButton.click();
fixture.detectChanges(); fixture.detectChanges();
const logoutButton = compiled.querySelector('[data-testid="user-dropdown-logout"]') as HTMLButtonElement; const logoutButton = compiled.querySelector(
'[data-testid="user-dropdown-logout"]',
) as HTMLButtonElement;
expect(logoutButton).not.toBeNull(); expect(logoutButton).not.toBeNull();
logoutButton.click(); logoutButton.click();
fixture.detectChanges(); fixture.detectChanges();
@@ -252,14 +379,16 @@ describe('StoreLayoutComponent', () => {
const footerLinks = Array.from( const footerLinks = Array.from(
compiled.querySelectorAll<HTMLAnchorElement>('app-store-footer a.store-layout__footer-link'), compiled.querySelectorAll<HTMLAnchorElement>('app-store-footer a.store-layout__footer-link'),
); );
const accountLink = footerLinks.find((link) => link.textContent?.trim() === 'Mi cuenta'); const profileLink = footerLinks.find((link) => link.textContent?.trim() === 'Mi cuenta');
const purchasesLink = footerLinks.find((link) => link.textContent?.trim() === 'Mis compras'); const purchasesLink = footerLinks.find((link) => link.textContent?.trim() === 'Mis compras');
const ticketsLink = footerLinks.find((link) => link.textContent?.trim() === 'Mis tickets');
const logoutButton = Array.from( const logoutButton = Array.from(
compiled.querySelectorAll<HTMLButtonElement>('app-store-footer button'), compiled.querySelectorAll<HTMLButtonElement>('app-store-footer button'),
).find((button) => button.textContent?.trim() === 'Cerrar sesion'); ).find((button) => button.textContent?.trim() === 'Cerrar sesion');
expect(accountLink?.getAttribute('href')).toBe('/mi-cuenta/datos-personales'); expect(profileLink?.getAttribute('href')).toBe('/mi-cuenta/datos-personales');
expect(purchasesLink?.getAttribute('href')).toBe('/mi-cuenta/compras'); expect(purchasesLink?.getAttribute('href')).toBe('/mi-cuenta/compras');
expect(ticketsLink).toBeUndefined();
expect(logoutButton).toBeDefined(); expect(logoutButton).toBeDefined();
logoutButton!.click(); logoutButton!.click();
@@ -275,11 +404,11 @@ describe('StoreLayoutComponent', () => {
const compiled = fixture.nativeElement as HTMLElement; const compiled = fixture.nativeElement as HTMLElement;
const footerLinks = Array.from( const footerLinks = Array.from(
compiled.querySelectorAll<HTMLAnchorElement>( compiled.querySelectorAll<HTMLAnchorElement>('app-store-footer a.store-layout__footer-link'),
'app-store-footer a.store-layout__footer-link', );
), const helpLinks = footerLinks.filter((link) =>
link.getAttribute('href')?.startsWith('/ayuda/'),
); );
const helpLinks = footerLinks.filter((link) => link.getAttribute('href')?.startsWith('/ayuda/'));
expect(helpLinks.map((link) => link.textContent?.trim())).toEqual([ expect(helpLinks.map((link) => link.textContent?.trim())).toEqual([
'Contacto', 'Contacto',
@@ -295,6 +424,25 @@ describe('StoreLayoutComponent', () => {
it('redirects to /checkout when cart buy button is clicked', () => { it('redirects to /checkout when cart buy button is clicked', () => {
const router = TestBed.inject(Router); const router = TestBed.inject(Router);
vi.spyOn(router, 'navigate'); vi.spyOn(router, 'navigate');
cartState.set({
id: 1,
tenant_codigo: tenant.codigo,
status: 'active',
subtotal: '100.00',
items: [
{
id: 1,
cantidad: 1,
precio_unitario: '100.00',
catalog_item_id: 1,
variant_id: null,
product: {
nombre: 'Producto',
imagen: null,
},
},
],
});
const fixture = TestBed.createComponent(StoreLayoutComponent); const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges(); fixture.detectChanges();
@@ -303,8 +451,9 @@ describe('StoreLayoutComponent', () => {
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')) const buyButton = Array.from(compiled.querySelectorAll('app-button button')).find(
.find((button) => button.textContent?.trim() === 'Comprar') as HTMLButtonElement | undefined; (button) => button.textContent?.trim() === 'Comprar',
) as HTMLButtonElement | undefined;
expect(buyButton).toBeDefined(); expect(buyButton).toBeDefined();
buyButton!.click(); buyButton!.click();

View File

@@ -8,7 +8,7 @@ import { CartComponent, CartItemMock } from '../../../shared/components/cart/car
import { ButtonComponent } from '../../../shared/components/button/button.component'; import { ButtonComponent } from '../../../shared/components/button/button.component';
import { CartItem } from '../../services/cart/cart.interface'; import { CartItem } from '../../services/cart/cart.interface';
import { AuthService } from '../../services/auth/auth.service'; import { AuthService } from '../../services/auth/auth.service';
import { findMenu, getMenuLabel } from '../../services/menu.utils'; import { findMenu } from '../../services/menu.utils';
@Component({ @Component({
selector: 'app-store-layout', selector: 'app-store-layout',
@@ -79,6 +79,24 @@ export class StoreLayoutComponent implements OnInit {
protected readonly tenant = this.tenantService.tenant; protected readonly tenant = this.tenantService.tenant;
protected readonly user = this.authService.user; protected readonly user = this.authService.user;
protected readonly isAuthenticated = this.authService.isAuthenticated; protected readonly isAuthenticated = this.authService.isAuthenticated;
protected readonly ticketsMenu = computed(() =>
findMenu(this.tenant()?.menues ?? [], 'account.tickets'),
);
protected readonly accountNavigationMenus = computed(() => {
const accountMenu = findMenu(this.tenant()?.menues ?? [], 'account');
if (!accountMenu) {
return [];
}
const profile = accountMenu.submenues.find((menu) => menu.code === 'account.profile');
const purchases = accountMenu.submenues.find((menu) => menu.code === 'account.purchases');
return [
...(profile ? [{ ...accountMenu, route: profile.route }] : []),
...(purchases ? [purchases] : []),
];
});
protected readonly cartQuantity = computed(() => { protected readonly cartQuantity = computed(() => {
const cart = this.cartService.cart(); const cart = this.cartService.cart();
@@ -96,6 +114,14 @@ export class StoreLayoutComponent implements OnInit {
void this.router.navigate(['/login']); void this.router.navigate(['/login']);
} }
protected onTicketsClick(): void {
const route = this.ticketsMenu()?.route;
if (route) {
void this.router.navigate([route]);
}
}
protected onLogoutClick(): void { protected onLogoutClick(): void {
this.authService.logout().subscribe({ this.authService.logout().subscribe({
next: () => void this.router.navigate(['/login']), next: () => void this.router.navigate(['/login']),
@@ -109,22 +135,23 @@ export class StoreLayoutComponent implements OnInit {
} }
protected readonly footerSections = computed<StoreFooterSection[]>(() => { protected readonly footerSections = computed<StoreFooterSection[]>(() => {
const helpSubmenues = const helpSubmenues = findMenu(this.tenant()?.menues ?? [], 'help')?.submenues ?? [];
findMenu(this.tenant()?.menues ?? [], 'help')?.submenues ?? [];
return [ return [
{ {
heading: 'Cuenta', heading: 'Cuenta',
links: [ links: [
{ label: 'Mi cuenta', routerLink: '/mi-cuenta/datos-personales' }, ...this.accountNavigationMenus().map((menu) => ({
{ label: 'Mis compras', routerLink: '/mi-cuenta/compras' }, label: menu.label,
routerLink: menu.route,
})),
{ label: 'Cerrar sesion', action: 'logout' }, { label: 'Cerrar sesion', action: 'logout' },
], ],
}, },
{ {
heading: 'Ayuda', heading: 'Ayuda',
links: helpSubmenues.map((menu) => ({ links: helpSubmenues.map((menu) => ({
label: getMenuLabel(menu), label: menu.label,
routerLink: menu.route, routerLink: menu.route,
})), })),
}, },

View File

@@ -1,13 +1,5 @@
import { Menu } from './tenant.interface'; import { Menu } from './tenant.interface';
const MENU_LABELS: Record<string, string> = {
'help.faq': 'Preguntas frecuentes',
'help.contact': 'Contacto',
'help.payment-methods': 'Medios de pago',
'help.shipping': 'Envíos',
'help.terms-and-conditions': 'Términos y condiciones',
};
export function findMenu(menues: readonly Menu[], menuCode: string): Menu | undefined { export function findMenu(menues: readonly Menu[], menuCode: string): Menu | undefined {
for (const menu of menues) { for (const menu of menues) {
if (menu.code === menuCode) { if (menu.code === menuCode) {
@@ -23,15 +15,3 @@ export function findMenu(menues: readonly Menu[], menuCode: string): Menu | unde
return undefined; return undefined;
} }
export function getMenuLabel(menu: Menu): string {
const configuredLabel = MENU_LABELS[menu.code];
if (configuredLabel) {
return configuredLabel;
}
const label = menu.code.split('.').at(-1)?.replaceAll('-', ' ') ?? menu.code;
return label.charAt(0).toUpperCase() + label.slice(1);
}

View File

@@ -26,6 +26,7 @@ export interface EventConfig {
export interface Menu { export interface Menu {
id: number; id: number;
code: string; code: string;
label: string;
parent_menu_code: string | null; parent_menu_code: string | null;
content_type: 'static' | 'dynamic'; content_type: 'static' | 'dynamic';
route: string; route: string;
@@ -63,4 +64,3 @@ export interface Tenant {
} }
export type TenantBootstrapResponse = ApiResponse<Tenant>; export type TenantBootstrapResponse = ApiResponse<Tenant>;

View File

@@ -398,8 +398,8 @@
<div class="accordion-showcase"> <div class="accordion-showcase">
<h2>Accordion</h2> <h2>Accordion</h2>
<p class="text-muted mb-4"> <p class="text-muted mb-4">
Accordion reutilizable basado en las clases globales de Bootstrap. Por defecto mantiene Accordion reutilizable basado en las clases globales de Bootstrap. Por defecto mantiene un
un solo item abierto. solo item abierto.
</p> </p>
<app-accordion> <app-accordion>
@@ -409,8 +409,8 @@
</app-accordion-item> </app-accordion-item>
<app-accordion-item title="¿Qué medios de pago están disponibles?"> <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 Los medios de pago dependen de la configuración de cada tienda y se muestran durante el
el checkout. checkout.
</app-accordion-item> </app-accordion-item>
<app-accordion-item title="Item deshabilitado" [disabled]="true"> <app-accordion-item title="Item deshabilitado" [disabled]="true">
@@ -692,6 +692,23 @@
</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>

View File

@@ -141,6 +141,40 @@ describe('ReutilizablesTestPageComponent', () => {
); );
}); });
it('renders the reusable map demo with all configured locations', async () => {
await TestBed.configureTestingModule({
imports: [ReutilizablesTestPageComponent],
providers: [
{
provide: TenantService,
useValue: createTenantServiceStub(tenant),
},
{
provide: ToastService,
useValue: createToastServiceStub(),
},
{
provide: ModalService,
useValue: createModalServiceStub(),
},
],
}).compileComponents();
const fixture = TestBed.createComponent(ReutilizablesTestPageComponent);
fixture.detectChanges();
const mapDemo = fixture.nativeElement.querySelector(
'[data-testid="location-map-demo"]',
) as HTMLElement;
const accessibleLocations = mapDemo.querySelectorAll('app-location-map .visually-hidden li');
expect(mapDemo.querySelector('app-location-map')).not.toBeNull();
expect(accessibleLocations).toHaveLength(3);
expect(mapDemo.textContent).toContain('Gigante de Arroyito');
fixture.destroy();
});
it('renders the carousel demo with mocked product images', async () => { it('renders the carousel demo with mocked product images', async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [ReutilizablesTestPageComponent], imports: [ReutilizablesTestPageComponent],

View File

@@ -18,6 +18,10 @@ import { CarouselComponent } from '../../../../shared/components/carousel/carous
import { MainCarouselComponent } from '../../../../shared/components/main-carousel/main-carousel.component'; import { MainCarouselComponent } from '../../../../shared/components/main-carousel/main-carousel.component';
import { AccordionComponent } from '../../../../shared/components/accordion/accordion.component'; import { AccordionComponent } from '../../../../shared/components/accordion/accordion.component';
import { AccordionItemComponent } from '../../../../shared/components/accordion/accordion-item.component'; import { AccordionItemComponent } from '../../../../shared/components/accordion/accordion-item.component';
import {
LocationMapComponent,
MapLocation,
} from '../../../../shared/components/location-map/location-map.component';
interface CarouselProductMock { interface CarouselProductMock {
id: number; id: number;
@@ -47,6 +51,7 @@ interface CarouselProductMock {
MainCarouselComponent, MainCarouselComponent,
AccordionComponent, AccordionComponent,
AccordionItemComponent, 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',
@@ -79,6 +84,29 @@ 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 = [ protected readonly mainCarouselImages = [
'https://images.unsplash.com/photo-1441986300917-64674bd600d8?auto=format&fit=crop&w=1600&q=80', '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-1529139574466-a303027c1d8b?auto=format&fit=crop&w=1600&q=80',

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,12 +2,12 @@
<h1 class="tickets-page__title">MIS TICKETS</h1> <h1 class="tickets-page__title">MIS TICKETS</h1>
<div class="tickets-list" aria-busy="{{ isLoading() }}"> <div class="tickets-list" aria-busy="{{ isLoading() }}">
@if (activeTickets().length) {
<header class="tickets-list__header"> <header class="tickets-list__header">
<label class="tickets-list__select-all"> <label class="tickets-list__select-all">
<input <input
type="checkbox" type="checkbox"
[checked]="allSelected()" [checked]="allSelected()"
[disabled]="activeTickets().length === 0"
(change)="toggleAll($any($event.target).checked)" (change)="toggleAll($any($event.target).checked)"
/> />
<span>TICKETS ACTIVOS</span> <span>TICKETS ACTIVOS</span>
@@ -24,6 +24,7 @@
</span> </span>
</div> </div>
</header> </header>
}
@if (isLoading()) { @if (isLoading()) {
@for (item of [1, 2, 3, 4]; track item) { @for (item of [1, 2, 3, 4]; track item) {

View File

@@ -26,6 +26,33 @@ const ticket = (overrides: Partial<TicketResponse>): TicketResponse => ({
}); });
describe('TicketsPage', () => { describe('TicketsPage', () => {
it('hides the active tickets header when there are no available tickets', async () => {
await TestBed.configureTestingModule({
imports: [TicketsPage],
providers: [{ provide: ToastService, useValue: { danger: vi.fn() } }],
})
.overrideComponent(TicketsPage, {
set: {
providers: [
{ provide: TicketService, useValue: { getTickets: () => Promise.resolve([]) } },
],
},
})
.compileComponents();
const fixture = TestBed.createComponent(TicketsPage);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const page = fixture.nativeElement as HTMLElement;
expect(page.querySelector('.tickets-list__select-all')).toBeNull();
expect(page.querySelector('.tickets-list__bulk-actions')).toBeNull();
expect(page.textContent).not.toContain('TICKETS ACTIVOS');
expect(page.textContent).toContain('Aún no tenés tickets disponibles.');
});
it('separates expired and used tickets into the disabled inactive list', async () => { it('separates expired and used tickets into the disabled inactive list', async () => {
const tickets = [ const tickets = [
ticket({ id: 1 }), ticket({ id: 1 }),

View File

@@ -1,5 +1,5 @@
<aside class="help-sidebar"> <aside class="help-sidebar">
<h1 class="help-sidebar__title">AYUDA</h1> <h1 class="help-sidebar__title">{{ menu()?.label }}</h1>
<div class="help-sidebar__divider"></div> <div class="help-sidebar__divider"></div>
<nav class="help-sidebar__nav" aria-label="Secciones de ayuda"> <nav class="help-sidebar__nav" aria-label="Secciones de ayuda">
@@ -9,7 +9,7 @@
[routerLink]="menu.route" [routerLink]="menu.route"
routerLinkActive="help-sidebar__link--active" routerLinkActive="help-sidebar__link--active"
> >
{{ menuLabel(menu) }} {{ menu.label }}
</a> </a>
} }
</nav> </nav>

View File

@@ -9,6 +9,7 @@
font-size: 1rem; font-size: 1rem;
font-weight: 700; font-weight: 700;
line-height: 1.25; line-height: 1.25;
text-transform: uppercase;
} }
.help-sidebar__divider { .help-sidebar__divider {

View File

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

View File

@@ -1,8 +1,7 @@
import { Component, computed, inject } from '@angular/core'; import { Component, computed, inject } from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router'; import { RouterLink, RouterLinkActive } from '@angular/router';
import { findMenu, getMenuLabel } from '../../../../../../core/services/menu.utils'; import { findMenu } from '../../../../../../core/services/menu.utils';
import { Menu } from '../../../../../../core/services/tenant.interface';
import { TenantService } from '../../../../../../core/services/tenant.service'; import { TenantService } from '../../../../../../core/services/tenant.service';
@Component({ @Component({
@@ -14,11 +13,8 @@ import { TenantService } from '../../../../../../core/services/tenant.service';
export class HelpSidebar { export class HelpSidebar {
private readonly tenantService = inject(TenantService); private readonly tenantService = inject(TenantService);
protected readonly submenues = computed( protected readonly menu = computed(() =>
() => findMenu(this.tenantService.tenant()?.menues ?? [], 'help')?.submenues ?? [], findMenu(this.tenantService.tenant()?.menues ?? [], 'help'),
); );
protected readonly submenues = computed(() => this.menu()?.submenues ?? []);
protected menuLabel(menu: Menu): string {
return getMenuLabel(menu);
}
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -22,6 +22,7 @@ const tenant: Tenant = {
{ {
id: 1, id: 1,
code: 'help', code: 'help',
label: 'Ayuda',
parent_menu_code: null, parent_menu_code: null,
content_type: 'dynamic', content_type: 'dynamic',
route: '/ayuda', route: '/ayuda',
@@ -29,6 +30,7 @@ const tenant: Tenant = {
{ {
id: 2, id: 2,
code: 'help.faq', code: 'help.faq',
label: 'Preguntas frecuentes',
parent_menu_code: 'help', parent_menu_code: 'help',
content_type: 'static', content_type: 'static',
route: '/ayuda/preguntas-frecuentes', route: '/ayuda/preguntas-frecuentes',

View File

@@ -89,8 +89,14 @@ export const routes: Routes = [
path: 'preguntas-frecuentes', path: 'preguntas-frecuentes',
canActivate: [hasMenuGuard('help.faq')], canActivate: [hasMenuGuard('help.faq')],
loadComponent: () => loadComponent: () =>
import('./pages/help-page/pages/faq-page/faq-page').then( import('./pages/help-page/pages/faq-page/faq-page').then((m) => m.FaqPage),
(m) => m.FaqPage, },
{
path: 'contacto',
canActivate: [hasMenuGuard('help.contact')],
loadComponent: () =>
import('./pages/help-page/pages/contact-page/contact-page').then(
(m) => m.ContactPage,
), ),
}, },
{ {
@@ -109,15 +115,13 @@ export const routes: Routes = [
}, },
{ {
path: 'mi-cuenta', path: 'mi-cuenta',
canActivate: [authGuard], canActivate: [authGuard, hasMenuGuard('account')],
loadComponent: () => loadComponent: () =>
import('./pages/account-page/sidebar-layout/sidebar-layout').then( import('./pages/account-page/sidebar-layout/sidebar-layout').then((m) => m.SidebarLayout),
(m) => m.SidebarLayout,
),
children: [ children: [
{ {
path: 'datos-personales', path: 'datos-personales',
canActivate: [hasMenuGuard('profile')], canActivate: [hasMenuGuard('account.profile')],
loadComponent: () => loadComponent: () =>
import('./pages/account-page/pages/profile-page/profile-page').then( import('./pages/account-page/pages/profile-page/profile-page').then(
(m) => m.ProfilePage, (m) => m.ProfilePage,
@@ -125,7 +129,7 @@ export const routes: Routes = [
}, },
{ {
path: 'compras', path: 'compras',
canActivate: [hasMenuGuard('purchases')], canActivate: [hasMenuGuard('account.purchases')],
loadComponent: () => loadComponent: () =>
import('./pages/account-page/pages/purchases-page/purchases-page').then( import('./pages/account-page/pages/purchases-page/purchases-page').then(
(m) => m.PurchasesPage, (m) => m.PurchasesPage,
@@ -140,7 +144,7 @@ export const routes: Routes = [
}, },
{ {
path: 'tickets', path: 'tickets',
canActivate: [hasMenuGuard('tickets')], canActivate: [hasMenuGuard('account.tickets')],
loadComponent: () => loadComponent: () =>
import('./pages/account-page/pages/tickets-page/tickets-page').then( import('./pages/account-page/pages/tickets-page/tickets-page').then(
(m) => m.TicketsPage, (m) => m.TicketsPage,

View File

@@ -0,0 +1,15 @@
<div #mapContainer class="location-map" role="region" [attr.aria-label]="ariaLabel"></div>
<ul class="visually-hidden">
@for (location of locations; track location.id) {
<li>
{{ location.label }}
@if (location.address) {
— {{ location.address }}
}
<a [href]="directionsUrl(location)" target="_blank" rel="noopener noreferrer">
Cómo llegar
</a>
</li>
}
</ul>

View File

@@ -0,0 +1,32 @@
:host {
display: block;
width: 100%;
}
.location-map {
width: 100%;
min-height: 22rem;
overflow: hidden;
background: #e9ecef;
border-radius: 0.75rem;
}
:host ::ng-deep .leaflet-popup-content {
display: grid;
gap: 0.2rem;
margin: 0.85rem 1rem;
color: #343a40;
line-height: 1.4;
a {
margin-top: 0.25rem;
color: var(--color-primary, #0d6efd);
font-weight: 600;
}
}
@media (max-width: 575.98px) {
.location-map {
min-height: 18rem;
}
}

View File

@@ -0,0 +1,217 @@
import { DOCUMENT, isPlatformBrowser } from '@angular/common';
import {
AfterViewInit,
Component,
ElementRef,
Inject,
Input,
OnChanges,
OnDestroy,
PLATFORM_ID,
SimpleChanges,
ViewChild,
} from '@angular/core';
import type { LayerGroup, Map as LeafletMap, LatLngBounds, LatLngTuple, TileLayer } from 'leaflet';
export type MapStyle = 'minimal-light' | 'standard';
const MAP_LAYERS: Record<
MapStyle,
{
url: string;
attribution: string;
maxZoom: number;
}
> = {
'minimal-light': {
url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>',
maxZoom: 20,
},
standard: {
url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 19,
},
};
export interface MapLocation {
id: string | number;
label: string;
latitude: number;
longitude: number;
address?: string;
}
@Component({
selector: 'app-location-map',
templateUrl: './location-map.component.html',
styleUrl: './location-map.component.scss',
})
export class LocationMapComponent implements AfterViewInit, OnChanges, OnDestroy {
@Input({ required: true }) locations: readonly MapLocation[] = [];
@Input() ariaLabel = 'Mapa de ubicaciones';
@Input() fallbackCenter: LatLngTuple = [-32.94682, -60.63932];
@Input() fallbackZoom = 13;
@Input() maxFitZoom = 15;
@Input() mapStyle: MapStyle = 'minimal-light';
@Input() markerColor = '#24a9e0';
@ViewChild('mapContainer', { static: true })
private readonly mapContainer!: ElementRef<HTMLDivElement>;
private leaflet: typeof import('leaflet') | null = null;
private map: LeafletMap | null = null;
private baseLayer: TileLayer | null = null;
private markerLayer: LayerGroup | null = null;
constructor(
@Inject(PLATFORM_ID) private readonly platformId: object,
@Inject(DOCUMENT) private readonly document: Document,
) {}
ngAfterViewInit(): void {
if (isPlatformBrowser(this.platformId)) {
void this.initializeMap();
}
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['mapStyle'] && this.map) {
this.renderBaseLayer();
}
if ((changes['locations'] || changes['markerColor']) && this.map) {
this.renderLocations();
}
}
ngOnDestroy(): void {
this.map?.remove();
this.map = null;
this.baseLayer = null;
this.markerLayer = null;
}
protected directionsUrl(location: MapLocation): string {
const destination = `${location.latitude},${location.longitude}`;
return `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(destination)}`;
}
private async initializeMap(): Promise<void> {
const leafletModule = await import('leaflet');
const leaflet =
(
leafletModule as unknown as {
default?: typeof import('leaflet');
}
).default ?? leafletModule;
if (!this.mapContainer.nativeElement.isConnected || this.map) {
return;
}
this.leaflet = leaflet;
this.map = leaflet.map(this.mapContainer.nativeElement, {
center: this.fallbackCenter,
zoom: this.fallbackZoom,
scrollWheelZoom: false,
});
this.renderBaseLayer();
this.markerLayer = leaflet.layerGroup().addTo(this.map);
this.renderLocations();
}
private renderBaseLayer(): void {
if (!this.leaflet || !this.map) {
return;
}
if (this.baseLayer) {
this.map.removeLayer(this.baseLayer);
}
const layer = MAP_LAYERS[this.mapStyle];
this.baseLayer = this.leaflet
.tileLayer(layer.url, {
maxZoom: layer.maxZoom,
attribution: layer.attribution,
crossOrigin: true,
})
.addTo(this.map);
}
private renderLocations(): void {
if (!this.leaflet || !this.map || !this.markerLayer) {
return;
}
this.markerLayer.clearLayers();
const validLocations = this.locations.filter(
({ latitude, longitude }) =>
Number.isFinite(latitude) &&
Number.isFinite(longitude) &&
latitude >= -90 &&
latitude <= 90 &&
longitude >= -180 &&
longitude <= 180,
);
if (validLocations.length === 0) {
this.map.setView(this.fallbackCenter, this.fallbackZoom);
return;
}
const bounds: LatLngBounds = this.leaflet.latLngBounds([]);
for (const location of validLocations) {
const coordinates: LatLngTuple = [location.latitude, location.longitude];
bounds.extend(coordinates);
const popup = this.document.createElement('div');
const title = this.document.createElement('strong');
title.textContent = location.label;
popup.append(title);
if (location.address) {
const address = this.document.createElement('div');
address.textContent = location.address;
popup.append(address);
}
const directions = this.document.createElement('a');
directions.href = this.directionsUrl(location);
directions.target = '_blank';
directions.rel = 'noopener noreferrer';
directions.textContent = 'Cómo llegar';
popup.append(directions);
const tooltip = this.document.createElement('span');
tooltip.textContent = location.label;
this.leaflet
.circleMarker(coordinates, {
radius: 8,
color: '#ffffff',
weight: 3,
fillColor: this.markerColor,
fillOpacity: 1,
})
.bindTooltip(tooltip)
.bindPopup(popup)
.addTo(this.markerLayer);
}
if (validLocations.length === 1) {
this.map.setView(bounds.getCenter(), this.maxFitZoom);
} else {
this.map.fitBounds(bounds, {
padding: [32, 32],
maxZoom: this.maxFitZoom,
});
}
}
}