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()"
/>
-
+
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 @@
+
+ @if (isAuthenticated()) {
+
+
+ {{ displayName() }}
+
+
+
+
+
+
+
+
+
+ } @else {
+
+ Mi cuenta
+ Accede para ver tus compras y continuar mas rapido.
+
+
+
+
+ }
+
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 = {