feat: add immersive store header with carousel and Smooch font
- Implemented a new immersive store header component with a background image, logo, hero content, and a continuous image carousel. - Integrated the Smooch font for enhanced typography in the header. - Updated the store layout to support the new immersive header type. - Added necessary styles and templates for the immersive header and carousel components. - Enhanced unit tests to cover the new immersive header functionality.
This commit is contained in:
BIN
public/fonts/KaushanScript/KaushanScript-Regular.ttf
Normal file
BIN
public/fonts/KaushanScript/KaushanScript-Regular.ttf
Normal file
Binary file not shown.
95
public/fonts/KaushanScript/OFL.txt
Normal file
95
public/fonts/KaushanScript/OFL.txt
Normal file
@@ -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.
|
||||
93
public/fonts/Smooch/OFL.txt
Normal file
93
public/fonts/Smooch/OFL.txt
Normal file
@@ -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.
|
||||
BIN
public/fonts/Smooch/Smooch-Regular.ttf
Normal file
BIN
public/fonts/Smooch/Smooch-Regular.ttf
Normal file
Binary file not shown.
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
<header
|
||||
class="immersive-header"
|
||||
[class.immersive-header--compact]="!showHero()"
|
||||
[class.immersive-header--has-image]="showHero() && !!backgroundImageUrl()"
|
||||
>
|
||||
<div
|
||||
class="immersive-header__backdrop"
|
||||
[style.background-image]="backgroundImageUrl() ? 'url(' + backgroundImageUrl() + ')' : null"
|
||||
>
|
||||
@if (showHero() && backgroundImageUrl(); as imageUrl) {
|
||||
<img class="immersive-header__backdrop-size" [src]="imageUrl" alt="" aria-hidden="true" />
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="immersive-header__content">
|
||||
<nav class="immersive-header__nav" aria-label="Navegación de la tienda">
|
||||
<a routerLink="/" class="immersive-header__brand" aria-label="Ir al inicio">
|
||||
@if (logoUrl()) {
|
||||
<img [src]="logoUrl()" alt="Logo del comercio" />
|
||||
}
|
||||
</a>
|
||||
|
||||
<div class="immersive-header__actions">
|
||||
@if (displaySearchBar()) {
|
||||
<form class="immersive-header__search" role="search" (submit)="submitSearch($event)">
|
||||
<label class="visually-hidden" for="immersive-search-input">Buscar productos</label>
|
||||
<input
|
||||
id="immersive-search-input"
|
||||
type="search"
|
||||
placeholder="Buscar"
|
||||
autocomplete="off"
|
||||
[formControl]="searchControl"
|
||||
[attr.aria-invalid]="showSearchError()"
|
||||
[attr.aria-describedby]="showSearchError() ? 'immersive-search-error' : null"
|
||||
(input)="showSearchError.set(false)"
|
||||
/>
|
||||
<button type="submit" aria-label="Buscar">
|
||||
<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
|
||||
</button>
|
||||
@if (showSearchError()) {
|
||||
<span id="immersive-search-error" class="immersive-header__search-error" role="alert"
|
||||
>Ingresá al menos 3 caracteres.</span
|
||||
>
|
||||
}
|
||||
</form>
|
||||
}
|
||||
|
||||
@if (ticketsMenu(); as menu) {
|
||||
<button class="immersive-header__tickets" type="button" (click)="ticketsClick.emit()">
|
||||
{{ menu.label }}
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (displayCart()) {
|
||||
<app-cart-icon
|
||||
[quantity]="cartDisabled() ? null : cartQuantity()"
|
||||
[disabled]="cartDisabled()"
|
||||
[ariaLabel]="
|
||||
cartDisabled() ? 'Carrito no disponible durante el checkout' : 'Carrito de compras'
|
||||
"
|
||||
(click)="onCartClick()"
|
||||
/>
|
||||
}
|
||||
|
||||
<div class="immersive-header__account">
|
||||
<app-icon-button
|
||||
variant="user"
|
||||
ariaLabel="Mi cuenta"
|
||||
ariaControls="immersive-user-dropdown"
|
||||
[ariaExpanded]="isUserDropdownOpen()"
|
||||
(clicked)="toggleUserDropdown()"
|
||||
/>
|
||||
@if (isUserDropdownOpen()) {
|
||||
<app-user-dropdown
|
||||
id="immersive-user-dropdown"
|
||||
[user]="user()"
|
||||
[accountNavigationMenus]="accountNavigationMenus()"
|
||||
(logoutClick)="onLogoutClick()"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@if (checkoutRemainingTime(); as remainingTime) {
|
||||
<div class="immersive-header__timer" role="timer" aria-label="Tiempo restante de compra">
|
||||
Tiempo restante de compra: <strong>{{ remainingTime }}</strong>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showHero()) {
|
||||
@if (heroContent(); as content) {
|
||||
<div class="immersive-header__copy">
|
||||
<p class="immersive-header__eyebrow">{{ content.eyebrow }}</p>
|
||||
<h1 class="immersive-header__title">{{ content.title }}</h1>
|
||||
<p class="immersive-header__description">{{ content.description }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (carouselImages().length) {
|
||||
<app-continuous-image-carousel
|
||||
[images]="carouselImages()"
|
||||
ariaLabel="Imágenes del evento"
|
||||
/>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</header>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<HTMLElement>>(ElementRef);
|
||||
|
||||
readonly logoUrl = input<string | null>(null);
|
||||
readonly backgroundImageUrl = input<string | null>(null);
|
||||
readonly heroContent = input<WebsiteExtras['immersiveHero']>(undefined);
|
||||
readonly carouselImages = input<readonly string[]>([]);
|
||||
readonly showHero = input(true);
|
||||
readonly cartQuantity = input(0);
|
||||
readonly user = input<AuthUser | null>(null);
|
||||
readonly isAuthenticated = input(false);
|
||||
readonly accountNavigationMenus = input<readonly Menu[]>([]);
|
||||
readonly ticketsMenu = input<Menu | null>(null);
|
||||
readonly displaySearchBar = 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>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,26 @@
|
||||
@switch (headerType()) {
|
||||
@case ('immersive') {
|
||||
<!-- Use standard until the immersive header is implemented. -->
|
||||
<ng-container *ngTemplateOutlet="standardHeader" />
|
||||
<app-immersive-store-header
|
||||
[logoUrl]="logoUrl()"
|
||||
[backgroundImageUrl]="backgroundImageUrl()"
|
||||
[heroContent]="heroContent()"
|
||||
[carouselImages]="carouselImages()"
|
||||
[showHero]="showHero()"
|
||||
[cartQuantity]="cartQuantity()"
|
||||
[user]="user()"
|
||||
[isAuthenticated]="isAuthenticated()"
|
||||
[accountNavigationMenus]="accountNavigationMenus()"
|
||||
[ticketsMenu]="ticketsMenu()"
|
||||
[displaySearchBar]="displaySeachBar()"
|
||||
[displayCart]="displayCart()"
|
||||
[cartDisabled]="cartDisabled()"
|
||||
[checkoutRemainingSeconds]="checkoutRemainingSeconds()"
|
||||
(cartClick)="cartClick.emit()"
|
||||
(ticketsClick)="ticketsClick.emit()"
|
||||
(loginClick)="loginClick.emit()"
|
||||
(logoutClick)="logoutClick.emit()"
|
||||
(searchSubmit)="searchSubmit.emit($event)"
|
||||
/>
|
||||
}
|
||||
@default {
|
||||
<ng-container *ngTemplateOutlet="standardHeader" />
|
||||
|
||||
@@ -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<StoreHeaderType>('standard');
|
||||
readonly logoUrl = input<string | null>(null);
|
||||
readonly backgroundImageUrl = input<string | null>(null);
|
||||
readonly heroContent = input<WebsiteExtras['immersiveHero']>(undefined);
|
||||
readonly carouselImages = input<readonly string[]>([]);
|
||||
readonly showHero = input(true);
|
||||
readonly cartQuantity = input<number>(0);
|
||||
readonly user = input<AuthUser | null>(null);
|
||||
readonly isAuthenticated = input<boolean>(false);
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
<app-store-header
|
||||
ngSkipHydration
|
||||
[headerType]="tenant()?.header_type ?? 'standard'"
|
||||
[heroContent]="tenant()?.extras?.immersiveHero"
|
||||
[carouselImages]="tenant()?.extras?.heroCarousel ?? []"
|
||||
[showHero]="isHomeRoute()"
|
||||
[cartQuantity]="cartQuantity()"
|
||||
[logoUrl]="tenant()?.header_logo ?? null"
|
||||
[backgroundImageUrl]="tenant()?.header_bg_image ?? null"
|
||||
|
||||
@@ -248,7 +248,7 @@ describe('StoreLayoutComponent', () => {
|
||||
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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
@if (trackImages().length) {
|
||||
<div class="continuous-carousel" role="region" [attr.aria-label]="ariaLabel()">
|
||||
<div class="continuous-carousel__track" [style.--duration]="durationSeconds() + 's'">
|
||||
@for (set of [0, 1]; track set) {
|
||||
<div class="continuous-carousel__set" [attr.aria-hidden]="set === 1 ? 'true' : null">
|
||||
@for (image of trackImages(); track $index) {
|
||||
<img
|
||||
class="continuous-carousel__image"
|
||||
[src]="image"
|
||||
alt=""
|
||||
[loading]="set === 0 && $index < 5 ? 'eager' : 'lazy'"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 string[]>();
|
||||
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));
|
||||
}
|
||||
Reference in New Issue
Block a user