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-face {
|
||||||
font-family: "Gotham";
|
font-family: "Gotham";
|
||||||
src: url("/fonts/Gotham/Gotham Thin.otf") format("opentype");
|
src: url("/fonts/Gotham/Gotham Thin.otf") format("opentype");
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
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 { Tenant } from '../services/tenant.interface';
|
||||||
import { TenantService } from '../services/tenant.service';
|
import { TenantService } from '../services/tenant.service';
|
||||||
|
import { TenantUrlSerializer } from '../services/tenant-url.serializer';
|
||||||
import { hasMenuGuard } from './menu.guard';
|
import { hasMenuGuard } from './menu.guard';
|
||||||
|
|
||||||
const tenant: Tenant = {
|
const tenant: Tenant = {
|
||||||
@@ -36,9 +37,10 @@ function runGuard(menuCode: string, url: string, currentTenant: Tenant | null) {
|
|||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
provideRouter([]),
|
provideRouter([]),
|
||||||
|
{ provide: UrlSerializer, useClass: TenantUrlSerializer },
|
||||||
{
|
{
|
||||||
provide: TenantService,
|
provide: TenantService,
|
||||||
useValue: { tenant: () => currentTenant },
|
useValue: { tenant: () => currentTenant, getTenant: () => currentTenant },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -73,4 +75,16 @@ describe('hasMenuGuard', () => {
|
|||||||
|
|
||||||
expect(runGuard('index', '/', tenantWithoutMenus)).toBe(true);
|
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 { 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 { findMenu } from '../services/menu.utils';
|
||||||
import { TenantService } from '../services/tenant.service';
|
import { TenantService } from '../services/tenant.service';
|
||||||
|
|
||||||
@@ -16,7 +16,9 @@ export const hasMenuGuard = (menuCode: string): CanActivateFn => {
|
|||||||
|
|
||||||
const hasMenu = findMenu(tenant.menues ?? [], menuCode) !== undefined;
|
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;
|
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()) {
|
@switch (headerType()) {
|
||||||
@case ('immersive') {
|
@case ('immersive') {
|
||||||
<!-- Use standard until the immersive header is implemented. -->
|
<app-immersive-store-header
|
||||||
<ng-container *ngTemplateOutlet="standardHeader" />
|
[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 {
|
@default {
|
||||||
<ng-container *ngTemplateOutlet="standardHeader" />
|
<ng-container *ngTemplateOutlet="standardHeader" />
|
||||||
|
|||||||
@@ -1,20 +1,24 @@
|
|||||||
import { NgTemplateOutlet } from '@angular/common';
|
import { NgTemplateOutlet } from '@angular/common';
|
||||||
import { Component, input, output } from '@angular/core';
|
import { Component, input, output } from '@angular/core';
|
||||||
import { AuthUser } from '../../../services/auth/auth.interfaces';
|
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';
|
import { StandardStoreHeaderComponent } from './standard-store-header.component';
|
||||||
|
|
||||||
export type StoreHeaderType = 'standard' | 'immersive';
|
export type StoreHeaderType = 'standard' | 'immersive';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-store-header',
|
selector: 'app-store-header',
|
||||||
imports: [NgTemplateOutlet, StandardStoreHeaderComponent],
|
imports: [NgTemplateOutlet, StandardStoreHeaderComponent, ImmersiveStoreHeaderComponent],
|
||||||
templateUrl: './store-header.component.html',
|
templateUrl: './store-header.component.html',
|
||||||
})
|
})
|
||||||
export class StoreHeaderComponent {
|
export class StoreHeaderComponent {
|
||||||
readonly headerType = input<StoreHeaderType>('standard');
|
readonly headerType = input<StoreHeaderType>('standard');
|
||||||
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 heroContent = input<WebsiteExtras['immersiveHero']>(undefined);
|
||||||
|
readonly carouselImages = input<readonly string[]>([]);
|
||||||
|
readonly showHero = input(true);
|
||||||
readonly cartQuantity = input<number>(0);
|
readonly cartQuantity = input<number>(0);
|
||||||
readonly user = input<AuthUser | null>(null);
|
readonly user = input<AuthUser | null>(null);
|
||||||
readonly isAuthenticated = input<boolean>(false);
|
readonly isAuthenticated = input<boolean>(false);
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
<app-store-header
|
<app-store-header
|
||||||
ngSkipHydration
|
ngSkipHydration
|
||||||
[headerType]="tenant()?.header_type ?? 'standard'"
|
[headerType]="tenant()?.header_type ?? 'standard'"
|
||||||
|
[heroContent]="tenant()?.extras?.immersiveHero"
|
||||||
|
[carouselImages]="tenant()?.extras?.heroCarousel ?? []"
|
||||||
|
[showHero]="isHomeRoute()"
|
||||||
[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"
|
||||||
|
|||||||
@@ -248,7 +248,7 @@ 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', () => {
|
it('renders the immersive header with configured copy and carousel images', () => {
|
||||||
const fixture = TestBed.createComponent(StoreLayoutComponent);
|
const fixture = TestBed.createComponent(StoreLayoutComponent);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
@@ -257,12 +257,34 @@ describe('StoreLayoutComponent', () => {
|
|||||||
expect(header.componentInstance.headerType()).toBe('standard');
|
expect(header.componentInstance.headerType()).toBe('standard');
|
||||||
expect(element.querySelector('app-standard-store-header')).not.toBeNull();
|
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();
|
fixture.detectChanges();
|
||||||
|
|
||||||
expect(header.componentInstance.headerType()).toBe('immersive');
|
expect(header.componentInstance.headerType()).toBe('immersive');
|
||||||
expect(element.querySelector('app-standard-store-header')).not.toBeNull();
|
expect(element.querySelector('app-standard-store-header')).toBeNull();
|
||||||
expect(element.querySelector('[data-testid="store-header-brand-slot"]')).not.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();
|
expect(element.querySelector('app-cart-icon')).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export class StoreLayoutComponent implements OnInit {
|
|||||||
|
|
||||||
protected readonly isCartOpen = signal(false);
|
protected readonly isCartOpen = signal(false);
|
||||||
protected readonly isCheckoutRoute = signal(this.isCheckoutUrl(this.router.url));
|
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 checkoutRemainingSeconds = this.checkoutCountdownService.remainingSeconds;
|
||||||
protected readonly isCreatingPurchase = signal(false);
|
protected readonly isCreatingPurchase = signal(false);
|
||||||
protected readonly displayCart = computed(() => this.tenant()?.display_cart ?? true);
|
protected readonly displayCart = computed(() => this.tenant()?.display_cart ?? true);
|
||||||
@@ -210,6 +211,7 @@ export class StoreLayoutComponent implements OnInit {
|
|||||||
.subscribe((event) => {
|
.subscribe((event) => {
|
||||||
const isCheckoutRoute = this.isCheckoutUrl(event.urlAfterRedirects);
|
const isCheckoutRoute = this.isCheckoutUrl(event.urlAfterRedirects);
|
||||||
this.isCheckoutRoute.set(isCheckoutRoute);
|
this.isCheckoutRoute.set(isCheckoutRoute);
|
||||||
|
this.isHomeRoute.set(this.isHomeUrl(event.urlAfterRedirects));
|
||||||
|
|
||||||
if (isCheckoutRoute) {
|
if (isCheckoutRoute) {
|
||||||
this.isCartOpen.set(false);
|
this.isCartOpen.set(false);
|
||||||
@@ -233,6 +235,11 @@ export class StoreLayoutComponent implements OnInit {
|
|||||||
return primarySegments[0]?.path === 'checkout';
|
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 {
|
protected onLoginClick(): void {
|
||||||
void this.router.navigate(['/login']);
|
void this.router.navigate(['/login']);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,11 @@ export interface TenantEvent {
|
|||||||
export interface WebsiteExtras {
|
export interface WebsiteExtras {
|
||||||
carousel?: string[];
|
carousel?: string[];
|
||||||
heroCarousel?: string[];
|
heroCarousel?: string[];
|
||||||
|
immersiveHero?: {
|
||||||
|
eyebrow: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
heroConfig?: HeroConfig | null;
|
heroConfig?: HeroConfig | null;
|
||||||
additionalInfoConfig?: AdditionalInfoConfig | null;
|
additionalInfoConfig?: AdditionalInfoConfig | null;
|
||||||
[extraName: string]: unknown;
|
[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