feat(store-header): implement standard store header with dynamic properties
This commit is contained in:
@@ -0,0 +1,192 @@
|
|||||||
|
<header
|
||||||
|
class="store-layout__header"
|
||||||
|
[style.background-image]="backgroundImageUrl() ? 'url(' + backgroundImageUrl() + ')' : null"
|
||||||
|
>
|
||||||
|
<div class="store-layout__header-main">
|
||||||
|
<div class="container-xl px-3 px-md-4 py-3 py-lg-4">
|
||||||
|
<div class="row align-items-center gy-3 gy-md-0">
|
||||||
|
<div class="col-auto col-md-auto order-1 store-layout__brand-column">
|
||||||
|
<a
|
||||||
|
routerLink="/"
|
||||||
|
class="store-layout__brand-slot d-inline-flex align-items-center gap-3 text-decoration-none"
|
||||||
|
data-testid="store-header-brand-slot"
|
||||||
|
aria-label="Logo del comercio"
|
||||||
|
>
|
||||||
|
@if (logoUrl()) {
|
||||||
|
<img class="store-layout__brand-logo" [src]="logoUrl()" alt="Logo del comercio" />
|
||||||
|
} @else {
|
||||||
|
<div class="store-layout__brand-logo-shell" aria-hidden="true">
|
||||||
|
<span class="store-layout__brand-logo-skeleton"></span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (displaySeachBar()) {
|
||||||
|
<div class="col-12 px-0 d-md-none order-3">
|
||||||
|
<hr class="store-layout__mobile-separator" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-md order-4 order-md-2">
|
||||||
|
<div class="store-layout__search-wrapper ms-md-auto">
|
||||||
|
<div class="input-group store-layout__search" role="search">
|
||||||
|
<label class="visually-hidden" for="store-search-input">Buscar productos</label>
|
||||||
|
<input
|
||||||
|
id="store-search-input"
|
||||||
|
type="search"
|
||||||
|
class="form-control store-layout__search-input border-end-0 rounded-start"
|
||||||
|
placeholder="Buscar productos"
|
||||||
|
autocomplete="off"
|
||||||
|
[attr.aria-describedby]="showSearchError() ? 'store-search-error' : null"
|
||||||
|
[attr.aria-invalid]="showSearchError()"
|
||||||
|
[minlength]="minSearchLength"
|
||||||
|
[formControl]="searchControl"
|
||||||
|
(input)="clearSearchError()"
|
||||||
|
(keydown.enter)="submitSearch($event)"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn store-layout__search-button border-start-0 rounded-end px-3"
|
||||||
|
aria-label="Buscar"
|
||||||
|
(click)="submitSearch()"
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (showSearchError()) {
|
||||||
|
<p id="store-search-error" class="store-layout__search-error mb-0" role="alert">
|
||||||
|
Ingresá al menos {{ minSearchLength }} caracteres para buscar.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="store-layout__actions-column col-auto col-md-auto order-2 order-md-3 ms-auto d-flex flex-row align-items-center justify-content-end gap-2 gap-md-3"
|
||||||
|
>
|
||||||
|
@if (ticketsMenu(); as menu) {
|
||||||
|
<app-button
|
||||||
|
variant="primary"
|
||||||
|
hostClass="store-layout__tickets-btn-host"
|
||||||
|
data-testid="store-header-tickets"
|
||||||
|
(click)="ticketsClick.emit()"
|
||||||
|
>
|
||||||
|
{{ menu.label }}
|
||||||
|
</app-button>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (displayCart()) {
|
||||||
|
<app-cart-icon
|
||||||
|
[quantity]="cartDisabled() ? null : cartQuantity()"
|
||||||
|
[disabled]="cartDisabled()"
|
||||||
|
[ariaLabel]="
|
||||||
|
cartDisabled() ? 'Carrito no disponible durante el checkout' : 'Carrito de compras'
|
||||||
|
"
|
||||||
|
[title]="cartDisabled() ? 'Carrito no disponible durante el checkout' : 'Carrito'"
|
||||||
|
(click)="onCartClick()"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="store-layout__user-menu d-none d-md-inline-flex position-relative">
|
||||||
|
<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()"
|
||||||
|
[accountNavigationMenus]="accountNavigationMenus()"
|
||||||
|
(logoutClick)="onLogoutClick()"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="store-layout__mobile-menu d-inline-flex d-md-none position-relative">
|
||||||
|
<app-icon-button
|
||||||
|
variant="bars"
|
||||||
|
ariaLabel="Menu"
|
||||||
|
ariaControls="store-mobile-menu"
|
||||||
|
[ariaExpanded]="isMobileMenuOpen()"
|
||||||
|
title="Menu"
|
||||||
|
(clicked)="toggleMobileMenu()"
|
||||||
|
/>
|
||||||
|
|
||||||
|
@if (isMobileMenuOpen()) {
|
||||||
|
<div
|
||||||
|
id="store-mobile-menu"
|
||||||
|
class="position-absolute end-0 bg-white rounded shadow mt-2 py-2 store-layout__mobile-dropdown-menu"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
class="dropdown-item d-flex align-items-center gap-2 px-3 py-2 text-decoration-none store-layout__mobile-dropdown-item"
|
||||||
|
(click)="onMobileAccountClick()"
|
||||||
|
>
|
||||||
|
<i class="fa-regular fa-circle-user fa-lg"></i>
|
||||||
|
<span class="fw-medium">Mi cuenta</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
@if (displayCategories()) {
|
||||||
|
<hr class="my-1 border-secondary opacity-25" />
|
||||||
|
|
||||||
|
@for (category of categories(); track category.id) {
|
||||||
|
<a
|
||||||
|
class="dropdown-item px-3 py-2 text-decoration-none store-layout__mobile-dropdown-item"
|
||||||
|
(click)="onCategorySelect(category)"
|
||||||
|
>
|
||||||
|
<span class="fw-medium">{{ category.nombre }}</span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="store-layout__categories d-none d-md-block">
|
||||||
|
<div class="container-xl h-100 px-3 px-md-4 d-flex align-items-center">
|
||||||
|
@if (displayCategories()) {
|
||||||
|
<div class="store-layout__category-menu">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-link d-inline-flex align-items-center gap-2 px-0 py-3 text-uppercase fw-bold text-decoration-none store-layout__category-trigger"
|
||||||
|
aria-label="Categorias"
|
||||||
|
aria-controls="store-category-dropdown"
|
||||||
|
[attr.aria-expanded]="isCategoryDropdownOpen()"
|
||||||
|
(click)="toggleCategoryDropdown()"
|
||||||
|
>
|
||||||
|
<span>Categorias</span>
|
||||||
|
<i class="fa-solid fa-chevron-down" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<app-category-dropdown
|
||||||
|
id="store-category-dropdown"
|
||||||
|
[categories]="categories()"
|
||||||
|
[open]="isCategoryDropdownOpen()"
|
||||||
|
(categorySelect)="onCategorySelect($event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (checkoutRemainingTime(); as remainingTime) {
|
||||||
|
<div
|
||||||
|
class="store-layout__checkout-timer ms-auto d-none d-md-flex align-items-baseline gap-3"
|
||||||
|
role="timer"
|
||||||
|
aria-label="Tiempo restante de compra"
|
||||||
|
>
|
||||||
|
<span class="store-layout__checkout-timer-label">Tiempo restante de compra:</span>
|
||||||
|
<span class="store-layout__checkout-timer-value">{{ remainingTime }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
computed,
|
||||||
|
ElementRef,
|
||||||
|
HostListener,
|
||||||
|
inject,
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
signal,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { Router, 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';
|
||||||
|
import { Menu } from '../../../services/tenant.interface';
|
||||||
|
import { ButtonComponent } from '../../../../shared/components/button/button.component';
|
||||||
|
import { ReactiveFormsModule, FormControl } from '@angular/forms';
|
||||||
|
import { Category } from '../../../services/tenant.interface';
|
||||||
|
import { CategoryDropdownComponent } from './category-dropdown/category-dropdown.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-standard-store-header',
|
||||||
|
imports: [
|
||||||
|
ButtonComponent,
|
||||||
|
CartIconComponent,
|
||||||
|
IconButtonComponent,
|
||||||
|
RouterLink,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
UserDropdownComponent,
|
||||||
|
CategoryDropdownComponent,
|
||||||
|
],
|
||||||
|
templateUrl: './standard-store-header.component.html',
|
||||||
|
styleUrl: './standard-store-header.component.scss',
|
||||||
|
})
|
||||||
|
export class StandardStoreHeaderComponent {
|
||||||
|
private readonly elementRef = inject(ElementRef<HTMLElement>);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
|
||||||
|
readonly logoUrl = input<string | null>(null);
|
||||||
|
readonly backgroundImageUrl = input<string | null>(null);
|
||||||
|
readonly cartQuantity = input<number>(0);
|
||||||
|
readonly user = input<AuthUser | null>(null);
|
||||||
|
readonly isAuthenticated = input<boolean>(false);
|
||||||
|
readonly accountNavigationMenus = input<readonly Menu[]>([]);
|
||||||
|
readonly ticketsMenu = input<Menu | null>(null);
|
||||||
|
readonly categories = input<readonly Category[]>([]);
|
||||||
|
readonly displayCategories = input(true);
|
||||||
|
readonly displaySeachBar = input(true);
|
||||||
|
readonly displayCart = input(true);
|
||||||
|
readonly cartDisabled = input(false);
|
||||||
|
readonly checkoutRemainingSeconds = input<number | null>(null);
|
||||||
|
readonly cartClick = output<void>();
|
||||||
|
readonly ticketsClick = output<void>();
|
||||||
|
readonly loginClick = output<void>();
|
||||||
|
readonly logoutClick = output<void>();
|
||||||
|
readonly searchSubmit = output<string>();
|
||||||
|
readonly categorySelect = output<Category>();
|
||||||
|
|
||||||
|
protected readonly isUserDropdownOpen = signal(false);
|
||||||
|
protected readonly isCategoryDropdownOpen = signal(false);
|
||||||
|
protected readonly isMobileMenuOpen = signal(false);
|
||||||
|
protected readonly minSearchLength = 3;
|
||||||
|
protected readonly showSearchError = signal(false);
|
||||||
|
protected readonly searchControl = new FormControl('', { nonNullable: true });
|
||||||
|
protected readonly checkoutRemainingTime = computed(() => {
|
||||||
|
const remainingSeconds = this.checkoutRemainingSeconds();
|
||||||
|
|
||||||
|
if (remainingSeconds === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const minutes = Math.floor(remainingSeconds / 60);
|
||||||
|
const seconds = remainingSeconds % 60;
|
||||||
|
|
||||||
|
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
protected onCartClick(): void {
|
||||||
|
if (this.cartDisabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cartClick.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('document:click', ['$event'])
|
||||||
|
protected onDocumentClick(event: MouseEvent): void {
|
||||||
|
if (!this.isUserDropdownOpen() && !this.isCategoryDropdownOpen() && !this.isMobileMenuOpen()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = event.target;
|
||||||
|
if (target instanceof Node && !this.elementRef.nativeElement.contains(target)) {
|
||||||
|
this.isUserDropdownOpen.set(false);
|
||||||
|
this.isCategoryDropdownOpen.set(false);
|
||||||
|
this.isMobileMenuOpen.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('document:keydown.escape')
|
||||||
|
protected onEscapeKey(): void {
|
||||||
|
this.isUserDropdownOpen.set(false);
|
||||||
|
this.isCategoryDropdownOpen.set(false);
|
||||||
|
this.isMobileMenuOpen.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 onLogoutClick(): void {
|
||||||
|
this.isUserDropdownOpen.set(false);
|
||||||
|
this.logoutClick.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected submitSearch(event?: Event): void {
|
||||||
|
event?.preventDefault();
|
||||||
|
|
||||||
|
const term = this.searchControl.value.trim();
|
||||||
|
|
||||||
|
if (term.length < this.minSearchLength) {
|
||||||
|
this.showSearchError.set(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.showSearchError.set(false);
|
||||||
|
this.searchSubmit.emit(term);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected clearSearchError(): void {
|
||||||
|
this.showSearchError.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected toggleCategoryDropdown(): void {
|
||||||
|
this.isCategoryDropdownOpen.update((isOpen) => !isOpen);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onCategorySelect(category: Category): void {
|
||||||
|
this.isCategoryDropdownOpen.set(false);
|
||||||
|
this.isMobileMenuOpen.set(false);
|
||||||
|
this.categorySelect.emit(category);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected toggleMobileMenu(): void {
|
||||||
|
this.isMobileMenuOpen.update((isOpen) => !isOpen);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onMobileAccountClick(): void {
|
||||||
|
this.isMobileMenuOpen.set(false);
|
||||||
|
if (!this.isAuthenticated()) {
|
||||||
|
this.loginClick.emit();
|
||||||
|
} else {
|
||||||
|
const menus = this.accountNavigationMenus();
|
||||||
|
if (menus.length > 0) {
|
||||||
|
this.router.navigate([menus[0].route]);
|
||||||
|
} else {
|
||||||
|
this.router.navigate(['/cuenta']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,192 +1,33 @@
|
|||||||
<header
|
@switch (headerType()) {
|
||||||
class="store-layout__header"
|
@case ('immersive') {
|
||||||
[style.background-image]="backgroundImageUrl() ? 'url(' + backgroundImageUrl() + ')' : null"
|
<!-- Use standard until the immersive header is implemented. -->
|
||||||
>
|
<ng-container *ngTemplateOutlet="standardHeader" />
|
||||||
<div class="store-layout__header-main">
|
}
|
||||||
<div class="container-xl px-3 px-md-4 py-3 py-lg-4">
|
@default {
|
||||||
<div class="row align-items-center gy-3 gy-md-0">
|
<ng-container *ngTemplateOutlet="standardHeader" />
|
||||||
<div class="col-auto col-md-auto order-1 store-layout__brand-column">
|
}
|
||||||
<a
|
}
|
||||||
routerLink="/"
|
|
||||||
class="store-layout__brand-slot d-inline-flex align-items-center gap-3 text-decoration-none"
|
|
||||||
data-testid="store-header-brand-slot"
|
|
||||||
aria-label="Logo del comercio"
|
|
||||||
>
|
|
||||||
@if (logoUrl()) {
|
|
||||||
<img class="store-layout__brand-logo" [src]="logoUrl()" alt="Logo del comercio" />
|
|
||||||
} @else {
|
|
||||||
<div class="store-layout__brand-logo-shell" aria-hidden="true">
|
|
||||||
<span class="store-layout__brand-logo-skeleton"></span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (displaySeachBar()) {
|
<ng-template #standardHeader>
|
||||||
<div class="col-12 px-0 d-md-none order-3">
|
<app-standard-store-header
|
||||||
<hr class="store-layout__mobile-separator" />
|
[logoUrl]="logoUrl()"
|
||||||
</div>
|
[backgroundImageUrl]="backgroundImageUrl()"
|
||||||
|
[cartQuantity]="cartQuantity()"
|
||||||
<div class="col-12 col-md order-4 order-md-2">
|
[user]="user()"
|
||||||
<div class="store-layout__search-wrapper ms-md-auto">
|
[isAuthenticated]="isAuthenticated()"
|
||||||
<div class="input-group store-layout__search" role="search">
|
[accountNavigationMenus]="accountNavigationMenus()"
|
||||||
<label class="visually-hidden" for="store-search-input">Buscar productos</label>
|
[ticketsMenu]="ticketsMenu()"
|
||||||
<input
|
[categories]="categories()"
|
||||||
id="store-search-input"
|
[displayCategories]="displayCategories()"
|
||||||
type="search"
|
[displaySeachBar]="displaySeachBar()"
|
||||||
class="form-control store-layout__search-input border-end-0 rounded-start"
|
[displayCart]="displayCart()"
|
||||||
placeholder="Buscar productos"
|
[cartDisabled]="cartDisabled()"
|
||||||
autocomplete="off"
|
[checkoutRemainingSeconds]="checkoutRemainingSeconds()"
|
||||||
[attr.aria-describedby]="showSearchError() ? 'store-search-error' : null"
|
(cartClick)="cartClick.emit()"
|
||||||
[attr.aria-invalid]="showSearchError()"
|
(ticketsClick)="ticketsClick.emit()"
|
||||||
[minlength]="minSearchLength"
|
(loginClick)="loginClick.emit()"
|
||||||
[formControl]="searchControl"
|
(logoutClick)="logoutClick.emit()"
|
||||||
(input)="clearSearchError()"
|
(searchSubmit)="searchSubmit.emit($event)"
|
||||||
(keydown.enter)="submitSearch($event)"
|
(categorySelect)="categorySelect.emit($event)"
|
||||||
/>
|
/>
|
||||||
<button
|
</ng-template>
|
||||||
type="button"
|
|
||||||
class="btn store-layout__search-button border-start-0 rounded-end px-3"
|
|
||||||
aria-label="Buscar"
|
|
||||||
(click)="submitSearch()"
|
|
||||||
>
|
|
||||||
<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (showSearchError()) {
|
|
||||||
<p id="store-search-error" class="store-layout__search-error mb-0" role="alert">
|
|
||||||
Ingresá al menos {{ minSearchLength }} caracteres para buscar.
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="store-layout__actions-column col-auto col-md-auto order-2 order-md-3 ms-auto d-flex flex-row align-items-center justify-content-end gap-2 gap-md-3"
|
|
||||||
>
|
|
||||||
@if (ticketsMenu(); as menu) {
|
|
||||||
<app-button
|
|
||||||
variant="primary"
|
|
||||||
hostClass="store-layout__tickets-btn-host"
|
|
||||||
data-testid="store-header-tickets"
|
|
||||||
(click)="ticketsClick.emit()"
|
|
||||||
>
|
|
||||||
{{ menu.label }}
|
|
||||||
</app-button>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (displayCart()) {
|
|
||||||
<app-cart-icon
|
|
||||||
[quantity]="cartDisabled() ? null : cartQuantity()"
|
|
||||||
[disabled]="cartDisabled()"
|
|
||||||
[ariaLabel]="
|
|
||||||
cartDisabled() ? 'Carrito no disponible durante el checkout' : 'Carrito de compras'
|
|
||||||
"
|
|
||||||
[title]="cartDisabled() ? 'Carrito no disponible durante el checkout' : 'Carrito'"
|
|
||||||
(click)="onCartClick()"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="store-layout__user-menu d-none d-md-inline-flex position-relative">
|
|
||||||
<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()"
|
|
||||||
[accountNavigationMenus]="accountNavigationMenus()"
|
|
||||||
(logoutClick)="onLogoutClick()"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="store-layout__mobile-menu d-inline-flex d-md-none position-relative">
|
|
||||||
<app-icon-button
|
|
||||||
variant="bars"
|
|
||||||
ariaLabel="Menu"
|
|
||||||
ariaControls="store-mobile-menu"
|
|
||||||
[ariaExpanded]="isMobileMenuOpen()"
|
|
||||||
title="Menu"
|
|
||||||
(clicked)="toggleMobileMenu()"
|
|
||||||
/>
|
|
||||||
|
|
||||||
@if (isMobileMenuOpen()) {
|
|
||||||
<div
|
|
||||||
id="store-mobile-menu"
|
|
||||||
class="position-absolute end-0 bg-white rounded shadow mt-2 py-2 store-layout__mobile-dropdown-menu"
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
class="dropdown-item d-flex align-items-center gap-2 px-3 py-2 text-decoration-none store-layout__mobile-dropdown-item"
|
|
||||||
(click)="onMobileAccountClick()"
|
|
||||||
>
|
|
||||||
<i class="fa-regular fa-circle-user fa-lg"></i>
|
|
||||||
<span class="fw-medium">Mi cuenta</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
@if (displayCategories()) {
|
|
||||||
<hr class="my-1 border-secondary opacity-25" />
|
|
||||||
|
|
||||||
@for (category of categories(); track category.id) {
|
|
||||||
<a
|
|
||||||
class="dropdown-item px-3 py-2 text-decoration-none store-layout__mobile-dropdown-item"
|
|
||||||
(click)="onCategorySelect(category)"
|
|
||||||
>
|
|
||||||
<span class="fw-medium">{{ category.nombre }}</span>
|
|
||||||
</a>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="store-layout__categories d-none d-md-block">
|
|
||||||
<div class="container-xl h-100 px-3 px-md-4 d-flex align-items-center">
|
|
||||||
@if (displayCategories()) {
|
|
||||||
<div class="store-layout__category-menu">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-link d-inline-flex align-items-center gap-2 px-0 py-3 text-uppercase fw-bold text-decoration-none store-layout__category-trigger"
|
|
||||||
aria-label="Categorias"
|
|
||||||
aria-controls="store-category-dropdown"
|
|
||||||
[attr.aria-expanded]="isCategoryDropdownOpen()"
|
|
||||||
(click)="toggleCategoryDropdown()"
|
|
||||||
>
|
|
||||||
<span>Categorias</span>
|
|
||||||
<i class="fa-solid fa-chevron-down" aria-hidden="true"></i>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<app-category-dropdown
|
|
||||||
id="store-category-dropdown"
|
|
||||||
[categories]="categories()"
|
|
||||||
[open]="isCategoryDropdownOpen()"
|
|
||||||
(categorySelect)="onCategorySelect($event)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (checkoutRemainingTime(); as remainingTime) {
|
|
||||||
<div
|
|
||||||
class="store-layout__checkout-timer ms-auto d-none d-md-flex align-items-baseline gap-3"
|
|
||||||
role="timer"
|
|
||||||
aria-label="Tiempo restante de compra"
|
|
||||||
>
|
|
||||||
<span class="store-layout__checkout-timer-label">Tiempo restante de compra:</span>
|
|
||||||
<span class="store-layout__checkout-timer-value">{{ remainingTime }}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|||||||
@@ -1,42 +1,18 @@
|
|||||||
import {
|
import { NgTemplateOutlet } from '@angular/common';
|
||||||
Component,
|
import { Component, input, output } from '@angular/core';
|
||||||
computed,
|
|
||||||
ElementRef,
|
|
||||||
HostListener,
|
|
||||||
inject,
|
|
||||||
input,
|
|
||||||
output,
|
|
||||||
signal,
|
|
||||||
} from '@angular/core';
|
|
||||||
import { Router, RouterLink } from '@angular/router';
|
|
||||||
import { AuthUser } from '../../../services/auth/auth.interfaces';
|
import { AuthUser } from '../../../services/auth/auth.interfaces';
|
||||||
import { CartIconComponent } from '../../../../shared/components/cart-icon/cart-icon.component';
|
import { Category, Menu } from '../../../services/tenant.interface';
|
||||||
import { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.component';
|
import { StandardStoreHeaderComponent } from './standard-store-header.component';
|
||||||
import { UserDropdownComponent } from './user-dropdown/user-dropdown.component';
|
|
||||||
import { Menu } from '../../../services/tenant.interface';
|
export type StoreHeaderType = 'standard' | 'immersive';
|
||||||
import { ButtonComponent } from '../../../../shared/components/button/button.component';
|
|
||||||
import { ReactiveFormsModule, FormControl } from '@angular/forms';
|
|
||||||
import { Category } from '../../../services/tenant.interface';
|
|
||||||
import { CategoryDropdownComponent } from './category-dropdown/category-dropdown.component';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-store-header',
|
selector: 'app-store-header',
|
||||||
imports: [
|
imports: [NgTemplateOutlet, StandardStoreHeaderComponent],
|
||||||
ButtonComponent,
|
|
||||||
CartIconComponent,
|
|
||||||
IconButtonComponent,
|
|
||||||
RouterLink,
|
|
||||||
ReactiveFormsModule,
|
|
||||||
UserDropdownComponent,
|
|
||||||
CategoryDropdownComponent,
|
|
||||||
],
|
|
||||||
templateUrl: './store-header.component.html',
|
templateUrl: './store-header.component.html',
|
||||||
styleUrl: './store-header.component.scss',
|
|
||||||
})
|
})
|
||||||
export class StoreHeaderComponent {
|
export class StoreHeaderComponent {
|
||||||
private readonly elementRef = inject(ElementRef<HTMLElement>);
|
readonly headerType = input<StoreHeaderType>('standard');
|
||||||
private readonly router = inject(Router);
|
|
||||||
|
|
||||||
readonly logoUrl = input<string | null>(null);
|
readonly logoUrl = input<string | null>(null);
|
||||||
readonly backgroundImageUrl = input<string | null>(null);
|
readonly backgroundImageUrl = input<string | null>(null);
|
||||||
readonly cartQuantity = input<number>(0);
|
readonly cartQuantity = input<number>(0);
|
||||||
@@ -50,123 +26,11 @@ export class StoreHeaderComponent {
|
|||||||
readonly displayCart = input(true);
|
readonly displayCart = input(true);
|
||||||
readonly cartDisabled = input(false);
|
readonly cartDisabled = input(false);
|
||||||
readonly checkoutRemainingSeconds = input<number | null>(null);
|
readonly checkoutRemainingSeconds = input<number | null>(null);
|
||||||
|
|
||||||
readonly cartClick = output<void>();
|
readonly cartClick = output<void>();
|
||||||
readonly ticketsClick = output<void>();
|
readonly ticketsClick = output<void>();
|
||||||
readonly loginClick = output<void>();
|
readonly loginClick = output<void>();
|
||||||
readonly logoutClick = output<void>();
|
readonly logoutClick = output<void>();
|
||||||
readonly searchSubmit = output<string>();
|
readonly searchSubmit = output<string>();
|
||||||
readonly categorySelect = output<Category>();
|
readonly categorySelect = output<Category>();
|
||||||
|
|
||||||
protected readonly isUserDropdownOpen = signal(false);
|
|
||||||
protected readonly isCategoryDropdownOpen = signal(false);
|
|
||||||
protected readonly isMobileMenuOpen = signal(false);
|
|
||||||
protected readonly minSearchLength = 3;
|
|
||||||
protected readonly showSearchError = signal(false);
|
|
||||||
protected readonly searchControl = new FormControl('', { nonNullable: true });
|
|
||||||
protected readonly checkoutRemainingTime = computed(() => {
|
|
||||||
const remainingSeconds = this.checkoutRemainingSeconds();
|
|
||||||
|
|
||||||
if (remainingSeconds === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const minutes = Math.floor(remainingSeconds / 60);
|
|
||||||
const seconds = remainingSeconds % 60;
|
|
||||||
|
|
||||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
protected onCartClick(): void {
|
|
||||||
if (this.cartDisabled()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.cartClick.emit();
|
|
||||||
}
|
|
||||||
|
|
||||||
@HostListener('document:click', ['$event'])
|
|
||||||
protected onDocumentClick(event: MouseEvent): void {
|
|
||||||
if (!this.isUserDropdownOpen() && !this.isCategoryDropdownOpen() && !this.isMobileMenuOpen()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const target = event.target;
|
|
||||||
if (target instanceof Node && !this.elementRef.nativeElement.contains(target)) {
|
|
||||||
this.isUserDropdownOpen.set(false);
|
|
||||||
this.isCategoryDropdownOpen.set(false);
|
|
||||||
this.isMobileMenuOpen.set(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@HostListener('document:keydown.escape')
|
|
||||||
protected onEscapeKey(): void {
|
|
||||||
this.isUserDropdownOpen.set(false);
|
|
||||||
this.isCategoryDropdownOpen.set(false);
|
|
||||||
this.isMobileMenuOpen.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 onLogoutClick(): void {
|
|
||||||
this.isUserDropdownOpen.set(false);
|
|
||||||
this.logoutClick.emit();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected submitSearch(event?: Event): void {
|
|
||||||
event?.preventDefault();
|
|
||||||
|
|
||||||
const term = this.searchControl.value.trim();
|
|
||||||
|
|
||||||
if (term.length < this.minSearchLength) {
|
|
||||||
this.showSearchError.set(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.showSearchError.set(false);
|
|
||||||
this.searchSubmit.emit(term);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected clearSearchError(): void {
|
|
||||||
this.showSearchError.set(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected toggleCategoryDropdown(): void {
|
|
||||||
this.isCategoryDropdownOpen.update((isOpen) => !isOpen);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected onCategorySelect(category: Category): void {
|
|
||||||
this.isCategoryDropdownOpen.set(false);
|
|
||||||
this.isMobileMenuOpen.set(false);
|
|
||||||
this.categorySelect.emit(category);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected toggleMobileMenu(): void {
|
|
||||||
this.isMobileMenuOpen.update((isOpen) => !isOpen);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected onMobileAccountClick(): void {
|
|
||||||
this.isMobileMenuOpen.set(false);
|
|
||||||
if (!this.isAuthenticated()) {
|
|
||||||
this.loginClick.emit();
|
|
||||||
} else {
|
|
||||||
const menus = this.accountNavigationMenus();
|
|
||||||
if (menus.length > 0) {
|
|
||||||
this.router.navigate([menus[0].route]);
|
|
||||||
} else {
|
|
||||||
this.router.navigate(['/cuenta']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<div class="store-layout d-flex flex-column flex-grow-1">
|
<div class="store-layout d-flex flex-column flex-grow-1">
|
||||||
<app-store-header
|
<app-store-header
|
||||||
ngSkipHydration
|
ngSkipHydration
|
||||||
|
[headerType]="tenant()?.header_type ?? 'standard'"
|
||||||
[cartQuantity]="cartQuantity()"
|
[cartQuantity]="cartQuantity()"
|
||||||
[logoUrl]="tenant()?.header_logo ?? null"
|
[logoUrl]="tenant()?.header_logo ?? null"
|
||||||
[backgroundImageUrl]="tenant()?.header_bg_image ?? null"
|
[backgroundImageUrl]="tenant()?.header_bg_image ?? null"
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { AuthService } from '../../services/auth/auth.service';
|
|||||||
import { AuthUser } from '../../services/auth/auth.interfaces';
|
import { AuthUser } from '../../services/auth/auth.interfaces';
|
||||||
import { StoreLayoutComponent } from './store-layout.component';
|
import { StoreLayoutComponent } from './store-layout.component';
|
||||||
import { StoreHeaderComponent } from './store-header/store-header.component';
|
import { StoreHeaderComponent } from './store-header/store-header.component';
|
||||||
|
import { StandardStoreHeaderComponent } from './store-header/standard-store-header.component';
|
||||||
import { CheckoutService } from '../../services/checkout.service';
|
import { CheckoutService } from '../../services/checkout.service';
|
||||||
import { CheckoutCountdownService } from '../../services/checkout-countdown.service';
|
import { CheckoutCountdownService } from '../../services/checkout-countdown.service';
|
||||||
import { TenantUrlSerializer } from '../../services/tenant-url.serializer';
|
import { TenantUrlSerializer } from '../../services/tenant-url.serializer';
|
||||||
@@ -247,6 +248,24 @@ describe('StoreLayoutComponent', () => {
|
|||||||
expect(compiled.querySelector('app-store-footer .store-layout__footer')).not.toBeNull();
|
expect(compiled.querySelector('app-store-footer .store-layout__footer')).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps the standard header visible for both configured header types', () => {
|
||||||
|
const fixture = TestBed.createComponent(StoreLayoutComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
const header = fixture.debugElement.query(By.directive(StoreHeaderComponent));
|
||||||
|
expect(header.componentInstance.headerType()).toBe('standard');
|
||||||
|
expect(element.querySelector('app-standard-store-header')).not.toBeNull();
|
||||||
|
|
||||||
|
tenantState.set({ ...tenant, header_type: 'immersive' });
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(header.componentInstance.headerType()).toBe('immersive');
|
||||||
|
expect(element.querySelector('app-standard-store-header')).not.toBeNull();
|
||||||
|
expect(element.querySelector('[data-testid="store-header-brand-slot"]')).not.toBeNull();
|
||||||
|
expect(element.querySelector('app-cart-icon')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('opens the cart when requested through the openCart query parameter', () => {
|
it('opens the cart when requested through the openCart query parameter', () => {
|
||||||
const fixture = TestBed.createComponent(StoreLayoutComponent);
|
const fixture = TestBed.createComponent(StoreLayoutComponent);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
@@ -327,7 +346,7 @@ describe('StoreLayoutComponent', () => {
|
|||||||
expect(compiled.querySelector('app-cart')).toBeNull();
|
expect(compiled.querySelector('app-cart')).toBeNull();
|
||||||
expect(compiled.querySelector('.store-layout__cart-overlay')).toBeNull();
|
expect(compiled.querySelector('.store-layout__cart-overlay')).toBeNull();
|
||||||
|
|
||||||
const header = fixture.debugElement.query(By.directive(StoreHeaderComponent));
|
const header = fixture.debugElement.query(By.directive(StandardStoreHeaderComponent));
|
||||||
(header.componentInstance as any).toggleMobileMenu();
|
(header.componentInstance as any).toggleMobileMenu();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
@@ -378,7 +397,7 @@ describe('StoreLayoutComponent', () => {
|
|||||||
const fixture = TestBed.createComponent(StoreLayoutComponent);
|
const fixture = TestBed.createComponent(StoreLayoutComponent);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
const header = fixture.debugElement.query(By.directive(StoreHeaderComponent));
|
const header = fixture.debugElement.query(By.directive(StandardStoreHeaderComponent));
|
||||||
const searchButton = header.query(By.css('.store-layout__search-button'));
|
const searchButton = header.query(By.css('.store-layout__search-button'));
|
||||||
|
|
||||||
(header.componentInstance as any).searchControl.setValue(' zapatillas ');
|
(header.componentInstance as any).searchControl.setValue(' zapatillas ');
|
||||||
@@ -396,7 +415,7 @@ describe('StoreLayoutComponent', () => {
|
|||||||
const fixture = TestBed.createComponent(StoreLayoutComponent);
|
const fixture = TestBed.createComponent(StoreLayoutComponent);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
const header = fixture.debugElement.query(By.directive(StoreHeaderComponent));
|
const header = fixture.debugElement.query(By.directive(StandardStoreHeaderComponent));
|
||||||
const input = header.query(By.css('#store-search-input'));
|
const input = header.query(By.css('#store-search-input'));
|
||||||
const keyboardEvent = {
|
const keyboardEvent = {
|
||||||
preventDefault: vi.fn(),
|
preventDefault: vi.fn(),
|
||||||
@@ -417,7 +436,7 @@ describe('StoreLayoutComponent', () => {
|
|||||||
const fixture = TestBed.createComponent(StoreLayoutComponent);
|
const fixture = TestBed.createComponent(StoreLayoutComponent);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
const header = fixture.debugElement.query(By.directive(StoreHeaderComponent));
|
const header = fixture.debugElement.query(By.directive(StandardStoreHeaderComponent));
|
||||||
const input = header.query(By.css('#store-search-input'));
|
const input = header.query(By.css('#store-search-input'));
|
||||||
const searchButton = header.query(By.css('.store-layout__search-button'));
|
const searchButton = header.query(By.css('.store-layout__search-button'));
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ export interface TenantEvent {
|
|||||||
|
|
||||||
export interface WebsiteExtras {
|
export interface WebsiteExtras {
|
||||||
carousel?: string[];
|
carousel?: string[];
|
||||||
|
heroCarousel?: string[];
|
||||||
heroConfig?: HeroConfig | null;
|
heroConfig?: HeroConfig | null;
|
||||||
additionalInfoConfig?: AdditionalInfoConfig | null;
|
additionalInfoConfig?: AdditionalInfoConfig | null;
|
||||||
[extraName: string]: unknown;
|
[extraName: string]: unknown;
|
||||||
|
|||||||
Reference in New Issue
Block a user