diff --git a/public/fonts/KaushanScript/KaushanScript-Regular.ttf b/public/fonts/KaushanScript/KaushanScript-Regular.ttf new file mode 100644 index 0000000..e69e4ac Binary files /dev/null and b/public/fonts/KaushanScript/KaushanScript-Regular.ttf differ diff --git a/public/fonts/KaushanScript/OFL.txt b/public/fonts/KaushanScript/OFL.txt new file mode 100644 index 0000000..87a559e --- /dev/null +++ b/public/fonts/KaushanScript/OFL.txt @@ -0,0 +1,95 @@ +Copyright (c) 2011, Pablo Impallari (www.impallari.com|impallari@gmail.com), +Copyright (c) 2011, Igino Marini. (www.ikern.com|mail@iginomarini.com), +with Reserved Font Name Kaushan Script. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/public/fonts/Smooch/OFL.txt b/public/fonts/Smooch/OFL.txt new file mode 100644 index 0000000..ddee91b --- /dev/null +++ b/public/fonts/Smooch/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2017 The Smooch Project Authors (https://github.com/googlefonts/smooch) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/public/fonts/Smooch/Smooch-Regular.ttf b/public/fonts/Smooch/Smooch-Regular.ttf new file mode 100644 index 0000000..17afcde Binary files /dev/null and b/public/fonts/Smooch/Smooch-Regular.ttf differ diff --git a/src/_fonts.scss b/src/_fonts.scss index 38ec4ad..1db9362 100644 --- a/src/_fonts.scss +++ b/src/_fonts.scss @@ -1,3 +1,11 @@ +@font-face { + font-family: 'Smooch'; + src: url('/fonts/Smooch/Smooch-Regular.ttf') format('truetype'); + font-style: normal; + font-weight: 400; + font-display: swap; +} + @font-face { font-family: "Gotham"; src: url("/fonts/Gotham/Gotham Thin.otf") format("opentype"); diff --git a/src/app/core/guards/menu.guard.spec.ts b/src/app/core/guards/menu.guard.spec.ts index 3bf9479..f0f898e 100644 --- a/src/app/core/guards/menu.guard.spec.ts +++ b/src/app/core/guards/menu.guard.spec.ts @@ -1,8 +1,9 @@ import { TestBed } from '@angular/core/testing'; -import { provideRouter, Router, UrlTree } from '@angular/router'; +import { provideRouter, Router, UrlSerializer, UrlTree } from '@angular/router'; import { Tenant } from '../services/tenant.interface'; import { TenantService } from '../services/tenant.service'; +import { TenantUrlSerializer } from '../services/tenant-url.serializer'; import { hasMenuGuard } from './menu.guard'; const tenant: Tenant = { @@ -36,9 +37,10 @@ function runGuard(menuCode: string, url: string, currentTenant: Tenant | null) { TestBed.configureTestingModule({ providers: [ provideRouter([]), + { provide: UrlSerializer, useClass: TenantUrlSerializer }, { provide: TenantService, - useValue: { tenant: () => currentTenant }, + useValue: { tenant: () => currentTenant, getTenant: () => currentTenant }, }, ], }); @@ -73,4 +75,16 @@ describe('hasMenuGuard', () => { expect(runGuard('index', '/', tenantWithoutMenus)).toBe(true); }); + + it('allows a tenant base path as the store root when its menu is missing', () => { + const tenantWithoutMenus = { ...tenant, base_path: '/onticket', menues: [] }; + + expect(runGuard('index', '/onticket', tenantWithoutMenus)).toBe(true); + }); + + it('allows a tenant base path with query parameters as the store root', () => { + const tenantWithoutMenus = { ...tenant, base_path: '/onticket', menues: [] }; + + expect(runGuard('index', '/onticket?ref=home', tenantWithoutMenus)).toBe(true); + }); }); diff --git a/src/app/core/guards/menu.guard.ts b/src/app/core/guards/menu.guard.ts index 5dffedb..5f57308 100644 --- a/src/app/core/guards/menu.guard.ts +++ b/src/app/core/guards/menu.guard.ts @@ -1,5 +1,5 @@ import { inject } from '@angular/core'; -import { CanActivateFn, Router } from '@angular/router'; +import { CanActivateFn, PRIMARY_OUTLET, Router } from '@angular/router'; import { findMenu } from '../services/menu.utils'; import { TenantService } from '../services/tenant.service'; @@ -16,7 +16,9 @@ export const hasMenuGuard = (menuCode: string): CanActivateFn => { const hasMenu = findMenu(tenant.menues ?? [], menuCode) !== undefined; - if (hasMenu || state.url === '/') { + const primarySegments = router.parseUrl(state.url).root.children[PRIMARY_OUTLET]?.segments ?? []; + + if (hasMenu || primarySegments.length === 0) { return true; } diff --git a/src/app/core/layout/store-layout/store-header/immersive-store-header.component.html b/src/app/core/layout/store-layout/store-header/immersive-store-header.component.html new file mode 100644 index 0000000..7a24021 --- /dev/null +++ b/src/app/core/layout/store-layout/store-header/immersive-store-header.component.html @@ -0,0 +1,108 @@ +
+
+ @if (showHero() && backgroundImageUrl(); as imageUrl) { + + } +
+ +
+ + + @if (checkoutRemainingTime(); as remainingTime) { +
+ Tiempo restante de compra: {{ remainingTime }} +
+ } + + @if (showHero()) { + @if (heroContent(); as content) { +
+

{{ content.eyebrow }}

+

{{ content.title }}

+

{{ content.description }}

+
+ } + + @if (carouselImages().length) { + + } + } +
+
diff --git a/src/app/core/layout/store-layout/store-header/immersive-store-header.component.scss b/src/app/core/layout/store-layout/store-header/immersive-store-header.component.scss new file mode 100644 index 0000000..e179e14 --- /dev/null +++ b/src/app/core/layout/store-layout/store-header/immersive-store-header.component.scss @@ -0,0 +1,236 @@ +:host { + display: block; +} + +.immersive-header { + position: relative; + display: grid; + isolation: isolate; + min-height: 26rem; + color: #fff; + background: #f5f5f5; +} + +.immersive-header--has-image { + min-height: 0; +} + +.immersive-header__content { + grid-area: 1 / 1; + position: relative; + z-index: 1; + padding-top: clamp(1.75rem, 4vw, 2.5rem); + padding-bottom: 0.5rem; +} + +.immersive-header__backdrop { + grid-area: 1 / 1; + position: relative; + z-index: 0; + background-color: #101010; + background-position: center 42%; + background-size: cover; +} + +.immersive-header__backdrop-size { + display: block; + width: 100%; + height: auto; + visibility: hidden; +} + +.immersive-header__backdrop::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient( + 180deg, + rgba(0, 0, 0, 0.48) 0%, + rgba(0, 0, 0, 0.4) 47%, + rgba(245, 245, 245, 0.88) 100% + ); +} + +.immersive-header__nav { + position: relative; + z-index: 1; + width: min(100% - 2rem, 1120px); + margin-inline: auto; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1.5rem; + min-height: 2.25rem; +} + +.immersive-header__brand { + display: inline-flex; + flex: none; + align-items: center; +} +.immersive-header__brand img { + display: block; + width: clamp(105px, 13vw, 145px); + max-height: 2.25rem; + object-fit: contain; +} +.immersive-header__actions { + display: flex; + align-items: center; + gap: clamp(0.35rem, 1vw, 1rem); +} + +.immersive-header__search { + position: relative; + display: flex; + width: clamp(115px, 17vw, 205px); + height: 2rem; + border: 1px solid var(--tenant-primary, #ff7006); + border-radius: 3px; + background: rgba(0, 0, 0, 0.1); +} +.immersive-header__search input { + width: 100%; + min-width: 0; + padding: 0 0.5rem; + border: 0; + outline: 0; + color: #fff; + background: transparent; + font-size: 0.75rem; +} +.immersive-header__search input::placeholder { + color: rgba(255, 255, 255, 0.78); +} +.immersive-header__search input::-webkit-search-cancel-button { + filter: invert(1); +} +.immersive-header__search button { + border: 0; + padding-inline: 0.55rem; + color: var(--tenant-primary, #ff7006); + background: transparent; +} +.immersive-header__search:focus-within { + outline: 2px solid var(--tenant-primary, #ff7006); + outline-offset: 2px; +} +.immersive-header__search-error { + position: absolute; + top: calc(100% + 0.25rem); + right: 0; + width: max-content; + color: #fff; + font-size: 0.7rem; +} +.immersive-header__tickets { + border: 1px solid var(--tenant-primary, #ff7006); + border-radius: 3px; + padding: 0.25rem 0.6rem; + color: #fff; + background: rgba(0, 0, 0, 0.2); + font-size: 0.75rem; +} +.immersive-header__tickets:hover { + background: var(--tenant-primary, #ff7006); +} +.immersive-header__account { + position: relative; + display: flex; +} +.immersive-header__account app-user-dropdown { + position: absolute; + top: 100%; + right: 0; + z-index: 20; + color: #222; +} +:host ::ng-deep .immersive-header__actions .icon-btn, +:host ::ng-deep .immersive-header__actions .cart-icon { + color: var(--tenant-primary, #ff7006); +} + +.immersive-header__copy { + position: relative; + z-index: 1; + margin: clamp(2.1rem, 5vw, 3.25rem) auto 0; + padding-inline: 1rem; + text-align: center; +} +.immersive-header__eyebrow, +.immersive-header__title { + font-family: 'Smooch', cursive; + font-weight: 400; + text-shadow: 0 2px 12px rgba(0, 0, 0, 0.55); +} +.immersive-header__eyebrow { + margin: 0; + font-size: 50px; + line-height: 1; +} +.immersive-header__title { + margin: -0.15rem 0 0; + font-size: 140px; + line-height: 1.05; +} +.immersive-header__description { + margin: 1.15rem 0 0; + font-size: clamp(0.75rem, 1.4vw, 0.95rem); + text-shadow: 0 1px 5px rgba(0, 0, 0, 0.6); +} +.immersive-header__timer { + position: relative; + z-index: 1; + width: min(100% - 2rem, 1120px); + margin: 0.75rem auto 0; + text-align: right; + font-size: 0.75rem; +} +.immersive-header app-continuous-image-carousel { + display: block; + position: relative; + z-index: 1; + margin-top: clamp(3.25rem, 7vw, 5rem); +} + +.immersive-header--compact { + min-height: 0; + padding-bottom: 1.5rem; + background: #171717; +} +.immersive-header--compact .immersive-header__backdrop { + position: absolute; + inset: 0; +} +.immersive-header--compact .immersive-header__backdrop::after { + background: rgba(0, 0, 0, 0.65); +} + +@media (max-width: 600px) { + .immersive-header__nav { + flex-wrap: wrap; + gap: 0.5rem; + } + .immersive-header__actions { + flex: 1; + justify-content: flex-end; + } + .immersive-header__search { + width: clamp(105px, 38vw, 180px); + } + .immersive-header__copy { + margin-top: 3rem; + } + .immersive-header__eyebrow { + font-size: clamp(2rem, 8vw, 50px); + } + .immersive-header__title { + font-size: clamp(3rem, 12vw, 5rem); + } +} + +@media (prefers-reduced-motion: reduce) { + .immersive-header__tickets { + transition: none; + } +} diff --git a/src/app/core/layout/store-layout/store-header/immersive-store-header.component.ts b/src/app/core/layout/store-layout/store-header/immersive-store-header.component.ts new file mode 100644 index 0000000..d1b3d17 --- /dev/null +++ b/src/app/core/layout/store-layout/store-header/immersive-store-header.component.ts @@ -0,0 +1,108 @@ +import { + Component, + ElementRef, + HostListener, + computed, + inject, + input, + output, + signal, +} from '@angular/core'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { RouterLink } from '@angular/router'; +import { AuthUser } from '../../../services/auth/auth.interfaces'; +import { Menu, WebsiteExtras } from '../../../services/tenant.interface'; +import { CartIconComponent } from '../../../../shared/components/cart-icon/cart-icon.component'; +import { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.component'; +import { ContinuousImageCarouselComponent } from '../../../../shared/components/continuous-image-carousel/continuous-image-carousel.component'; +import { UserDropdownComponent } from './user-dropdown/user-dropdown.component'; + +@Component({ + selector: 'app-immersive-store-header', + imports: [ + RouterLink, + ReactiveFormsModule, + CartIconComponent, + IconButtonComponent, + ContinuousImageCarouselComponent, + UserDropdownComponent, + ], + templateUrl: './immersive-store-header.component.html', + styleUrl: './immersive-store-header.component.scss', +}) +export class ImmersiveStoreHeaderComponent { + private readonly host = inject>(ElementRef); + + readonly logoUrl = input(null); + readonly backgroundImageUrl = input(null); + readonly heroContent = input(undefined); + readonly carouselImages = input([]); + readonly showHero = input(true); + readonly cartQuantity = input(0); + readonly user = input(null); + readonly isAuthenticated = input(false); + readonly accountNavigationMenus = input([]); + readonly ticketsMenu = input(null); + readonly displaySearchBar = 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(); + + protected readonly searchControl = new FormControl('', { nonNullable: true }); + protected readonly showSearchError = signal(false); + protected readonly isUserDropdownOpen = signal(false); + protected readonly checkoutRemainingTime = computed(() => { + const seconds = this.checkoutRemainingSeconds(); + if (seconds === null) return null; + + return `${Math.floor(seconds / 60) + .toString() + .padStart(2, '0')}:${(seconds % 60).toString().padStart(2, '0')}`; + }); + + protected submitSearch(event?: Event): void { + event?.preventDefault(); + const term = this.searchControl.value.trim(); + if (term.length < 3) { + this.showSearchError.set(true); + return; + } + this.showSearchError.set(false); + this.searchSubmit.emit(term); + } + + protected toggleUserDropdown(): void { + if (!this.isAuthenticated()) { + this.loginClick.emit(); + return; + } + this.isUserDropdownOpen.update((open) => !open); + } + + protected onCartClick(): void { + if (!this.cartDisabled()) this.cartClick.emit(); + } + + protected onLogoutClick(): void { + this.isUserDropdownOpen.set(false); + this.logoutClick.emit(); + } + + @HostListener('document:click', ['$event']) + protected onDocumentClick(event: MouseEvent): void { + if (event.target instanceof Node && !this.host.nativeElement.contains(event.target)) { + this.isUserDropdownOpen.set(false); + } + } + + @HostListener('document:keydown.escape') + protected onEscapeKey(): void { + this.isUserDropdownOpen.set(false); + } +} 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 4749951..ce166b3 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,7 +1,26 @@ @switch (headerType()) { @case ('immersive') { - - + } @default { 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 e66d43d..062234b 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,20 +1,24 @@ import { NgTemplateOutlet } from '@angular/common'; import { Component, input, output } from '@angular/core'; import { AuthUser } from '../../../services/auth/auth.interfaces'; -import { Category, Menu } from '../../../services/tenant.interface'; +import { Category, Menu, WebsiteExtras } from '../../../services/tenant.interface'; +import { ImmersiveStoreHeaderComponent } from './immersive-store-header.component'; import { StandardStoreHeaderComponent } from './standard-store-header.component'; export type StoreHeaderType = 'standard' | 'immersive'; @Component({ selector: 'app-store-header', - imports: [NgTemplateOutlet, StandardStoreHeaderComponent], + imports: [NgTemplateOutlet, StandardStoreHeaderComponent, ImmersiveStoreHeaderComponent], templateUrl: './store-header.component.html', }) export class StoreHeaderComponent { readonly headerType = input('standard'); readonly logoUrl = input(null); readonly backgroundImageUrl = input(null); + readonly heroContent = input(undefined); + readonly carouselImages = input([]); + readonly showHero = input(true); readonly cartQuantity = input(0); readonly user = input(null); readonly isAuthenticated = input(false); 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 f8223a2..24320ce 100644 --- a/src/app/core/layout/store-layout/store-layout.component.html +++ b/src/app/core/layout/store-layout/store-layout.component.html @@ -2,6 +2,9 @@ { expect(compiled.querySelector('app-store-footer .store-layout__footer')).not.toBeNull(); }); - it('keeps the standard header visible for both configured header types', () => { + it('renders the immersive header with configured copy and carousel images', () => { const fixture = TestBed.createComponent(StoreLayoutComponent); fixture.detectChanges(); @@ -257,12 +257,34 @@ describe('StoreLayoutComponent', () => { expect(header.componentInstance.headerType()).toBe('standard'); expect(element.querySelector('app-standard-store-header')).not.toBeNull(); - tenantState.set({ ...tenant, header_type: 'immersive' }); + tenantState.set({ + ...tenant, + header_type: 'immersive', + extras: { + immersiveHero: { + eyebrow: 'Encendé tu', + title: 'experiencia', + description: 'Reservá tu entrada', + }, + heroCarousel: ['/first.jpg', '/second.jpg'], + }, + }); 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-standard-store-header')).toBeNull(); + expect(element.querySelector('app-immersive-store-header')).not.toBeNull(); + expect(element.querySelector('.immersive-header__eyebrow')?.textContent).toContain( + 'Encendé tu', + ); + expect(element.querySelector('.immersive-header__title')?.textContent).toContain('experiencia'); + expect(element.querySelector('.immersive-header__description')?.textContent).toContain( + 'Reservá tu entrada', + ); + expect(element.querySelectorAll('.continuous-carousel__set')).toHaveLength(2); + expect(element.querySelector('.continuous-carousel__image')?.getAttribute('src')).toBe( + '/first.jpg', + ); expect(element.querySelector('app-cart-icon')).not.toBeNull(); }); 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 803f6c1..56eb770 100644 --- a/src/app/core/layout/store-layout/store-layout.component.ts +++ b/src/app/core/layout/store-layout/store-layout.component.ts @@ -68,6 +68,7 @@ export class StoreLayoutComponent implements OnInit { protected readonly isCartOpen = signal(false); protected readonly isCheckoutRoute = signal(this.isCheckoutUrl(this.router.url)); + protected readonly isHomeRoute = signal(this.isHomeUrl(this.router.url)); protected readonly checkoutRemainingSeconds = this.checkoutCountdownService.remainingSeconds; protected readonly isCreatingPurchase = signal(false); protected readonly displayCart = computed(() => this.tenant()?.display_cart ?? true); @@ -210,6 +211,7 @@ export class StoreLayoutComponent implements OnInit { .subscribe((event) => { const isCheckoutRoute = this.isCheckoutUrl(event.urlAfterRedirects); this.isCheckoutRoute.set(isCheckoutRoute); + this.isHomeRoute.set(this.isHomeUrl(event.urlAfterRedirects)); if (isCheckoutRoute) { this.isCartOpen.set(false); @@ -233,6 +235,11 @@ export class StoreLayoutComponent implements OnInit { return primarySegments[0]?.path === 'checkout'; } + private isHomeUrl(url: string): boolean { + const primarySegments = this.router.parseUrl(url).root.children[PRIMARY_OUTLET]?.segments ?? []; + return primarySegments.length === 0; + } + protected onLoginClick(): void { void this.router.navigate(['/login']); } diff --git a/src/app/core/services/tenant.interface.ts b/src/app/core/services/tenant.interface.ts index 87770b4..6455be9 100644 --- a/src/app/core/services/tenant.interface.ts +++ b/src/app/core/services/tenant.interface.ts @@ -72,6 +72,11 @@ export interface TenantEvent { export interface WebsiteExtras { carousel?: string[]; heroCarousel?: string[]; + immersiveHero?: { + eyebrow: string; + title: string; + description: string; + }; heroConfig?: HeroConfig | null; additionalInfoConfig?: AdditionalInfoConfig | null; [extraName: string]: unknown; diff --git a/src/app/shared/components/continuous-image-carousel/continuous-image-carousel.component.html b/src/app/shared/components/continuous-image-carousel/continuous-image-carousel.component.html new file mode 100644 index 0000000..cb477b6 --- /dev/null +++ b/src/app/shared/components/continuous-image-carousel/continuous-image-carousel.component.html @@ -0,0 +1,18 @@ +@if (trackImages().length) { + +} diff --git a/src/app/shared/components/continuous-image-carousel/continuous-image-carousel.component.scss b/src/app/shared/components/continuous-image-carousel/continuous-image-carousel.component.scss new file mode 100644 index 0000000..a67349f --- /dev/null +++ b/src/app/shared/components/continuous-image-carousel/continuous-image-carousel.component.scss @@ -0,0 +1,71 @@ +:host { + display: block; + width: 100%; +} + +.continuous-carousel { + --gap: 1rem; + --card-width: calc((100vw - 4 * var(--gap)) / 5); + + width: 100%; + overflow: hidden; +} + +.continuous-carousel__track { + display: flex; + width: max-content; + animation: continuous-carousel-scroll var(--duration) linear infinite; +} + +.continuous-carousel__set { + display: flex; + flex: none; + gap: var(--gap); + padding-right: var(--gap); +} + +.continuous-carousel__image { + display: block; + flex: none; + width: var(--card-width); + aspect-ratio: 1.45; + object-fit: cover; + border-radius: 2px; + background: #e5e5e5; +} + +.continuous-carousel:hover .continuous-carousel__track, +.continuous-carousel:focus-within .continuous-carousel__track { + animation-play-state: paused; +} + +@keyframes continuous-carousel-scroll { + to { + transform: translateX(-50%); + } +} + +@media (max-width: 767px) { + .continuous-carousel { + --gap: 0.75rem; + --card-width: calc((100vw - 2 * var(--gap)) / 3); + } +} + +@media (max-width: 480px) { + .continuous-carousel { + --card-width: calc((100vw - var(--gap)) / 2); + } +} + +@media (prefers-reduced-motion: reduce) { + .continuous-carousel { + overflow-x: auto; + } + .continuous-carousel__track { + animation: none; + } + .continuous-carousel__set[aria-hidden='true'] { + display: none; + } +} diff --git a/src/app/shared/components/continuous-image-carousel/continuous-image-carousel.component.ts b/src/app/shared/components/continuous-image-carousel/continuous-image-carousel.component.ts new file mode 100644 index 0000000..8ae99c5 --- /dev/null +++ b/src/app/shared/components/continuous-image-carousel/continuous-image-carousel.component.ts @@ -0,0 +1,24 @@ +import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; + +@Component({ + selector: 'app-continuous-image-carousel', + templateUrl: './continuous-image-carousel.component.html', + styleUrl: './continuous-image-carousel.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ContinuousImageCarouselComponent { + readonly images = input.required(); + readonly ariaLabel = input('Imágenes destacadas'); + + // One full set must cover the viewport before the duplicate set takes over. + protected readonly trackImages = computed(() => { + const images = this.images().filter(Boolean); + if (images.length === 0) return []; + + return Array.from( + { length: Math.max(images.length, 5) }, + (_, index) => images[index % images.length], + ); + }); + protected readonly durationSeconds = computed(() => Math.max(this.trackImages().length * 4, 16)); +}