From 6927da634280ccfd006d56fbd543f3e8b8722b34 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Thu, 17 Sep 2026 16:49:12 -0300 Subject: [PATCH] feat(store-header): implement standard store header with dynamic properties --- .../standard-store-header.component.html | 192 +++++++++++++++ ...s => standard-store-header.component.scss} | 0 .../standard-store-header.component.ts | 172 ++++++++++++++ .../store-header/store-header.component.html | 223 +++--------------- .../store-header/store-header.component.ts | 154 +----------- .../store-layout/store-layout.component.html | 1 + .../store-layout.component.spec.ts | 27 ++- src/app/core/services/tenant.interface.ts | 1 + 8 files changed, 430 insertions(+), 340 deletions(-) create mode 100644 src/app/core/layout/store-layout/store-header/standard-store-header.component.html rename src/app/core/layout/store-layout/store-header/{store-header.component.scss => standard-store-header.component.scss} (100%) create mode 100644 src/app/core/layout/store-layout/store-header/standard-store-header.component.ts diff --git a/src/app/core/layout/store-layout/store-header/standard-store-header.component.html b/src/app/core/layout/store-layout/store-header/standard-store-header.component.html new file mode 100644 index 0000000..b8a3115 --- /dev/null +++ b/src/app/core/layout/store-layout/store-header/standard-store-header.component.html @@ -0,0 +1,192 @@ +
+
+
+
+ + + @if (displaySeachBar()) { +
+
+
+ +
+
+ + + @if (showSearchError()) { + + } +
+
+ } + +
+ @if (ticketsMenu(); as menu) { + + {{ menu.label }} + + } + + @if (displayCart()) { + + } + +
+ + + @if (isUserDropdownOpen()) { + + } +
+ +
+ + + @if (isMobileMenuOpen()) { +
+ + + Mi cuenta + + + @if (displayCategories()) { +
+ + @for (category of categories(); track category.id) { + + {{ category.nombre }} + + } + } +
+ } +
+
+
+
+
+ +
+
+ @if (displayCategories()) { +
+ + + +
+ } + + @if (checkoutRemainingTime(); as remainingTime) { +
+ Tiempo restante de compra: + {{ remainingTime }} +
+ } +
+
+
diff --git a/src/app/core/layout/store-layout/store-header/store-header.component.scss b/src/app/core/layout/store-layout/store-header/standard-store-header.component.scss similarity index 100% rename from src/app/core/layout/store-layout/store-header/store-header.component.scss rename to src/app/core/layout/store-layout/store-header/standard-store-header.component.scss diff --git a/src/app/core/layout/store-layout/store-header/standard-store-header.component.ts b/src/app/core/layout/store-layout/store-header/standard-store-header.component.ts new file mode 100644 index 0000000..a2d6ec3 --- /dev/null +++ b/src/app/core/layout/store-layout/store-header/standard-store-header.component.ts @@ -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); + private readonly router = inject(Router); + + readonly logoUrl = input(null); + readonly backgroundImageUrl = input(null); + readonly cartQuantity = input(0); + readonly user = input(null); + readonly isAuthenticated = input(false); + readonly accountNavigationMenus = input([]); + readonly ticketsMenu = input(null); + readonly categories = input([]); + readonly displayCategories = input(true); + readonly displaySeachBar = input(true); + readonly displayCart = input(true); + readonly cartDisabled = input(false); + readonly checkoutRemainingSeconds = input(null); + readonly cartClick = output(); + readonly ticketsClick = output(); + readonly loginClick = output(); + readonly logoutClick = output(); + readonly searchSubmit = output(); + readonly categorySelect = output(); + + 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']); + } + } + } +} 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 b8a3115..4749951 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,192 +1,33 @@ -
-
-
-
- +@switch (headerType()) { + @case ('immersive') { + + + } + @default { + + } +} - @if (displaySeachBar()) { -
-
-
- -
-
- - - @if (showSearchError()) { - - } -
-
- } - -
- @if (ticketsMenu(); as menu) { - - {{ menu.label }} - - } - - @if (displayCart()) { - - } - -
- - - @if (isUserDropdownOpen()) { - - } -
- -
- - - @if (isMobileMenuOpen()) { -
- - - Mi cuenta - - - @if (displayCategories()) { -
- - @for (category of categories(); track category.id) { - - {{ category.nombre }} - - } - } -
- } -
-
-
-
-
- -
-
- @if (displayCategories()) { -
- - - -
- } - - @if (checkoutRemainingTime(); as remainingTime) { -
- Tiempo restante de compra: - {{ remainingTime }} -
- } -
-
-
+ + + 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 dfe1831..e66d43d 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,42 +1,18 @@ -import { - Component, - computed, - ElementRef, - HostListener, - inject, - input, - output, - signal, -} from '@angular/core'; -import { Router, RouterLink } from '@angular/router'; +import { NgTemplateOutlet } from '@angular/common'; +import { Component, input, output } from '@angular/core'; 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'; +import { Category, Menu } from '../../../services/tenant.interface'; +import { StandardStoreHeaderComponent } from './standard-store-header.component'; + +export type StoreHeaderType = 'standard' | 'immersive'; @Component({ selector: 'app-store-header', - imports: [ - ButtonComponent, - CartIconComponent, - IconButtonComponent, - RouterLink, - ReactiveFormsModule, - UserDropdownComponent, - CategoryDropdownComponent, - ], + imports: [NgTemplateOutlet, StandardStoreHeaderComponent], templateUrl: './store-header.component.html', - styleUrl: './store-header.component.scss', }) export class StoreHeaderComponent { - private readonly elementRef = inject(ElementRef); - private readonly router = inject(Router); - + readonly headerType = input('standard'); readonly logoUrl = input(null); readonly backgroundImageUrl = input(null); readonly cartQuantity = input(0); @@ -50,123 +26,11 @@ export class StoreHeaderComponent { readonly displayCart = input(true); readonly cartDisabled = input(false); readonly checkoutRemainingSeconds = input(null); + readonly cartClick = output(); readonly ticketsClick = output(); readonly loginClick = output(); readonly logoutClick = output(); readonly searchSubmit = output(); readonly categorySelect = output(); - - 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']); - } - } - } } 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 e3113a9..f8223a2 100644 --- a/src/app/core/layout/store-layout/store-layout.component.html +++ b/src/app/core/layout/store-layout/store-layout.component.html @@ -1,6 +1,7 @@
{ 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', () => { const fixture = TestBed.createComponent(StoreLayoutComponent); fixture.detectChanges(); @@ -327,7 +346,7 @@ describe('StoreLayoutComponent', () => { expect(compiled.querySelector('app-cart')).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(); fixture.detectChanges(); @@ -378,7 +397,7 @@ describe('StoreLayoutComponent', () => { const fixture = TestBed.createComponent(StoreLayoutComponent); 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')); (header.componentInstance as any).searchControl.setValue(' zapatillas '); @@ -396,7 +415,7 @@ describe('StoreLayoutComponent', () => { const fixture = TestBed.createComponent(StoreLayoutComponent); 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 keyboardEvent = { preventDefault: vi.fn(), @@ -417,7 +436,7 @@ describe('StoreLayoutComponent', () => { const fixture = TestBed.createComponent(StoreLayoutComponent); 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 searchButton = header.query(By.css('.store-layout__search-button')); diff --git a/src/app/core/services/tenant.interface.ts b/src/app/core/services/tenant.interface.ts index 78a6021..87770b4 100644 --- a/src/app/core/services/tenant.interface.ts +++ b/src/app/core/services/tenant.interface.ts @@ -71,6 +71,7 @@ export interface TenantEvent { export interface WebsiteExtras { carousel?: string[]; + heroCarousel?: string[]; heroConfig?: HeroConfig | null; additionalInfoConfig?: AdditionalInfoConfig | null; [extraName: string]: unknown;