feat(user-dropdown): implement user dropdown component with authentication actions and integrate with store header
This commit is contained in:
@@ -40,12 +40,27 @@
|
||||
title="Carrito"
|
||||
(click)="cartClick.emit()"
|
||||
/>
|
||||
<app-icon-button
|
||||
variant="user"
|
||||
ariaLabel="Mi cuenta"
|
||||
title="Mi cuenta"
|
||||
(click)="userClick.emit()"
|
||||
/>
|
||||
<div class="store-layout__user-menu">
|
||||
<app-icon-button
|
||||
variant="user"
|
||||
ariaLabel="Mi cuenta"
|
||||
ariaControls="store-user-dropdown"
|
||||
[ariaExpanded]="isUserDropdownOpen()"
|
||||
title="Mi cuenta"
|
||||
(clicked)="toggleUserDropdown()"
|
||||
/>
|
||||
|
||||
@if (isUserDropdownOpen()) {
|
||||
<app-user-dropdown
|
||||
id="store-user-dropdown"
|
||||
[user]="user()"
|
||||
[isAuthenticated]="isAuthenticated()"
|
||||
(loginClick)="onLoginClick()"
|
||||
(registerClick)="onRegisterClick()"
|
||||
(logoutClick)="onLogoutClick()"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<HTMLElement>);
|
||||
|
||||
readonly logoUrl = input<string | null>(null);
|
||||
readonly cartQuantity = input<number>(0);
|
||||
readonly user = input<AuthUser | null>(null);
|
||||
readonly isAuthenticated = input<boolean>(false);
|
||||
readonly cartClick = output<void>();
|
||||
readonly userClick = output<void>();
|
||||
readonly loginClick = output<void>();
|
||||
readonly registerClick = output<void>();
|
||||
readonly logoutClick = output<void>();
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<section class="user-dropdown" role="menu" aria-label="Menu de usuario">
|
||||
@if (isAuthenticated()) {
|
||||
<div class="user-dropdown__profile">
|
||||
<div class="user-dropdown__identity">
|
||||
<span class="user-dropdown__name">{{ displayName() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="user-dropdown__divider"></div>
|
||||
|
||||
<button type="button" class="user-dropdown__item" role="menuitem" data-testid="user-dropdown-account">
|
||||
<span>Mi cuenta</span>
|
||||
</button>
|
||||
<button type="button" class="user-dropdown__item" role="menuitem" data-testid="user-dropdown-purchases">
|
||||
<span>Mis compras</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="user-dropdown__item user-dropdown__item--danger"
|
||||
role="menuitem"
|
||||
data-testid="user-dropdown-logout"
|
||||
(click)="logoutClick.emit()"
|
||||
>
|
||||
<span>Cerrar sesion</span>
|
||||
</button>
|
||||
} @else {
|
||||
<div class="user-dropdown__guest">
|
||||
<span class="user-dropdown__guest-title">Mi cuenta</span>
|
||||
<span class="user-dropdown__guest-copy">Accede para ver tus compras y continuar mas rapido.</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="user-dropdown__primary"
|
||||
role="menuitem"
|
||||
data-testid="user-dropdown-login"
|
||||
(click)="loginClick.emit()"
|
||||
>
|
||||
Iniciar sesion
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="user-dropdown__secondary"
|
||||
role="menuitem"
|
||||
data-testid="user-dropdown-register"
|
||||
(click)="registerClick.emit()"
|
||||
>
|
||||
Crear cuenta
|
||||
</button>
|
||||
}
|
||||
</section>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<AuthUser | null>(null);
|
||||
readonly isAuthenticated = input(false);
|
||||
readonly loginClick = output<void>();
|
||||
readonly registerClick = output<void>();
|
||||
readonly logoutClick = output<void>();
|
||||
|
||||
protected readonly displayName = computed(() => this.user()?.nombre_apellido || 'Mi cuenta');
|
||||
}
|
||||
@@ -2,8 +2,12 @@
|
||||
<app-store-header
|
||||
[cartQuantity]="cartQuantity()"
|
||||
[logoUrl]="tenant()?.header_logo ?? null"
|
||||
[user]="user()"
|
||||
[isAuthenticated]="isAuthenticated()"
|
||||
(cartClick)="isCartOpen.set(!isCartOpen())"
|
||||
(userClick)="onUserClick()"
|
||||
(loginClick)="onLoginClick()"
|
||||
(registerClick)="onRegisterClick()"
|
||||
(logoutClick)="onLogoutClick()"
|
||||
/>
|
||||
|
||||
@if (isCartOpen()) {
|
||||
|
||||
@@ -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<AuthUser | null>(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');
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)"
|
||||
>
|
||||
<i [ngClass]="iconClass()"></i>
|
||||
</button>
|
||||
|
||||
@@ -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<IconButtonVariant>();
|
||||
readonly disabled = input(false);
|
||||
readonly ariaLabel = input<string>('');
|
||||
readonly ariaExpanded = input<boolean | null>(null);
|
||||
readonly ariaControls = input<string | null>(null);
|
||||
readonly clicked = output<MouseEvent>();
|
||||
|
||||
protected readonly iconClass = computed(() => {
|
||||
const classes: Record<IconButtonVariant, string> = {
|
||||
|
||||
Reference in New Issue
Block a user