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 35878d8..d358e20 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 @@ -40,12 +40,27 @@ title="Carrito" (click)="cartClick.emit()" /> - +
+ + + @if (isUserDropdownOpen()) { + + } +
diff --git a/src/app/core/layout/store-layout/store-header/store-header.component.scss b/src/app/core/layout/store-layout/store-header/store-header.component.scss index 135c24e..b9a9cc8 100644 --- a/src/app/core/layout/store-layout/store-header/store-header.component.scss +++ b/src/app/core/layout/store-layout/store-header/store-header.component.scss @@ -83,6 +83,11 @@ color: #8a8a8a; } +.store-layout__user-menu { + position: relative; + display: inline-flex; +} + .store-layout__category-trigger { font-size: 0.8rem; letter-spacing: 0.02em; 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 db4dad8..345803b 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 @@ -1,17 +1,68 @@ -import { Component, input, output } from '@angular/core'; +import { Component, ElementRef, HostListener, inject, input, output, signal } from '@angular/core'; import { RouterLink } from '@angular/router'; +import { AuthUser } from '../../../services/auth/auth.interfaces'; import { CartIconComponent } from '../../../../shared/components/cart-icon/cart-icon.component'; import { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.component'; +import { UserDropdownComponent } from './user-dropdown/user-dropdown.component'; @Component({ selector: 'app-store-header', - imports: [CartIconComponent, IconButtonComponent, RouterLink], + imports: [CartIconComponent, IconButtonComponent, RouterLink, UserDropdownComponent], templateUrl: './store-header.component.html', styleUrl: './store-header.component.scss' }) export class StoreHeaderComponent { + private readonly elementRef = inject(ElementRef); + readonly logoUrl = input(null); readonly cartQuantity = input(0); + readonly user = input(null); + readonly isAuthenticated = input(false); readonly cartClick = output(); - readonly userClick = output(); + readonly loginClick = output(); + readonly registerClick = output(); + readonly logoutClick = output(); + + protected readonly isUserDropdownOpen = signal(false); + + @HostListener('document:click', ['$event']) + protected onDocumentClick(event: MouseEvent): void { + if (!this.isUserDropdownOpen()) { + return; + } + + const target = event.target; + if (target instanceof Node && !this.elementRef.nativeElement.contains(target)) { + this.isUserDropdownOpen.set(false); + } + } + + @HostListener('document:keydown.escape') + protected onEscapeKey(): void { + this.isUserDropdownOpen.set(false); + } + + protected toggleUserDropdown(): void { + if (!this.isAuthenticated()) { + this.loginClick.emit(); + return; + } + + this.isUserDropdownOpen.update((isOpen) => !isOpen); + } + + protected onLoginClick(): void { + this.isUserDropdownOpen.set(false); + this.loginClick.emit(); + } + + protected onRegisterClick(): void { + this.isUserDropdownOpen.set(false); + this.registerClick.emit(); + } + + protected onLogoutClick(): void { + this.isUserDropdownOpen.set(false); + this.logoutClick.emit(); + } } 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 new file mode 100644 index 0000000..6cb004c --- /dev/null +++ b/src/app/core/layout/store-layout/store-header/user-dropdown/user-dropdown.component.html @@ -0,0 +1,52 @@ + diff --git a/src/app/core/layout/store-layout/store-header/user-dropdown/user-dropdown.component.scss b/src/app/core/layout/store-layout/store-header/user-dropdown/user-dropdown.component.scss new file mode 100644 index 0000000..a68d85e --- /dev/null +++ b/src/app/core/layout/store-layout/store-header/user-dropdown/user-dropdown.component.scss @@ -0,0 +1,145 @@ +:host { + position: absolute; + top: calc(100% + 0.75rem); + right: 0; + z-index: 1060; + display: block; + width: min(18rem, calc(100vw - 2rem)); +} + +.user-dropdown { + position: relative; + color: #202020; + background: #ffffff; + border: 1px solid rgba(32, 32, 32, 0.1); + border-radius: 8px; + box-shadow: 0 18px 44px rgba(0, 0, 0, 0.16); + padding: 0.75rem; + animation: user-dropdown-enter 0.16s ease-out; +} + +.user-dropdown__profile { + padding: 0.25rem; +} + +.user-dropdown__identity, +.user-dropdown__guest { + display: grid; + min-width: 0; + gap: 0.15rem; +} + +.user-dropdown__name, +.user-dropdown__guest-title { + color: #a0a0a0; + font-size: 13px; + font-weight: 325; + line-height: 1.2; +} + +.user-dropdown__guest-copy { + overflow: hidden; + color: #666666; + font-size: 0.8rem; + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +} + +.user-dropdown__guest { + padding: 0.25rem 0.25rem 0.75rem; +} + +.user-dropdown__divider { + height: 1px; + margin: 0.6rem 0; + background: rgba(32, 32, 32, 0.08); +} + +.user-dropdown__item, +.user-dropdown__primary, +.user-dropdown__secondary { + width: 100%; + border: 0; + border-radius: 6px; + font: inherit; + cursor: pointer; +} + +.user-dropdown__item { + display: flex; + align-items: center; + gap: 0.65rem; + padding: 0.65rem 0.6rem; + color: #8a8a8a; + font-size: 14px; + font-weight: 700; + text-align: left; + background: transparent; + transition: color 0.15s ease; +} + +.user-dropdown__item:active, +.user-dropdown__item:hover, +.user-dropdown__item:focus-visible { + color: var(--tenant-primary); + outline: none; +} + +.user-dropdown__item--danger:active, +.user-dropdown__item--danger:hover, +.user-dropdown__item--danger:focus-visible { + color: var(--tenant-primary); +} + +.user-dropdown__primary, +.user-dropdown__secondary { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 2.35rem; + padding: 0.55rem 0.85rem; + font-size: 0.88rem; + font-weight: 700; +} + +.user-dropdown__primary { + color: #ffffff; + background: var(--tenant-primary); +} + +.user-dropdown__primary:hover, +.user-dropdown__primary:focus-visible { + filter: brightness(0.96); + outline: none; +} + +.user-dropdown__secondary { + margin-top: 0.5rem; + color: var(--tenant-primary); + background: rgba(0, 0, 0, 0.04); +} + +.user-dropdown__secondary:hover, +.user-dropdown__secondary:focus-visible { + background: rgba(0, 0, 0, 0.07); + outline: none; +} + +@keyframes user-dropdown-enter { + from { + opacity: 0; + transform: translateY(-0.35rem); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (max-width: 575.98px) { + :host { + right: -0.5rem; + } +} diff --git a/src/app/core/layout/store-layout/store-header/user-dropdown/user-dropdown.component.ts b/src/app/core/layout/store-layout/store-header/user-dropdown/user-dropdown.component.ts new file mode 100644 index 0000000..bdad800 --- /dev/null +++ b/src/app/core/layout/store-layout/store-header/user-dropdown/user-dropdown.component.ts @@ -0,0 +1,17 @@ +import { Component, computed, input, output } from '@angular/core'; +import { AuthUser } from '../../../../services/auth/auth.interfaces'; + +@Component({ + selector: 'app-user-dropdown', + templateUrl: './user-dropdown.component.html', + styleUrl: './user-dropdown.component.scss' +}) +export class UserDropdownComponent { + readonly user = input(null); + readonly isAuthenticated = input(false); + readonly loginClick = output(); + readonly registerClick = output(); + readonly logoutClick = output(); + + protected readonly displayName = computed(() => this.user()?.nombre_apellido || 'Mi cuenta'); +} diff --git a/src/app/core/layout/store-layout/store-layout.component.html b/src/app/core/layout/store-layout/store-layout.component.html index 02638fd..0a5ea70 100644 --- a/src/app/core/layout/store-layout/store-layout.component.html +++ b/src/app/core/layout/store-layout/store-layout.component.html @@ -2,8 +2,12 @@ @if (isCartOpen()) { 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 0fc586c..d1ecf0a 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 @@ -10,6 +10,7 @@ import { TenantService } from '../../services/tenant.service'; import { CartService } from '../../services/cart/cart.service'; import { ToastService } from '../../services/toast.service'; import { AuthService } from '../../services/auth/auth.service'; +import { AuthUser } from '../../services/auth/auth.interfaces'; import { StoreLayoutComponent } from './store-layout.component'; const tenant: Tenant = { @@ -30,6 +31,8 @@ const tenant: Tenant = { describe('StoreLayoutComponent', () => { beforeEach(async () => { const tenantState = signal(tenant); + const authUserState = signal(null); + const isAuthenticatedState = signal(false); await TestBed.configureTestingModule({ imports: [StoreLayoutComponent], @@ -66,7 +69,9 @@ describe('StoreLayoutComponent', () => { { provide: AuthService, useValue: { - isAuthenticated: signal(false) + user: authUserState, + isAuthenticated: isAuthenticatedState, + logout: vi.fn().mockReturnValue(of(void 0)) } } ] @@ -112,7 +117,6 @@ describe('StoreLayoutComponent', () => { const router = TestBed.inject(Router); vi.spyOn(router, 'navigate'); - // Set isAuthenticated to false (authService.isAuthenticated as any).set(false); const fixture = TestBed.createComponent(StoreLayoutComponent); @@ -124,16 +128,21 @@ describe('StoreLayoutComponent', () => { userButton.click(); fixture.detectChanges(); + expect(compiled.querySelector('app-user-dropdown')).toBeNull(); expect(router.navigate).toHaveBeenCalledWith(['/login']); }); - it('does not redirect to /login when user icon is clicked and user is logged in', () => { + it('opens authenticated user dropdown without redirecting on icon click', () => { const authService = TestBed.inject(AuthService); const router = TestBed.inject(Router); vi.spyOn(router, 'navigate'); - // Set isAuthenticated to true (authService.isAuthenticated as any).set(true); + (authService.user as any).set({ + id: 1, + nombre_apellido: 'Ada Lovelace', + email: 'ada@example.com' + }); const fixture = TestBed.createComponent(StoreLayoutComponent); fixture.detectChanges(); @@ -144,9 +153,40 @@ describe('StoreLayoutComponent', () => { userButton.click(); fixture.detectChanges(); + expect(compiled.querySelector('app-user-dropdown')).not.toBeNull(); + expect(compiled.textContent).toContain('Ada Lovelace'); expect(router.navigate).not.toHaveBeenCalled(); }); + it('logs out from the authenticated user dropdown', () => { + const authService = TestBed.inject(AuthService); + const router = TestBed.inject(Router); + vi.spyOn(router, 'navigate'); + + (authService.isAuthenticated as any).set(true); + (authService.user as any).set({ + id: 1, + nombre_apellido: 'Ada Lovelace', + 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; + userButton.click(); + fixture.detectChanges(); + + const logoutButton = compiled.querySelector('[data-testid="user-dropdown-logout"]') as HTMLButtonElement; + expect(logoutButton).not.toBeNull(); + logoutButton.click(); + fixture.detectChanges(); + + expect(authService.logout).toHaveBeenCalled(); + expect(router.navigate).toHaveBeenCalledWith(['/login']); + }); + it('redirects to /checkout when cart buy button is clicked', () => { const router = TestBed.inject(Router); vi.spyOn(router, 'navigate'); 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 147a488..d50678b 100644 --- a/src/app/core/layout/store-layout/store-layout.component.ts +++ b/src/app/core/layout/store-layout/store-layout.component.ts @@ -70,6 +70,8 @@ export class StoreLayoutComponent implements OnInit { protected readonly currentYear = new Date().getFullYear(); protected readonly tenant = this.tenantService.tenant; + protected readonly user = this.authService.user; + protected readonly isAuthenticated = this.authService.isAuthenticated; protected readonly cartQuantity = computed(() => { const cart = this.cartService.cart(); @@ -83,10 +85,19 @@ export class StoreLayoutComponent implements OnInit { }); } - protected onUserClick(): void { - if (!this.authService.isAuthenticated()) { - this.router.navigate(['/login']); - } + protected onLoginClick(): void { + void this.router.navigate(['/login']); + } + + protected onRegisterClick(): void { + void this.router.navigate(['/register']); + } + + protected onLogoutClick(): void { + this.authService.logout().subscribe({ + next: () => void this.router.navigate(['/login']), + error: (err) => console.error('Error logging out', err) + }); } protected onCheckoutClick(): void { diff --git a/src/app/shared/components/icon-button/icon-button.component.html b/src/app/shared/components/icon-button/icon-button.component.html index c7318a3..b8aae5c 100644 --- a/src/app/shared/components/icon-button/icon-button.component.html +++ b/src/app/shared/components/icon-button/icon-button.component.html @@ -3,6 +3,9 @@ [disabled]="disabled()" [ngClass]="buttonClasses()" [attr.aria-label]="ariaLabel() || variant()" + [attr.aria-expanded]="ariaExpanded()" + [attr.aria-controls]="ariaControls()" + (click)="clicked.emit($event)" > diff --git a/src/app/shared/components/icon-button/icon-button.component.ts b/src/app/shared/components/icon-button/icon-button.component.ts index 140292f..a899f80 100644 --- a/src/app/shared/components/icon-button/icon-button.component.ts +++ b/src/app/shared/components/icon-button/icon-button.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core'; import { NgClass } from '@angular/common'; export type IconButtonVariant = @@ -21,6 +21,9 @@ export class IconButtonComponent { readonly variant = input.required(); readonly disabled = input(false); readonly ariaLabel = input(''); + readonly ariaExpanded = input(null); + readonly ariaControls = input(null); + readonly clicked = output(); protected readonly iconClass = computed(() => { const classes: Record = {