From 974f947fc65e63fbdac2f97a7f8e0a2f2df532dc Mon Sep 17 00:00:00 2001 From: ncoronel Date: Fri, 24 Jul 2026 09:58:18 -0300 Subject: [PATCH] feat(account-page): implement dynamic account navigation menus in sidebar and user dropdown --- .../store-header/store-header.component.html | 14 ++- .../store-header/store-header.component.ts | 4 +- .../user-dropdown.component.html | 16 ++- .../user-dropdown/user-dropdown.component.ts | 9 +- .../store-layout/store-layout.component.html | 9 +- .../store-layout.component.spec.ts | 107 +++++++++++++----- .../store-layout/store-layout.component.ts | 16 ++- src/app/core/services/menu.utils.ts | 8 ++ .../account-sidebar/account-sidebar.html | 12 +- .../account-sidebar/account-sidebar.spec.ts | 80 +++++++++++++ .../account-sidebar/account-sidebar.ts | 18 ++- src/app/features/store/store.routes.ts | 10 +- 12 files changed, 243 insertions(+), 60 deletions(-) create mode 100644 src/app/features/store/pages/account-page/components/account-sidebar/account-sidebar.spec.ts diff --git a/src/app/core/layout/store-layout/store-header/store-header.component.html b/src/app/core/layout/store-layout/store-header/store-header.component.html index 325f257..3e36778 100644 --- a/src/app/core/layout/store-layout/store-header/store-header.component.html +++ b/src/app/core/layout/store-layout/store-header/store-header.component.html @@ -1,6 +1,8 @@
-
+
-
+
-
+
} diff --git a/src/app/core/layout/store-layout/store-header/store-header.component.ts b/src/app/core/layout/store-layout/store-header/store-header.component.ts index e58eca6..2209f62 100644 --- a/src/app/core/layout/store-layout/store-header/store-header.component.ts +++ b/src/app/core/layout/store-layout/store-header/store-header.component.ts @@ -4,12 +4,13 @@ import { AuthUser } from '../../../services/auth/auth.interfaces'; import { CartIconComponent } from '../../../../shared/components/cart-icon/cart-icon.component'; import { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.component'; import { UserDropdownComponent } from './user-dropdown/user-dropdown.component'; +import { Menu } from '../../../services/tenant.interface'; @Component({ selector: 'app-store-header', imports: [CartIconComponent, IconButtonComponent, RouterLink, UserDropdownComponent], templateUrl: './store-header.component.html', - styleUrl: './store-header.component.scss' + styleUrl: './store-header.component.scss', }) export class StoreHeaderComponent { private readonly elementRef = inject(ElementRef); @@ -18,6 +19,7 @@ export class StoreHeaderComponent { readonly cartQuantity = input(0); readonly user = input(null); readonly isAuthenticated = input(false); + readonly accountNavigationMenus = input([]); readonly cartClick = output(); readonly loginClick = output(); readonly logoutClick = output(); diff --git a/src/app/core/layout/store-layout/store-header/user-dropdown/user-dropdown.component.html b/src/app/core/layout/store-layout/store-header/user-dropdown/user-dropdown.component.html index 575be6b..5e51ba6 100644 --- a/src/app/core/layout/store-layout/store-header/user-dropdown/user-dropdown.component.html +++ b/src/app/core/layout/store-layout/store-header/user-dropdown/user-dropdown.component.html @@ -5,12 +5,16 @@
-
- Mi cuenta - - - Mis compras - + @for (menu of accountNavigationMenus(); track menu.code) { + + {{ menuLabel(menu) }} + + }
} diff --git a/src/app/core/layout/store-layout/store-layout.component.spec.ts b/src/app/core/layout/store-layout/store-layout.component.spec.ts index 7146ccd..0c59b83 100644 --- a/src/app/core/layout/store-layout/store-layout.component.spec.ts +++ b/src/app/core/layout/store-layout/store-layout.component.spec.ts @@ -27,6 +27,39 @@ const tenant: Tenant = { header_logo: 'https://example.com/header.png', footer_logo: 'https://example.com/footer.png', menues: [ + { + id: 4, + code: 'account', + parent_menu_code: null, + content_type: 'dynamic', + route: '/mi-cuenta', + submenues: [ + { + id: 5, + code: 'profile', + parent_menu_code: 'account', + content_type: 'dynamic', + route: '/mi-cuenta/datos-personales', + submenues: [], + }, + { + id: 6, + code: 'purchases', + parent_menu_code: 'account', + content_type: 'dynamic', + route: '/mi-cuenta/compras', + submenues: [], + }, + { + id: 7, + code: 'tickets', + parent_menu_code: 'account', + content_type: 'dynamic', + route: '/mi-cuenta/tickets', + submenues: [], + }, + ], + }, { id: 1, code: 'help', @@ -97,17 +130,19 @@ describe('StoreLayoutComponent', () => { tenant: tenantState.asReadonly(), status: signal<'ready'>('ready').asReadonly(), getTenant: () => tenantState(), - bootstrap: vi.fn().mockResolvedValue(undefined) - } + bootstrap: vi.fn().mockResolvedValue(undefined), + }, }, { provide: CartService, useValue: { cart: signal(null).asReadonly(), loadCart: () => of({ id: 1, items: [], subtotal: '0' }), - updateItemQuantity: vi.fn().mockReturnValue(of({ data: { id: 1, items: [], subtotal: '0' } })), - removeItem: vi.fn().mockReturnValue(of({ data: { id: 1, items: [], subtotal: '0' } })) - } + updateItemQuantity: vi + .fn() + .mockReturnValue(of({ data: { id: 1, items: [], subtotal: '0' } })), + removeItem: vi.fn().mockReturnValue(of({ data: { id: 1, items: [], subtotal: '0' } })), + }, }, { provide: ToastService, @@ -116,18 +151,18 @@ describe('StoreLayoutComponent', () => { info: vi.fn(), danger: vi.fn(), success: vi.fn(), - dismiss: vi.fn() - } + dismiss: vi.fn(), + }, }, { provide: AuthService, useValue: { user: authUserState, isAuthenticated: isAuthenticatedState, - logout: vi.fn().mockReturnValue(of(void 0)) - } - } - ] + logout: vi.fn().mockReturnValue(of(void 0)), + }, + }, + ], }).compileComponents(); }); @@ -152,10 +187,14 @@ describe('StoreLayoutComponent', () => { expect(compiled.querySelector('[data-testid="store-header-brand-slot"]')).not.toBeNull(); expect(compiled.querySelector('[data-testid="store-footer-brand-slot"]')).not.toBeNull(); expect( - compiled.querySelector(`app-store-header img.store-layout__brand-logo[src="${tenant.header_logo}"]`) + compiled.querySelector( + `app-store-header img.store-layout__brand-logo[src="${tenant.header_logo}"]`, + ), ).not.toBeNull(); expect( - compiled.querySelector(`app-store-footer img.store-layout__brand-logo[src="${tenant.footer_logo}"]`) + compiled.querySelector( + `app-store-footer img.store-layout__brand-logo[src="${tenant.footer_logo}"]`, + ), ).not.toBeNull(); expect(compiled.querySelector('.fa-cart-shopping')).not.toBeNull(); expect(compiled.querySelector('.fa-circle-user')).not.toBeNull(); @@ -176,7 +215,9 @@ describe('StoreLayoutComponent', () => { fixture.detectChanges(); const compiled = fixture.nativeElement as HTMLElement; - const userButton = compiled.querySelector('app-icon-button[variant="user"] button') as HTMLButtonElement; + const userButton = compiled.querySelector( + 'app-icon-button[variant="user"] button', + ) as HTMLButtonElement; expect(userButton).not.toBeNull(); userButton.click(); fixture.detectChanges(); @@ -194,20 +235,25 @@ describe('StoreLayoutComponent', () => { (authService.user as any).set({ id: 1, nombre_apellido: 'Ada Lovelace', - email: 'ada@example.com' + email: 'ada@example.com', }); const fixture = TestBed.createComponent(StoreLayoutComponent); fixture.detectChanges(); const compiled = fixture.nativeElement as HTMLElement; - const userButton = compiled.querySelector('app-icon-button[variant="user"] button') as HTMLButtonElement; + const userButton = compiled.querySelector( + 'app-icon-button[variant="user"] button', + ) as HTMLButtonElement; expect(userButton).not.toBeNull(); userButton.click(); fixture.detectChanges(); expect(compiled.querySelector('app-user-dropdown')).not.toBeNull(); expect(compiled.textContent).toContain('Ada Lovelace'); + expect(compiled.querySelector('[data-testid="user-dropdown-profile"]')).not.toBeNull(); + expect(compiled.querySelector('[data-testid="user-dropdown-purchases"]')).not.toBeNull(); + expect(compiled.querySelector('[data-testid="user-dropdown-tickets"]')).toBeNull(); expect(router.navigate).not.toHaveBeenCalled(); }); @@ -220,18 +266,22 @@ describe('StoreLayoutComponent', () => { (authService.user as any).set({ id: 1, nombre_apellido: 'Ada Lovelace', - email: 'ada@example.com' + email: 'ada@example.com', }); const fixture = TestBed.createComponent(StoreLayoutComponent); fixture.detectChanges(); const compiled = fixture.nativeElement as HTMLElement; - const userButton = compiled.querySelector('app-icon-button[variant="user"] button') as HTMLButtonElement; + const userButton = compiled.querySelector( + 'app-icon-button[variant="user"] button', + ) as HTMLButtonElement; userButton.click(); fixture.detectChanges(); - const logoutButton = compiled.querySelector('[data-testid="user-dropdown-logout"]') as HTMLButtonElement; + const logoutButton = compiled.querySelector( + '[data-testid="user-dropdown-logout"]', + ) as HTMLButtonElement; expect(logoutButton).not.toBeNull(); logoutButton.click(); fixture.detectChanges(); @@ -252,14 +302,16 @@ describe('StoreLayoutComponent', () => { const footerLinks = Array.from( compiled.querySelectorAll('app-store-footer a.store-layout__footer-link'), ); - const accountLink = footerLinks.find((link) => link.textContent?.trim() === 'Mi cuenta'); + const profileLink = footerLinks.find((link) => link.textContent?.trim() === 'Mi cuenta'); const purchasesLink = footerLinks.find((link) => link.textContent?.trim() === 'Mis compras'); + const ticketsLink = footerLinks.find((link) => link.textContent?.trim() === 'Mis tickets'); const logoutButton = Array.from( compiled.querySelectorAll('app-store-footer button'), ).find((button) => button.textContent?.trim() === 'Cerrar sesion'); - expect(accountLink?.getAttribute('href')).toBe('/mi-cuenta/datos-personales'); + expect(profileLink?.getAttribute('href')).toBe('/mi-cuenta/datos-personales'); expect(purchasesLink?.getAttribute('href')).toBe('/mi-cuenta/compras'); + expect(ticketsLink).toBeUndefined(); expect(logoutButton).toBeDefined(); logoutButton!.click(); @@ -275,11 +327,11 @@ describe('StoreLayoutComponent', () => { const compiled = fixture.nativeElement as HTMLElement; const footerLinks = Array.from( - compiled.querySelectorAll( - 'app-store-footer a.store-layout__footer-link', - ), + compiled.querySelectorAll('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([ 'Contacto', @@ -303,8 +355,9 @@ describe('StoreLayoutComponent', () => { fixture.detectChanges(); const compiled = fixture.nativeElement as HTMLElement; - const buyButton = Array.from(compiled.querySelectorAll('app-button button')) - .find((button) => button.textContent?.trim() === 'Comprar') as HTMLButtonElement | undefined; + const buyButton = Array.from(compiled.querySelectorAll('app-button button')).find( + (button) => button.textContent?.trim() === 'Comprar', + ) as HTMLButtonElement | undefined; expect(buyButton).toBeDefined(); buyButton!.click(); diff --git a/src/app/core/layout/store-layout/store-layout.component.ts b/src/app/core/layout/store-layout/store-layout.component.ts index 902a19e..8876649 100644 --- a/src/app/core/layout/store-layout/store-layout.component.ts +++ b/src/app/core/layout/store-layout/store-layout.component.ts @@ -8,7 +8,7 @@ import { CartComponent, CartItemMock } from '../../../shared/components/cart/car import { ButtonComponent } from '../../../shared/components/button/button.component'; import { CartItem } from '../../services/cart/cart.interface'; import { AuthService } from '../../services/auth/auth.service'; -import { findMenu, getMenuLabel } from '../../services/menu.utils'; +import { findMenu, getAccountNavigationLabel, getMenuLabel } from '../../services/menu.utils'; @Component({ selector: 'app-store-layout', @@ -79,6 +79,11 @@ export class StoreLayoutComponent implements OnInit { protected readonly tenant = this.tenantService.tenant; protected readonly user = this.authService.user; protected readonly isAuthenticated = this.authService.isAuthenticated; + protected readonly accountNavigationMenus = computed(() => { + const accountSubmenues = findMenu(this.tenant()?.menues ?? [], 'account')?.submenues ?? []; + + return accountSubmenues.filter((menu) => ['profile', 'purchases'].includes(menu.code)); + }); protected readonly cartQuantity = computed(() => { const cart = this.cartService.cart(); @@ -109,15 +114,16 @@ export class StoreLayoutComponent implements OnInit { } protected readonly footerSections = computed(() => { - const helpSubmenues = - findMenu(this.tenant()?.menues ?? [], 'help')?.submenues ?? []; + const helpSubmenues = findMenu(this.tenant()?.menues ?? [], 'help')?.submenues ?? []; return [ { heading: 'Cuenta', links: [ - { label: 'Mi cuenta', routerLink: '/mi-cuenta/datos-personales' }, - { label: 'Mis compras', routerLink: '/mi-cuenta/compras' }, + ...this.accountNavigationMenus().map((menu) => ({ + label: getAccountNavigationLabel(menu), + routerLink: menu.route, + })), { label: 'Cerrar sesion', action: 'logout' }, ], }, diff --git a/src/app/core/services/menu.utils.ts b/src/app/core/services/menu.utils.ts index 3439acb..de802fd 100644 --- a/src/app/core/services/menu.utils.ts +++ b/src/app/core/services/menu.utils.ts @@ -1,6 +1,10 @@ import { Menu } from './tenant.interface'; const MENU_LABELS: Record = { + account: 'Mi cuenta', + profile: 'Datos personales', + purchases: 'Mis compras', + tickets: 'Mis tickets', 'help.faq': 'Preguntas frecuentes', 'help.contact': 'Contacto', 'help.payment-methods': 'Medios de pago', @@ -35,3 +39,7 @@ export function getMenuLabel(menu: Menu): string { return label.charAt(0).toUpperCase() + label.slice(1); } + +export function getAccountNavigationLabel(menu: Menu): string { + return menu.code === 'profile' ? 'Mi cuenta' : getMenuLabel(menu); +} diff --git a/src/app/features/store/pages/account-page/components/account-sidebar/account-sidebar.html b/src/app/features/store/pages/account-page/components/account-sidebar/account-sidebar.html index bcfe9dd..7f1dbb5 100644 --- a/src/app/features/store/pages/account-page/components/account-sidebar/account-sidebar.html +++ b/src/app/features/store/pages/account-page/components/account-sidebar/account-sidebar.html @@ -1,11 +1,11 @@ diff --git a/src/app/features/store/pages/account-page/components/account-sidebar/account-sidebar.spec.ts b/src/app/features/store/pages/account-page/components/account-sidebar/account-sidebar.spec.ts new file mode 100644 index 0000000..00b6f57 --- /dev/null +++ b/src/app/features/store/pages/account-page/components/account-sidebar/account-sidebar.spec.ts @@ -0,0 +1,80 @@ +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({ + 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', + parent_menu_code: null, + content_type: 'dynamic', + route: '/mi-cuenta', + submenues: [ + { + id: 2, + code: 'profile', + parent_menu_code: 'account', + content_type: 'dynamic', + route: '/mi-cuenta/datos-personales', + submenues: [], + }, + { + id: 3, + code: 'purchases', + 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('.nav-link')); + + 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', + ]); + }); +}); diff --git a/src/app/features/store/pages/account-page/components/account-sidebar/account-sidebar.ts b/src/app/features/store/pages/account-page/components/account-sidebar/account-sidebar.ts index a379049..96e751d 100644 --- a/src/app/features/store/pages/account-page/components/account-sidebar/account-sidebar.ts +++ b/src/app/features/store/pages/account-page/components/account-sidebar/account-sidebar.ts @@ -1,6 +1,10 @@ -import { Component } from '@angular/core'; +import { Component, computed, inject } from '@angular/core'; import { RouterLink, RouterLinkActive } from '@angular/router'; +import { findMenu, getMenuLabel } from '../../../../../../core/services/menu.utils'; +import { Menu } from '../../../../../../core/services/tenant.interface'; +import { TenantService } from '../../../../../../core/services/tenant.service'; + @Component({ selector: 'app-account-sidebar', standalone: true, @@ -8,4 +12,14 @@ import { RouterLink, RouterLinkActive } from '@angular/router'; templateUrl: './account-sidebar.html', styleUrl: './account-sidebar.scss', }) -export class AccountSidebar {} +export class AccountSidebar { + private readonly tenantService = inject(TenantService); + + protected readonly submenues = computed( + () => findMenu(this.tenantService.tenant()?.menues ?? [], 'account')?.submenues ?? [], + ); + + protected menuLabel(menu: Menu): string { + return getMenuLabel(menu); + } +} diff --git a/src/app/features/store/store.routes.ts b/src/app/features/store/store.routes.ts index cd6d13e..0a32995 100644 --- a/src/app/features/store/store.routes.ts +++ b/src/app/features/store/store.routes.ts @@ -89,9 +89,7 @@ export const routes: Routes = [ path: 'preguntas-frecuentes', canActivate: [hasMenuGuard('help.faq')], loadComponent: () => - import('./pages/help-page/pages/faq-page/faq-page').then( - (m) => m.FaqPage, - ), + import('./pages/help-page/pages/faq-page/faq-page').then((m) => m.FaqPage), }, { path: 'contacto', @@ -117,11 +115,9 @@ export const routes: Routes = [ }, { path: 'mi-cuenta', - canActivate: [authGuard], + canActivate: [authGuard, hasMenuGuard('account')], loadComponent: () => - import('./pages/account-page/sidebar-layout/sidebar-layout').then( - (m) => m.SidebarLayout, - ), + import('./pages/account-page/sidebar-layout/sidebar-layout').then((m) => m.SidebarLayout), children: [ { path: 'datos-personales',