Compare commits

...

13 Commits

Author SHA1 Message Date
e7d24607a6 feat(menus): guard public event routes 2026-09-21 09:16:44 -03:00
865893b96b feat(events): add category-filtered event pages 2026-09-21 09:16:44 -03:00
14bda25c56 feat(event-detail): enhance event detail page with improved layout and additional features
feat(counter-to): add countdown component for event timing
feat(fonts): include Gotham Book font for better typography
fix(location-map): make map height responsive with CSS variables
2026-09-18 16:37:39 -03:00
6997550767 feat(events): add multi-event storefront pages 2026-09-18 14:33:49 -03:00
657ef1d89f feat(events): add storefront event API client 2026-09-18 14:33:42 -03:00
018b1d8ec7 chore(storefront): log tenant bootstrap data in development 2026-09-18 11:42:33 -03:00
93a9ca7e5c fix(storefront): read active event social media from bootstrap 2026-09-18 11:32:29 -03:00
6ce734b2c2 feat(store-header): implement immersive categories and update header properties 2026-09-18 09:49:51 -03:00
7f4aa81d4b 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.
2026-09-18 09:11:09 -03:00
6927da6342 feat(store-header): implement standard store header with dynamic properties 2026-09-17 16:49:12 -03:00
2f98fd6234 feat(tenant): add header_type property to Tenant interface 2026-09-17 16:02:21 -03:00
de5c533d85 refactor(storefront): consume storefront website type 2026-09-17 14:26:04 -03:00
90a0ac3e36 Merge pull request 'feature/refund' (#6) from feature/refund into homologacion
Reviewed-on: https://gitea.quo.ar/tbianchini/shopit-front/pulls/6
2026-09-17 15:19:23 +00:00
47 changed files with 2308 additions and 629 deletions

Binary file not shown.

View 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.

View 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.

Binary file not shown.

View File

@@ -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");
@@ -30,6 +38,14 @@
font-display: swap;
}
@font-face {
font-family: "Gotham Book";
src: url("/fonts/Gotham/Gotham Book.otf") format("opentype");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Gotham";
src: url("/fonts/Gotham/Gotham Italic.otf") format("opentype");

View File

@@ -14,7 +14,6 @@ describe('isStoreCatalogRequest', () => {
});
it.each([
['POST', 'http://localhost:8000/api/tenants/demo/catalog-items'],
['GET', 'http://localhost:8000/api/tenants/demo/productos'],
['GET', 'http://localhost:8000/api/tenants/demo/categories/7'],
['GET', 'http://localhost:8000/api/tenants/demo/catalog-items/42/variant-options'],

View File

@@ -17,7 +17,7 @@ import { TenantUrlSerializer } from './core/services/tenant-url.serializer';
export function isStoreCatalogRequest(request: HttpRequest<unknown>): boolean {
return (
request.method === 'GET' &&
/\/api\/tenants\/[^/?#]+\/(?:catalog(?:\/featured-groups\/\d+\/items)?|catalog-items(?:\/\d+)?)\/?(?:[?#]|$)/.test(
/\/api\/tenants\/[^/?#]+\/(?:catalog(?:\/featured-groups\/\d+\/items)?|catalog-items(?:\/\d+)?|events(?:\/\d+)?)\/?(?:[?#]|$)/.test(
request.urlWithParams,
)
);

View File

@@ -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);
});
});

View File

@@ -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;
}

View File

@@ -0,0 +1,103 @@
<header
class="immersive-header"
[class.immersive-header--compact]="!showHero()"
>
<div
class="immersive-header__backdrop"
[style.background-image]="backgroundImageUrl() ? 'url(' + backgroundImageUrl() + ')' : null"
></div>
<div class="immersive-header__content">
<nav class="immersive-header__nav container-xl px-3 px-md-4" 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 container-xl px-3 px-md-4" 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>

View File

@@ -0,0 +1,224 @@
:host {
display: block;
}
.immersive-header {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr);
isolation: isolate;
min-height: 640px;
color: #fff;
background: #f5f5f5;
}
.immersive-header__content {
grid-area: 1 / 1;
position: relative;
z-index: 1;
min-width: 0;
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::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
180deg,
rgba(0, 0, 0, 0.48) 0%,
rgba(0, 0, 0, 0.4) 47%,
#f5f5f5 100%
);
}
.immersive-header__nav {
position: relative;
z-index: 1;
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;
margin-top: 0.75rem;
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;
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,192 @@
<header
class="store-layout__header"
[style.background-image]="backgroundImageUrl() ? 'url(' + backgroundImageUrl() + ')' : null"
>
<div class="store-layout__header-main">
<div class="container-xl px-3 px-md-4 py-3 py-lg-4">
<div class="row align-items-center gy-3 gy-md-0">
<div class="col-auto col-md-auto order-1 store-layout__brand-column">
<a
routerLink="/"
class="store-layout__brand-slot d-inline-flex align-items-center gap-3 text-decoration-none"
data-testid="store-header-brand-slot"
aria-label="Logo del comercio"
>
@if (logoUrl()) {
<img class="store-layout__brand-logo" [src]="logoUrl()" alt="Logo del comercio" />
} @else {
<div class="store-layout__brand-logo-shell" aria-hidden="true">
<span class="store-layout__brand-logo-skeleton"></span>
</div>
}
</a>
</div>
@if (displaySeachBar()) {
<div class="col-12 px-0 d-md-none order-3">
<hr class="store-layout__mobile-separator" />
</div>
<div class="col-12 col-md order-4 order-md-2">
<div class="store-layout__search-wrapper ms-md-auto">
<div class="input-group store-layout__search" role="search">
<label class="visually-hidden" for="store-search-input">Buscar productos</label>
<input
id="store-search-input"
type="search"
class="form-control store-layout__search-input border-end-0 rounded-start"
placeholder="Buscar productos"
autocomplete="off"
[attr.aria-describedby]="showSearchError() ? 'store-search-error' : null"
[attr.aria-invalid]="showSearchError()"
[minlength]="minSearchLength"
[formControl]="searchControl"
(input)="clearSearchError()"
(keydown.enter)="submitSearch($event)"
/>
<button
type="button"
class="btn store-layout__search-button border-start-0 rounded-end px-3"
aria-label="Buscar"
(click)="submitSearch()"
>
<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
</button>
</div>
@if (showSearchError()) {
<p id="store-search-error" class="store-layout__search-error mb-0" role="alert">
Ingresá al menos {{ minSearchLength }} caracteres para buscar.
</p>
}
</div>
</div>
}
<div
class="store-layout__actions-column col-auto col-md-auto order-2 order-md-3 ms-auto d-flex flex-row align-items-center justify-content-end gap-2 gap-md-3"
>
@if (ticketsMenu(); as menu) {
<app-button
variant="primary"
hostClass="store-layout__tickets-btn-host"
data-testid="store-header-tickets"
(click)="ticketsClick.emit()"
>
{{ menu.label }}
</app-button>
}
@if (displayCart()) {
<app-cart-icon
[quantity]="cartDisabled() ? null : cartQuantity()"
[disabled]="cartDisabled()"
[ariaLabel]="
cartDisabled() ? 'Carrito no disponible durante el checkout' : 'Carrito de compras'
"
[title]="cartDisabled() ? 'Carrito no disponible durante el checkout' : 'Carrito'"
(click)="onCartClick()"
/>
}
<div class="store-layout__user-menu d-none d-md-inline-flex position-relative">
<app-icon-button
variant="user"
ariaLabel="Mi cuenta"
ariaControls="store-user-dropdown"
[ariaExpanded]="isUserDropdownOpen()"
title="Mi cuenta"
(clicked)="toggleUserDropdown()"
/>
@if (isUserDropdownOpen()) {
<app-user-dropdown
id="store-user-dropdown"
[user]="user()"
[accountNavigationMenus]="accountNavigationMenus()"
(logoutClick)="onLogoutClick()"
/>
}
</div>
<div class="store-layout__mobile-menu d-inline-flex d-md-none position-relative">
<app-icon-button
variant="bars"
ariaLabel="Menu"
ariaControls="store-mobile-menu"
[ariaExpanded]="isMobileMenuOpen()"
title="Menu"
(clicked)="toggleMobileMenu()"
/>
@if (isMobileMenuOpen()) {
<div
id="store-mobile-menu"
class="position-absolute end-0 bg-white rounded shadow mt-2 py-2 store-layout__mobile-dropdown-menu"
>
<a
class="dropdown-item d-flex align-items-center gap-2 px-3 py-2 text-decoration-none store-layout__mobile-dropdown-item"
(click)="onMobileAccountClick()"
>
<i class="fa-regular fa-circle-user fa-lg"></i>
<span class="fw-medium">Mi cuenta</span>
</a>
@if (displayCategories()) {
<hr class="my-1 border-secondary opacity-25" />
@for (category of categories(); track category.id) {
<a
class="dropdown-item px-3 py-2 text-decoration-none store-layout__mobile-dropdown-item"
(click)="onCategorySelect(category)"
>
<span class="fw-medium">{{ category.nombre }}</span>
</a>
}
}
</div>
}
</div>
</div>
</div>
</div>
</div>
<div class="store-layout__categories d-none d-md-block">
<div class="container-xl h-100 px-3 px-md-4 d-flex align-items-center">
@if (displayCategories()) {
<div class="store-layout__category-menu">
<button
type="button"
class="btn btn-link d-inline-flex align-items-center gap-2 px-0 py-3 text-uppercase fw-bold text-decoration-none store-layout__category-trigger"
aria-label="Categorias"
aria-controls="store-category-dropdown"
[attr.aria-expanded]="isCategoryDropdownOpen()"
(click)="toggleCategoryDropdown()"
>
<span>Categorias</span>
<i class="fa-solid fa-chevron-down" aria-hidden="true"></i>
</button>
<app-category-dropdown
id="store-category-dropdown"
[categories]="categories()"
[open]="isCategoryDropdownOpen()"
(categorySelect)="onCategorySelect($event)"
/>
</div>
}
@if (checkoutRemainingTime(); as remainingTime) {
<div
class="store-layout__checkout-timer ms-auto d-none d-md-flex align-items-baseline gap-3"
role="timer"
aria-label="Tiempo restante de compra"
>
<span class="store-layout__checkout-timer-label">Tiempo restante de compra:</span>
<span class="store-layout__checkout-timer-value">{{ remainingTime }}</span>
</div>
}
</div>
</div>
</header>

View File

@@ -0,0 +1,246 @@
.store-layout__header {
color: #ffffff;
background-color: var(--tenant-header-bg);
background-repeat: no-repeat;
background-position: center;
background-size: cover;
}
.store-layout__header-main {
border-bottom: 1px solid var(--border-color);
}
.store-layout__categories {
height: 3.5rem;
}
.store-layout__checkout-timer {
color: var(--tenant-primary);
font-weight: 700;
white-space: nowrap;
}
.store-layout__checkout-timer-label {
font-size: 13px;
}
.store-layout__checkout-timer-value {
font-size: 20px;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.store-layout__brand-slot {
min-width: 150px;
}
.store-layout__brand-column {
max-width: 12rem;
}
.store-layout__brand-logo-shell {
position: relative;
width: min(100%, 12rem);
height: 2.75rem;
}
.store-layout__brand-logo {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
}
.store-layout__brand-logo-skeleton {
display: block;
width: 100%;
height: 100%;
border-radius: 0.75rem;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0.12) 0%,
rgba(255, 255, 255, 0.22) 50%,
rgba(255, 255, 255, 0.12) 100%
);
background-size: 200% 100%;
animation: store-header-logo-skeleton 1.2s ease-in-out infinite;
}
.store-layout__search-wrapper {
width: 100%;
min-width: 0;
}
@media (min-width: 768px) {
.store-layout__search-wrapper {
width: min(100%, 260px);
}
.store-layout__actions-column {
padding-left: 0;
}
::ng-deep .store-layout__tickets-btn-host {
flex: 0 0 clamp(8rem, 15vw, 13.5rem);
width: clamp(8rem, 15vw, 13.5rem);
}
}
.store-layout__search {
width: 100%;
}
.store-layout__search-error {
margin-top: 0.25rem;
font-size: 0.75rem;
line-height: 1.2;
color: var(--tenant-danger, #dc3545);
}
.store-layout__search-input {
color: #666666;
border-color: var(--border-color);
background-color: rgba(255, 255, 255, 0.96);
}
.store-layout__search-input::placeholder {
color: #666666;
}
.store-layout__search-input:focus {
border-color: var(--border-color);
box-shadow: none;
outline: none;
}
.store-layout__search-button {
color: #a0a0a0;
border-color: var(--border-color);
background-color: rgba(255, 255, 255, 0.96);
}
.store-layout__search-button:hover,
.store-layout__search-button:focus-visible {
color: #8a8a8a;
border-color: var(--border-color);
background-color: #ffffff;
}
.store-layout__action-button {
color: #a0a0a0;
}
.store-layout__action-button:hover,
.store-layout__action-button:focus-visible {
color: #8a8a8a;
}
.store-layout__user-menu {
position: relative;
display: inline-flex;
}
.store-layout__category-trigger {
font-size: 0.8rem;
letter-spacing: 0.02em;
color: #a0a0a0;
}
.store-layout__category-trigger:hover,
.store-layout__category-trigger:focus-visible {
color: #8a8a8a;
}
.store-layout__category-menu {
position: relative;
display: inline-flex;
flex-direction: column;
}
.store-layout__category-menu app-category-dropdown {
position: absolute;
z-index: 1050;
top: 100%;
left: 0;
}
@keyframes store-header-logo-skeleton {
from {
background-position: 200% 0;
}
to {
background-position: -200% 0;
}
}
@media (max-width: 767.98px) {
.store-layout__brand-slot {
min-width: 0;
max-width: 150px;
}
.store-layout__actions-column {
flex: 1 1 auto;
width: auto;
min-width: 0;
}
::ng-deep .store-layout__tickets-btn-host {
flex: 1 1 0;
width: auto;
min-width: 0;
max-width: 50vw;
}
.store-layout__mobile-separator {
border-color: var(--border-color);
opacity: 1;
margin: 0 -1rem;
}
}
@media (max-width: 399.98px) {
.store-layout__brand-column {
flex: 1 1 0;
width: auto;
min-width: 0;
max-width: none;
padding-right: 0.25rem;
}
.store-layout__brand-slot {
width: 100%;
max-width: 6.5rem;
}
.store-layout__actions-column {
flex: 1 1 auto;
width: auto;
min-width: 0;
margin-left: 0 !important;
padding-left: 0.25rem;
gap: 0.25rem !important;
}
::ng-deep .store-layout__tickets-btn-host .btn {
padding-inline: 0.75rem;
font-size: 0.875rem;
}
}
.store-layout__mobile-dropdown-menu {
top: 100%;
min-width: 220px;
z-index: 1050;
}
.store-layout__mobile-dropdown-item {
cursor: pointer;
color: #8a8a8a;
&:hover,
&:focus-visible {
color: #666666;
}
}

View File

@@ -0,0 +1,172 @@
import {
Component,
computed,
ElementRef,
HostListener,
inject,
input,
output,
signal,
} from '@angular/core';
import { Router, RouterLink } from '@angular/router';
import { AuthUser } from '../../../services/auth/auth.interfaces';
import { CartIconComponent } from '../../../../shared/components/cart-icon/cart-icon.component';
import { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.component';
import { UserDropdownComponent } from './user-dropdown/user-dropdown.component';
import { Menu } from '../../../services/tenant.interface';
import { ButtonComponent } from '../../../../shared/components/button/button.component';
import { ReactiveFormsModule, FormControl } from '@angular/forms';
import { Category } from '../../../services/tenant.interface';
import { CategoryDropdownComponent } from './category-dropdown/category-dropdown.component';
@Component({
selector: 'app-standard-store-header',
imports: [
ButtonComponent,
CartIconComponent,
IconButtonComponent,
RouterLink,
ReactiveFormsModule,
UserDropdownComponent,
CategoryDropdownComponent,
],
templateUrl: './standard-store-header.component.html',
styleUrl: './standard-store-header.component.scss',
})
export class StandardStoreHeaderComponent {
private readonly elementRef = inject(ElementRef<HTMLElement>);
private readonly router = inject(Router);
readonly logoUrl = input<string | null>(null);
readonly backgroundImageUrl = input<string | null>(null);
readonly cartQuantity = input<number>(0);
readonly user = input<AuthUser | null>(null);
readonly isAuthenticated = input<boolean>(false);
readonly accountNavigationMenus = input<readonly Menu[]>([]);
readonly ticketsMenu = input<Menu | null>(null);
readonly categories = input<readonly Category[]>([]);
readonly displayCategories = input(true);
readonly displaySeachBar = input(true);
readonly displayCart = input(true);
readonly cartDisabled = input(false);
readonly checkoutRemainingSeconds = input<number | null>(null);
readonly cartClick = output<void>();
readonly ticketsClick = output<void>();
readonly loginClick = output<void>();
readonly logoutClick = output<void>();
readonly searchSubmit = output<string>();
readonly categorySelect = output<Category>();
protected readonly isUserDropdownOpen = signal(false);
protected readonly isCategoryDropdownOpen = signal(false);
protected readonly isMobileMenuOpen = signal(false);
protected readonly minSearchLength = 3;
protected readonly showSearchError = signal(false);
protected readonly searchControl = new FormControl('', { nonNullable: true });
protected readonly checkoutRemainingTime = computed(() => {
const remainingSeconds = this.checkoutRemainingSeconds();
if (remainingSeconds === null) {
return null;
}
const minutes = Math.floor(remainingSeconds / 60);
const seconds = remainingSeconds % 60;
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
});
protected onCartClick(): void {
if (this.cartDisabled()) {
return;
}
this.cartClick.emit();
}
@HostListener('document:click', ['$event'])
protected onDocumentClick(event: MouseEvent): void {
if (!this.isUserDropdownOpen() && !this.isCategoryDropdownOpen() && !this.isMobileMenuOpen()) {
return;
}
const target = event.target;
if (target instanceof Node && !this.elementRef.nativeElement.contains(target)) {
this.isUserDropdownOpen.set(false);
this.isCategoryDropdownOpen.set(false);
this.isMobileMenuOpen.set(false);
}
}
@HostListener('document:keydown.escape')
protected onEscapeKey(): void {
this.isUserDropdownOpen.set(false);
this.isCategoryDropdownOpen.set(false);
this.isMobileMenuOpen.set(false);
}
protected toggleUserDropdown(): void {
if (!this.isAuthenticated()) {
this.loginClick.emit();
return;
}
this.isUserDropdownOpen.update((isOpen) => !isOpen);
}
protected onLoginClick(): void {
this.isUserDropdownOpen.set(false);
this.loginClick.emit();
}
protected onLogoutClick(): void {
this.isUserDropdownOpen.set(false);
this.logoutClick.emit();
}
protected submitSearch(event?: Event): void {
event?.preventDefault();
const term = this.searchControl.value.trim();
if (term.length < this.minSearchLength) {
this.showSearchError.set(true);
return;
}
this.showSearchError.set(false);
this.searchSubmit.emit(term);
}
protected clearSearchError(): void {
this.showSearchError.set(false);
}
protected toggleCategoryDropdown(): void {
this.isCategoryDropdownOpen.update((isOpen) => !isOpen);
}
protected onCategorySelect(category: Category): void {
this.isCategoryDropdownOpen.set(false);
this.isMobileMenuOpen.set(false);
this.categorySelect.emit(category);
}
protected toggleMobileMenu(): void {
this.isMobileMenuOpen.update((isOpen) => !isOpen);
}
protected onMobileAccountClick(): void {
this.isMobileMenuOpen.set(false);
if (!this.isAuthenticated()) {
this.loginClick.emit();
} else {
const menus = this.accountNavigationMenus();
if (menus.length > 0) {
this.router.navigate([menus[0].route]);
} else {
this.router.navigate(['/cuenta']);
}
}
}
}

View File

@@ -1,192 +1,68 @@
<header
class="store-layout__header"
[style.background-image]="backgroundImageUrl() ? 'url(' + backgroundImageUrl() + ')' : null"
>
<div class="store-layout__header-main">
<div class="container-xl px-3 px-md-4 py-3 py-lg-4">
<div class="row align-items-center gy-3 gy-md-0">
<div class="col-auto col-md-auto order-1 store-layout__brand-column">
<a
routerLink="/"
class="store-layout__brand-slot d-inline-flex align-items-center gap-3 text-decoration-none"
data-testid="store-header-brand-slot"
aria-label="Logo del comercio"
>
@if (logoUrl()) {
<img class="store-layout__brand-logo" [src]="logoUrl()" alt="Logo del comercio" />
} @else {
<div class="store-layout__brand-logo-shell" aria-hidden="true">
<span class="store-layout__brand-logo-skeleton"></span>
</div>
}
</a>
</div>
@if (displaySeachBar()) {
<div class="col-12 px-0 d-md-none order-3">
<hr class="store-layout__mobile-separator" />
</div>
<div class="col-12 col-md order-4 order-md-2">
<div class="store-layout__search-wrapper ms-md-auto">
<div class="input-group store-layout__search" role="search">
<label class="visually-hidden" for="store-search-input">Buscar productos</label>
<input
id="store-search-input"
type="search"
class="form-control store-layout__search-input border-end-0 rounded-start"
placeholder="Buscar productos"
autocomplete="off"
[attr.aria-describedby]="showSearchError() ? 'store-search-error' : null"
[attr.aria-invalid]="showSearchError()"
[minlength]="minSearchLength"
[formControl]="searchControl"
(input)="clearSearchError()"
(keydown.enter)="submitSearch($event)"
/>
<button
type="button"
class="btn store-layout__search-button border-start-0 rounded-end px-3"
aria-label="Buscar"
(click)="submitSearch()"
>
<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
</button>
</div>
@if (showSearchError()) {
<p id="store-search-error" class="store-layout__search-error mb-0" role="alert">
Ingresá al menos {{ minSearchLength }} caracteres para buscar.
</p>
}
</div>
</div>
}
<div
class="store-layout__actions-column col-auto col-md-auto order-2 order-md-3 ms-auto d-flex flex-row align-items-center justify-content-end gap-2 gap-md-3"
>
@if (ticketsMenu(); as menu) {
<app-button
variant="primary"
hostClass="store-layout__tickets-btn-host"
data-testid="store-header-tickets"
(click)="ticketsClick.emit()"
>
{{ menu.label }}
</app-button>
}
@if (displayCart()) {
<app-cart-icon
[quantity]="cartDisabled() ? null : cartQuantity()"
[disabled]="cartDisabled()"
[ariaLabel]="
cartDisabled() ? 'Carrito no disponible durante el checkout' : 'Carrito de compras'
"
[title]="cartDisabled() ? 'Carrito no disponible durante el checkout' : 'Carrito'"
(click)="onCartClick()"
/>
}
<div class="store-layout__user-menu d-none d-md-inline-flex position-relative">
<app-icon-button
variant="user"
ariaLabel="Mi cuenta"
ariaControls="store-user-dropdown"
[ariaExpanded]="isUserDropdownOpen()"
title="Mi cuenta"
(clicked)="toggleUserDropdown()"
/>
@if (isUserDropdownOpen()) {
<app-user-dropdown
id="store-user-dropdown"
@switch (headerType()) {
@case ('immersive') {
<app-immersive-store-header
[logoUrl]="logoUrl()"
[backgroundImageUrl]="backgroundImageUrl()"
[heroContent]="heroContent()"
[carouselImages]="carouselImages()"
[showHero]="showHero()"
[cartQuantity]="cartQuantity()"
[user]="user()"
[isAuthenticated]="isAuthenticated()"
[accountNavigationMenus]="accountNavigationMenus()"
(logoutClick)="onLogoutClick()"
[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)"
/>
}
</div>
@default {
<ng-container *ngTemplateOutlet="standardHeader" />
}
}
<div class="store-layout__mobile-menu d-inline-flex d-md-none position-relative">
<app-icon-button
variant="bars"
ariaLabel="Menu"
ariaControls="store-mobile-menu"
[ariaExpanded]="isMobileMenuOpen()"
title="Menu"
(clicked)="toggleMobileMenu()"
<ng-template #standardHeader>
<app-standard-store-header
[logoUrl]="logoUrl()"
[backgroundImageUrl]="backgroundImageUrl()"
[cartQuantity]="cartQuantity()"
[user]="user()"
[isAuthenticated]="isAuthenticated()"
[accountNavigationMenus]="accountNavigationMenus()"
[ticketsMenu]="ticketsMenu()"
[categories]="categories()"
[displayCategories]="displayCategories()"
[displaySeachBar]="displaySeachBar()"
[displayCart]="displayCart()"
[cartDisabled]="cartDisabled()"
[checkoutRemainingSeconds]="checkoutRemainingSeconds()"
(cartClick)="cartClick.emit()"
(ticketsClick)="ticketsClick.emit()"
(loginClick)="loginClick.emit()"
(logoutClick)="logoutClick.emit()"
(searchSubmit)="searchSubmit.emit($event)"
(categorySelect)="categorySelect.emit($event)"
/>
</ng-template>
@if (isMobileMenuOpen()) {
<div
id="store-mobile-menu"
class="position-absolute end-0 bg-white rounded shadow mt-2 py-2 store-layout__mobile-dropdown-menu"
>
<a
class="dropdown-item d-flex align-items-center gap-2 px-3 py-2 text-decoration-none store-layout__mobile-dropdown-item"
(click)="onMobileAccountClick()"
>
<i class="fa-regular fa-circle-user fa-lg"></i>
<span class="fw-medium">Mi cuenta</span>
</a>
@if (displayCategories()) {
<hr class="my-1 border-secondary opacity-25" />
@if (headerType() === 'immersive' && displayCategories() && categories().length) {
<nav class="immersive-categories" aria-label="Categorías">
<div class="immersive-categories__list">
@for (category of categories(); track category.id) {
<a
class="dropdown-item px-3 py-2 text-decoration-none store-layout__mobile-dropdown-item"
(click)="onCategorySelect(category)"
>
<span class="fw-medium">{{ category.nombre }}</span>
</a>
}
}
</div>
}
</div>
</div>
</div>
</div>
</div>
<div class="store-layout__categories d-none d-md-block">
<div class="container-xl h-100 px-3 px-md-4 d-flex align-items-center">
@if (displayCategories()) {
<div class="store-layout__category-menu">
<button
type="button"
class="btn btn-link d-inline-flex align-items-center gap-2 px-0 py-3 text-uppercase fw-bold text-decoration-none store-layout__category-trigger"
aria-label="Categorias"
aria-controls="store-category-dropdown"
[attr.aria-expanded]="isCategoryDropdownOpen()"
(click)="toggleCategoryDropdown()"
class="immersive-categories__item"
(click)="categorySelect.emit(category)"
>
<span>Categorias</span>
<i class="fa-solid fa-chevron-down" aria-hidden="true"></i>
{{ category.nombre | uppercase }}
</button>
<app-category-dropdown
id="store-category-dropdown"
[categories]="categories()"
[open]="isCategoryDropdownOpen()"
(categorySelect)="onCategorySelect($event)"
/>
</div>
}
@if (checkoutRemainingTime(); as remainingTime) {
<div
class="store-layout__checkout-timer ms-auto d-none d-md-flex align-items-baseline gap-3"
role="timer"
aria-label="Tiempo restante de compra"
>
<span class="store-layout__checkout-timer-label">Tiempo restante de compra:</span>
<span class="store-layout__checkout-timer-value">{{ remainingTime }}</span>
</div>
}
</div>
</div>
</header>
</nav>
}

View File

@@ -1,246 +1,37 @@
.store-layout__header {
color: #ffffff;
background-color: var(--tenant-header-bg);
background-repeat: no-repeat;
background-position: center;
background-size: cover;
:host {
display: block;
}
.store-layout__header-main {
border-bottom: 1px solid var(--border-color);
.immersive-categories {
width: 100%;
overflow-x: auto;
background: #f5f5f5;
}
.store-layout__categories {
height: 3.5rem;
.immersive-categories__list {
display: flex;
align-items: center;
justify-content: center;
gap: clamp(1rem, 3vw, 2.5rem);
width: max-content;
min-width: 100%;
min-height: 3.5rem;
padding: 0.75rem 1rem;
}
.store-layout__checkout-timer {
color: var(--tenant-primary);
.immersive-categories__item {
flex: none;
border: 0;
padding: 0;
background: transparent;
color: #666666;
font-size: 15px;
font-weight: 700;
white-space: nowrap;
}
.store-layout__checkout-timer-label {
font-size: 13px;
}
.store-layout__checkout-timer-value {
font-size: 20px;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.store-layout__brand-slot {
min-width: 150px;
}
.store-layout__brand-column {
max-width: 12rem;
}
.store-layout__brand-logo-shell {
position: relative;
width: min(100%, 12rem);
height: 2.75rem;
}
.store-layout__brand-logo {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
}
.store-layout__brand-logo-skeleton {
display: block;
width: 100%;
height: 100%;
border-radius: 0.75rem;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0.12) 0%,
rgba(255, 255, 255, 0.22) 50%,
rgba(255, 255, 255, 0.12) 100%
);
background-size: 200% 100%;
animation: store-header-logo-skeleton 1.2s ease-in-out infinite;
}
.store-layout__search-wrapper {
width: 100%;
min-width: 0;
}
@media (min-width: 768px) {
.store-layout__search-wrapper {
width: min(100%, 260px);
}
.store-layout__actions-column {
padding-left: 0;
}
::ng-deep .store-layout__tickets-btn-host {
flex: 0 0 clamp(8rem, 15vw, 13.5rem);
width: clamp(8rem, 15vw, 13.5rem);
}
}
.store-layout__search {
width: 100%;
}
.store-layout__search-error {
margin-top: 0.25rem;
font-size: 0.75rem;
line-height: 1.2;
color: var(--tenant-danger, #dc3545);
}
.store-layout__search-input {
color: #666666;
border-color: var(--border-color);
background-color: rgba(255, 255, 255, 0.96);
}
.store-layout__search-input::placeholder {
color: #666666;
}
.store-layout__search-input:focus {
border-color: var(--border-color);
box-shadow: none;
outline: none;
}
.store-layout__search-button {
color: #a0a0a0;
border-color: var(--border-color);
background-color: rgba(255, 255, 255, 0.96);
}
.store-layout__search-button:hover,
.store-layout__search-button:focus-visible {
color: #8a8a8a;
border-color: var(--border-color);
background-color: #ffffff;
}
.store-layout__action-button {
color: #a0a0a0;
}
.store-layout__action-button:hover,
.store-layout__action-button:focus-visible {
color: #8a8a8a;
}
.store-layout__user-menu {
position: relative;
display: inline-flex;
}
.store-layout__category-trigger {
font-size: 0.8rem;
letter-spacing: 0.02em;
color: #a0a0a0;
}
.store-layout__category-trigger:hover,
.store-layout__category-trigger:focus-visible {
color: #8a8a8a;
}
.store-layout__category-menu {
position: relative;
display: inline-flex;
flex-direction: column;
}
.store-layout__category-menu app-category-dropdown {
position: absolute;
z-index: 1050;
top: 100%;
left: 0;
}
@keyframes store-header-logo-skeleton {
from {
background-position: 200% 0;
}
to {
background-position: -200% 0;
}
}
@media (max-width: 767.98px) {
.store-layout__brand-slot {
min-width: 0;
max-width: 150px;
}
.store-layout__actions-column {
flex: 1 1 auto;
width: auto;
min-width: 0;
}
::ng-deep .store-layout__tickets-btn-host {
flex: 1 1 0;
width: auto;
min-width: 0;
max-width: 50vw;
}
.store-layout__mobile-separator {
border-color: var(--border-color);
opacity: 1;
margin: 0 -1rem;
}
}
@media (max-width: 399.98px) {
.store-layout__brand-column {
flex: 1 1 0;
width: auto;
min-width: 0;
max-width: none;
padding-right: 0.25rem;
}
.store-layout__brand-slot {
width: 100%;
max-width: 6.5rem;
}
.store-layout__actions-column {
flex: 1 1 auto;
width: auto;
min-width: 0;
margin-left: 0 !important;
padding-left: 0.25rem;
gap: 0.25rem !important;
}
::ng-deep .store-layout__tickets-btn-host .btn {
padding-inline: 0.75rem;
font-size: 0.875rem;
}
}
.store-layout__mobile-dropdown-menu {
top: 100%;
min-width: 220px;
z-index: 1050;
}
.store-layout__mobile-dropdown-item {
cursor: pointer;
color: #8a8a8a;
&:hover,
&:focus-visible {
color: #666666;
}
}
.immersive-categories__item:hover,
.immersive-categories__item:focus-visible {
text-decoration: underline;
}

View File

@@ -1,44 +1,25 @@
import {
Component,
computed,
ElementRef,
HostListener,
inject,
input,
output,
signal,
} from '@angular/core';
import { Router, RouterLink } from '@angular/router';
import { NgTemplateOutlet, UpperCasePipe } from '@angular/common';
import { Component, input, output } from '@angular/core';
import { AuthUser } from '../../../services/auth/auth.interfaces';
import { CartIconComponent } from '../../../../shared/components/cart-icon/cart-icon.component';
import { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.component';
import { UserDropdownComponent } from './user-dropdown/user-dropdown.component';
import { Menu } from '../../../services/tenant.interface';
import { ButtonComponent } from '../../../../shared/components/button/button.component';
import { ReactiveFormsModule, FormControl } from '@angular/forms';
import { Category } from '../../../services/tenant.interface';
import { CategoryDropdownComponent } from './category-dropdown/category-dropdown.component';
import { Category, Menu, 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: [
ButtonComponent,
CartIconComponent,
IconButtonComponent,
RouterLink,
ReactiveFormsModule,
UserDropdownComponent,
CategoryDropdownComponent,
],
imports: [NgTemplateOutlet, UpperCasePipe, StandardStoreHeaderComponent, ImmersiveStoreHeaderComponent],
templateUrl: './store-header.component.html',
styleUrl: './store-header.component.scss',
})
export class StoreHeaderComponent {
private readonly elementRef = inject(ElementRef<HTMLElement>);
private readonly router = inject(Router);
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);
@@ -50,123 +31,11 @@ export class StoreHeaderComponent {
readonly displayCart = input(true);
readonly cartDisabled = input(false);
readonly checkoutRemainingSeconds = input<number | null>(null);
readonly cartClick = output<void>();
readonly ticketsClick = output<void>();
readonly loginClick = output<void>();
readonly logoutClick = output<void>();
readonly searchSubmit = output<string>();
readonly categorySelect = output<Category>();
protected readonly isUserDropdownOpen = signal(false);
protected readonly isCategoryDropdownOpen = signal(false);
protected readonly isMobileMenuOpen = signal(false);
protected readonly minSearchLength = 3;
protected readonly showSearchError = signal(false);
protected readonly searchControl = new FormControl('', { nonNullable: true });
protected readonly checkoutRemainingTime = computed(() => {
const remainingSeconds = this.checkoutRemainingSeconds();
if (remainingSeconds === null) {
return null;
}
const minutes = Math.floor(remainingSeconds / 60);
const seconds = remainingSeconds % 60;
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
});
protected onCartClick(): void {
if (this.cartDisabled()) {
return;
}
this.cartClick.emit();
}
@HostListener('document:click', ['$event'])
protected onDocumentClick(event: MouseEvent): void {
if (!this.isUserDropdownOpen() && !this.isCategoryDropdownOpen() && !this.isMobileMenuOpen()) {
return;
}
const target = event.target;
if (target instanceof Node && !this.elementRef.nativeElement.contains(target)) {
this.isUserDropdownOpen.set(false);
this.isCategoryDropdownOpen.set(false);
this.isMobileMenuOpen.set(false);
}
}
@HostListener('document:keydown.escape')
protected onEscapeKey(): void {
this.isUserDropdownOpen.set(false);
this.isCategoryDropdownOpen.set(false);
this.isMobileMenuOpen.set(false);
}
protected toggleUserDropdown(): void {
if (!this.isAuthenticated()) {
this.loginClick.emit();
return;
}
this.isUserDropdownOpen.update((isOpen) => !isOpen);
}
protected onLoginClick(): void {
this.isUserDropdownOpen.set(false);
this.loginClick.emit();
}
protected onLogoutClick(): void {
this.isUserDropdownOpen.set(false);
this.logoutClick.emit();
}
protected submitSearch(event?: Event): void {
event?.preventDefault();
const term = this.searchControl.value.trim();
if (term.length < this.minSearchLength) {
this.showSearchError.set(true);
return;
}
this.showSearchError.set(false);
this.searchSubmit.emit(term);
}
protected clearSearchError(): void {
this.showSearchError.set(false);
}
protected toggleCategoryDropdown(): void {
this.isCategoryDropdownOpen.update((isOpen) => !isOpen);
}
protected onCategorySelect(category: Category): void {
this.isCategoryDropdownOpen.set(false);
this.isMobileMenuOpen.set(false);
this.categorySelect.emit(category);
}
protected toggleMobileMenu(): void {
this.isMobileMenuOpen.update((isOpen) => !isOpen);
}
protected onMobileAccountClick(): void {
this.isMobileMenuOpen.set(false);
if (!this.isAuthenticated()) {
this.loginClick.emit();
} else {
const menus = this.accountNavigationMenus();
if (menus.length > 0) {
this.router.navigate([menus[0].route]);
} else {
this.router.navigate(['/cuenta']);
}
}
}
}

View File

@@ -1,6 +1,10 @@
<div class="store-layout d-flex flex-column flex-grow-1">
<app-store-header
ngSkipHydration
[headerType]="tenant()?.header_type ?? 'standard'"
[heroContent]="tenant()?.extras?.immersiveHero"
[carouselImages]="tenant()?.extras?.immersiveHeroCarousel ?? []"
[showHero]="isHomeRoute()"
[cartQuantity]="cartQuantity()"
[logoUrl]="tenant()?.header_logo ?? null"
[backgroundImageUrl]="tenant()?.header_bg_image ?? null"

View File

@@ -23,6 +23,7 @@ import { AuthService } from '../../services/auth/auth.service';
import { AuthUser } from '../../services/auth/auth.interfaces';
import { StoreLayoutComponent } from './store-layout.component';
import { StoreHeaderComponent } from './store-header/store-header.component';
import { StandardStoreHeaderComponent } from './store-header/standard-store-header.component';
import { CheckoutService } from '../../services/checkout.service';
import { CheckoutCountdownService } from '../../services/checkout-countdown.service';
import { TenantUrlSerializer } from '../../services/tenant-url.serializer';
@@ -247,6 +248,81 @@ describe('StoreLayoutComponent', () => {
expect(compiled.querySelector('app-store-footer .store-layout__footer')).not.toBeNull();
});
it('renders the immersive header with configured copy and carousel images', () => {
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const header = fixture.debugElement.query(By.directive(StoreHeaderComponent));
expect(header.componentInstance.headerType()).toBe('standard');
expect(element.querySelector('app-standard-store-header')).not.toBeNull();
tenantState.set({
...tenant,
header_type: 'immersive',
extras: {
immersiveHero: {
eyebrow: 'Encendé tu',
title: 'experiencia',
description: 'Reservá tu entrada',
},
immersiveHeroCarousel: ['/first.jpg', '/second.jpg'],
},
});
fixture.detectChanges();
expect(header.componentInstance.headerType()).toBe('immersive');
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();
});
it('shows immersive categories below the header and navigates on selection', () => {
tenantState.set({
...tenant,
header_type: 'immersive',
display_categories: true,
categories: [{ id: 11, nombre: 'Música', subcategories: [] }],
});
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const header = element.querySelector('app-immersive-store-header header');
const categories = element.querySelector('app-store-header .immersive-categories');
const categoryButton = categories?.querySelector<HTMLButtonElement>('button');
expect(header).not.toBeNull();
expect(categories).not.toBeNull();
expect(header?.contains(categories)).toBe(false);
expect(categoryButton?.textContent?.trim()).toBe('MÚSICA');
const navigate = vi.spyOn(TestBed.inject(Router), 'navigate');
categoryButton?.click();
expect(navigate).toHaveBeenCalledWith(['/categoria', 11], { queryParams: { page: 1 } });
tenantState.set({
...tenant,
header_type: 'immersive',
display_categories: false,
categories: [{ id: 11, nombre: 'Música', subcategories: [] }],
});
fixture.detectChanges();
expect(element.querySelector('.immersive-categories')).toBeNull();
});
it('opens the cart when requested through the openCart query parameter', () => {
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
@@ -327,7 +403,7 @@ describe('StoreLayoutComponent', () => {
expect(compiled.querySelector('app-cart')).toBeNull();
expect(compiled.querySelector('.store-layout__cart-overlay')).toBeNull();
const header = fixture.debugElement.query(By.directive(StoreHeaderComponent));
const header = fixture.debugElement.query(By.directive(StandardStoreHeaderComponent));
(header.componentInstance as any).toggleMobileMenu();
fixture.detectChanges();
@@ -378,7 +454,7 @@ describe('StoreLayoutComponent', () => {
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
const header = fixture.debugElement.query(By.directive(StoreHeaderComponent));
const header = fixture.debugElement.query(By.directive(StandardStoreHeaderComponent));
const searchButton = header.query(By.css('.store-layout__search-button'));
(header.componentInstance as any).searchControl.setValue(' zapatillas ');
@@ -396,7 +472,7 @@ describe('StoreLayoutComponent', () => {
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
const header = fixture.debugElement.query(By.directive(StoreHeaderComponent));
const header = fixture.debugElement.query(By.directive(StandardStoreHeaderComponent));
const input = header.query(By.css('#store-search-input'));
const keyboardEvent = {
preventDefault: vi.fn(),
@@ -417,7 +493,7 @@ describe('StoreLayoutComponent', () => {
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
const header = fixture.debugElement.query(By.directive(StoreHeaderComponent));
const header = fixture.debugElement.query(By.directive(StandardStoreHeaderComponent));
const input = header.query(By.css('#store-search-input'));
const searchButton = header.query(By.css('.store-layout__search-button'));

View File

@@ -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']);
}

View File

@@ -0,0 +1,35 @@
import { ApiPaginatedResponse } from '../api-paginated-response.interface';
export interface StoreEvent {
id: number;
event_category_id: number | null;
title: string;
subtitle: string | null;
description: string | null;
location: string | null;
exact_location: { latitude: number; longitude: number } | null;
date_text: string | null;
start_time?: string | null;
starts_at?: string | null;
dates?: { id: number; date: string; time_start: string }[];
social_media?: { code: string; url: string }[];
attachment_id: number | null;
image: string | null;
catalog_items?: {
id: number;
name: string;
description: string | null;
price: string;
image: string | null;
requires_selection: boolean;
maximum_quantity: number | null;
variants: {
id: number;
price: string;
event_date_ids: number[];
maximum_quantity: number | null;
}[];
}[];
}
export type StoreEventPage = ApiPaginatedResponse<StoreEvent[]>;

View File

@@ -0,0 +1,24 @@
import { inject, Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { BaseApiService } from '../base-api.service';
import { ApiResponse } from '../api-response.interface';
import { TenantService } from '../tenant.service';
import { StoreEvent, StoreEventPage } from './event.interface';
@Injectable({ providedIn: 'root' })
export class EventService extends BaseApiService {
private readonly tenantService = inject(TenantService);
list(page: number, query = '', categoryId?: number): Observable<StoreEventPage> {
return this.http.get<StoreEventPage>(`${this.tenantService.getTenantApiUrl()}/events`, {
params: { page, ...(query ? { q: query } : {}), ...(categoryId ? { category_id: categoryId } : {}) },
});
}
get(id: number): Observable<ApiResponse<StoreEvent>> {
return this.http.get<ApiResponse<StoreEvent>>(
`${this.tenantService.getTenantApiUrl()}/events/${id}`,
);
}
}

View File

@@ -19,16 +19,6 @@ export interface BankAccount {
cvu: string;
}
export interface WebsiteType {
codigo: string;
nombre: string;
primary_color: string | null;
secondary_color: string | null;
danger_color: string | null;
success_color: string | null;
site_logo: string | null;
}
export interface HeroConfig {
background_image_id?: string | ResponsiveImage | null;
title_html?: string | null;
@@ -74,13 +64,24 @@ export interface ActiveEventDate {
}
export interface TenantEvent {
id: number;
title: string;
location: string;
subtitle: string | null;
description: string | null;
location: string | null;
date_text: string | null;
dates: ActiveEventDate[];
social_media: SocialMedia[];
}
export interface WebsiteExtras {
carousel?: string[];
immersiveHeroCarousel?: string[];
immersiveHero?: {
eyebrow: string;
title: string;
description: string;
};
heroConfig?: HeroConfig | null;
additionalInfoConfig?: AdditionalInfoConfig | null;
[extraName: string]: unknown;
@@ -131,9 +132,8 @@ export interface Tenant {
footer_logo: string;
header_bg_image?: string | null;
footer_bg_image?: string | null;
website_type_code?: string | null;
website_type?: WebsiteType | null;
event_date_text?: string | null;
storefront_website_type_code?: string | null;
header_type?: 'standard' | 'immersive';
extras?: WebsiteExtras;
event?: TenantEvent | null;
selected_bank_account_id?: number | null;

View File

@@ -183,6 +183,10 @@ export class TenantService extends BaseApiService {
}
private setReady(tenant: Tenant): void {
if (isPlatformBrowser(this.platformId) && !environment.production) {
console.info('[Tenant bootstrap]', tenant);
}
this.ensureAssetPreconnect(tenant.asset_url);
this.tenantState.set(tenant);
this.statusState.set('ready');

View File

@@ -0,0 +1,19 @@
<a class="event-card" [routerLink]="['/evento', event().id]">
<div class="event-card__image">
@if (event().image) {
<img [src]="event().image" [alt]="event().title" />
}
</div>
<div class="event-card__body">
<h2 class="event-card__title">{{ event().title | uppercase }}</h2>
@if (event().subtitle) {
<p class="event-card__subtitle">{{ event().subtitle }}</p>
}
@if (event().date_text) {
<p class="event-card__meta"><i class="fa-regular fa-calendar" aria-hidden="true"></i>{{ event().date_text }}</p>
}
@if (event().location) {
<p class="event-card__meta"><i class="fa-solid fa-location-dot" aria-hidden="true"></i>{{ event().location }}</p>
}
</div>
</a>

View File

@@ -0,0 +1,11 @@
:host { display: block; min-width: 0; }
.event-card { display: block; height: 100%; color: inherit; background: #fff; text-decoration: none; box-shadow: 0 12px 30px rgba(0, 0, 0, .05); transition: transform .2s, box-shadow .2s; }
.event-card:hover { transform: translateY(-3px); box-shadow: 0 16px 32px rgba(0, 0, 0, .1); }
.event-card:focus-visible { outline: 2px solid var(--tenant-primary); outline-offset: 3px; }
.event-card__image { width: 100%; aspect-ratio: 1.43; background: #e5e6e7; }
.event-card__image img { width: 100%; height: 100%; object-fit: cover; display: block; }
.event-card__body { padding: 1rem 1.1rem 1.2rem; }
.event-card__title { margin: 0; font-size: 15px; font-weight: 700; line-height: 1.3; color: #666666; }
.event-card__subtitle { margin: .25rem 0 .8rem; font-size: 12px; font-weight: 400; color: #666666; }
.event-card__meta { display: flex; align-items: baseline; gap: .45rem; margin: .2rem 0 0; font-size: 9px; font-weight: 400; font-style: italic; color: #666666; }
.event-card__meta i { width: .75rem; flex: none; }

View File

@@ -0,0 +1,16 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { UpperCasePipe } from '@angular/common';
import { RouterLink } from '@angular/router';
import { StoreEvent } from '../../../../core/services/event/event.interface';
@Component({
selector: 'app-event-card',
imports: [RouterLink, UpperCasePipe],
templateUrl: './event-card.component.html',
styleUrl: './event-card.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EventCardComponent {
readonly event = input.required<StoreEvent>();
}

View File

@@ -0,0 +1,20 @@
@if (loading()) {
<p class="event-list__message">Cargando eventos...</p>
} @else if (error()) {
<p class="event-list__message" role="alert">No pudimos cargar los eventos.</p>
} @else if (results(); as page) {
@if (page.data.length) {
<div class="event-list__grid">
@for (event of page.data; track event.id) {
<app-event-card [event]="event" />
}
</div>
@if (page.meta.last_page > 1) {
<div class="event-list__pagination">
<app-paginator [page]="page.meta.current_page" [totalPages]="page.meta.last_page" (pageChange)="changePage($event)" />
</div>
}
} @else {
<p class="event-list__message">{{ search() ? 'No encontramos eventos para tu búsqueda.' : 'No hay eventos disponibles en este momento.' }}</p>
}
}

View File

@@ -0,0 +1,5 @@
.event-list__grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: clamp(1.25rem, 3vw, 2.5rem); }
.event-list__pagination { display: flex; justify-content: center; margin-top: 3rem; }
.event-list__message { text-align: center; padding: 2rem 0; }
@media (max-width: 767.98px) { .event-list__grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 479.98px) { .event-list__grid { grid-template-columns: 1fr; } }

View File

@@ -0,0 +1,68 @@
import { ChangeDetectionStrategy, Component, DestroyRef, inject, input, OnInit, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router } from '@angular/router';
import { catchError, combineLatest, distinctUntilChanged, map, of, switchMap, tap } from 'rxjs';
import { EventService } from '../../../../core/services/event/event.service';
import { StoreEventPage } from '../../../../core/services/event/event.interface';
import { PaginatorComponent } from '../../../../shared/components/paginator/paginator.component';
import { EventCardComponent } from '../event-card/event-card.component';
@Component({
selector: 'app-event-list',
imports: [EventCardComponent, PaginatorComponent],
templateUrl: './event-list.component.html',
styleUrl: './event-list.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EventListComponent implements OnInit {
private readonly eventService = inject(EventService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly destroyRef = inject(DestroyRef);
readonly search = input(false);
readonly categoryId = input<number | null>(null);
protected readonly results = signal<StoreEventPage | null>(null);
protected readonly loading = signal(true);
protected readonly error = signal(false);
protected readonly query = signal('');
ngOnInit(): void {
combineLatest([this.route.queryParamMap, this.route.paramMap]).pipe(
map(([params, routeParams]) => {
const requestedPage = Number(params.get('page'));
return {
page: Number.isSafeInteger(requestedPage) && requestedPage > 0 ? requestedPage : 1,
query: this.search() ? (params.get('q') ?? '').trim() : '',
categoryId: this.categoryId() === null ? null : Number(routeParams.get('id')),
};
}),
distinctUntilChanged((a, b) => a.page === b.page && a.query === b.query && a.categoryId === b.categoryId),
tap(({ query }) => {
this.query.set(query);
this.loading.set(true);
this.error.set(false);
this.results.set(null);
}),
switchMap(({ page, query, categoryId }) => this.eventService.list(page, query, categoryId ?? undefined).pipe(
catchError(() => {
this.error.set(true);
return of(null);
}),
)),
takeUntilDestroyed(this.destroyRef),
).subscribe((results) => {
this.results.set(results);
this.loading.set(false);
});
}
protected changePage(page: number): void {
void this.router.navigate([], {
relativeTo: this.route,
queryParams: { page: page === 1 ? null : page },
queryParamsHandling: 'merge',
});
}
}

View File

@@ -0,0 +1,31 @@
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
import { TenantService } from '../../../../core/services/tenant.service';
import { EventListComponent } from '../../components/event-list/event-list.component';
@Component({
selector: 'app-event-category-page',
imports: [EventListComponent],
template: `
@if (category(); as selectedCategory) {
<section aria-labelledby="event-category-title">
<h1 id="event-category-title" class="h3 mb-4">{{ selectedCategory.nombre }}</h1>
<app-event-list [categoryId]="selectedCategory.id" />
</section>
} @else {
<p role="alert">La categoría no existe.</p>
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EventCategoryPageComponent {
private readonly route = inject(ActivatedRoute);
private readonly tenant = inject(TenantService).tenant;
private readonly params = toSignal(this.route.paramMap, { initialValue: this.route.snapshot.paramMap });
protected readonly category = computed(() => {
const id = Number(this.params().get('id'));
return this.tenant()?.categories.find((category) => category.id === id) ?? null;
});
}

View File

@@ -0,0 +1,111 @@
@if (event(); as item) {
<div class="event-detail row gx-sm-4 gx-lg-5">
<aside class="event-detail__sidebar col-12 col-sm-4">
<div class="event-detail__image" [class.event-detail__image--placeholder]="!item.image">
@if (item.image) {
<img [src]="item.image" [alt]="item.title" />
} @else {
<span aria-hidden="true"><i class="fa-regular fa-calendar"></i></span>
}
</div>
@if (item.starts_at) {
<app-counter-to [startsAt]="item.starts_at" />
}
@if (item.description) {
<section class="event-detail__description" aria-labelledby="event-description-title">
<h2 id="event-description-title">Descripción</h2>
<p>{{ item.description }}</p>
</section>
}
@if (item.social_media?.length) {
<section class="event-detail__social" aria-labelledby="event-social-title">
<h2 id="event-social-title">Información</h2>
@for (social of item.social_media; track social.code) {
<a [href]="social.url" target="_blank" rel="noopener noreferrer">
<i [class]="socialIcon(social.code)" aria-hidden="true"></i>
<span>{{ socialLabel(social.code) }}</span>
</a>
}
</section>
}
@if (mapLocations().length) {
<app-location-map
class="event-detail__map"
[locations]="mapLocations()"
ariaLabel="Ubicación del evento"
mapStyle="minimal-light"
/>
}
</aside>
<main class="event-detail__main col-12 col-sm-8">
<header class="event-detail__header">
<h1>{{ item.title }}</h1>
@if (item.subtitle) { <p class="event-detail__subtitle">{{ item.subtitle }}</p> }
<div class="event-detail__meta">
@if (item.date_text) {
<p><i class="fa-regular fa-calendar" aria-hidden="true"></i>
{{ item.date_text }}@if (item.dates?.length === 1 && item.start_time) { · {{ item.start_time.slice(0, 5) }} h }
</p>
}
@if (item.location) {
<p><i class="fa-solid fa-location-dot" aria-hidden="true"></i>{{ item.location }}</p>
}
</div>
</header>
<section class="event-detail__products" aria-labelledby="event-products-title">
<div class="event-detail__section-heading"><h2 id="event-products-title">Productos</h2></div>
@if ((item.dates?.length ?? 0) > 1) {
<label class="event-detail__date-label" for="event-date-select">Seleccioná fecha</label>
<select id="event-date-select" class="event-detail__date-select" [value]="selectedDateId()" (change)="selectDate($event)">
@for (date of item.dates; track date.id) {
<option [value]="date.id">{{ formatDate(date.date) }} · {{ date.time_start.slice(0, 5) }} h</option>
}
</select>
}
@if (item.catalog_items?.length) {
<div class="event-detail__product-list">
@for (product of item.catalog_items; track product.id) {
<div class="event-detail__product row align-items-center gx-3 gy-2">
<h3 class="col-12 col-sm">{{ product.name }}</h3>
<div class="event-detail__product-action col-auto ms-auto ms-sm-0">
@if (needsSelection(product)) {
<a [routerLink]="['/producto', product.id]">Elegir opciones</a>
} @else {
<app-quantity-selector
[quantity]="quantityFor(product.id)"
[min]="0"
[max]="maximumQuantityFor(product)"
size="medium"
(decrease)="changeQuantity(product, -1)"
(increase)="changeQuantity(product, 1)"
/>
}
</div>
<strong class="event-detail__price col-auto">{{ priceFor(product) }}</strong>
</div>
}
</div>
<div class="event-detail__total"><span>Total</span><strong>{{ formatPrice(total()) }}</strong></div>
<div class="event-detail__buttons">
<app-button variant="cancel" (click)="cancel()">Cancelar</app-button>
<app-button variant="primary" [disabled]="total() === 0 || submitting()" (click)="buyNow()">
{{ submitting() ? 'Procesando…' : 'Comprar' }}
</app-button>
</div>
} @else {
<p class="event-detail__empty">Por ahora no hay productos disponibles para este evento.</p>
}
</section>
</main>
</div>
} @else if (!loading()) {
<p role="alert">No encontramos este evento.</p>
}

View File

@@ -0,0 +1,43 @@
:host { display: block; color: #666666; }
.event-detail { margin-bottom: 3rem; }
.event-detail__sidebar, .event-detail__main { min-width: 0; }
.event-detail__image { background: #e7e8e9; }
.event-detail__image--placeholder { aspect-ratio: 1.43; display: grid; place-items: center; }
.event-detail__image img { width: 100%; height: auto; display: block; }
.event-detail__image span { font-size: 3rem; color: #aaa; }
.event-detail__description { margin-top: 1.5rem; }
.event-detail__description h2, .event-detail__social h2, .event-detail__section-heading h2 { margin: 0 0 .65rem; color: #666666; font-size: 15px; font-weight: 700; text-transform: uppercase; letter-spacing: .015em; }
.event-detail__description p { margin: 0; white-space: pre-line; line-height: 1.36; font-size: 14px; }
.event-detail__social { display: grid; gap: .35rem; margin-top: 2.3rem; }
.event-detail__social a { color: #666666; font-family: 'Gotham Book', sans-serif; font-size: 14px; font-weight: 400; display: flex; align-items: center; gap: .5rem; text-decoration: none; font-style: italic; }
.event-detail__social a:hover { color: var(--tenant-primary); text-decoration: underline; }
.event-detail__social i { width: 14px; font-size: 14px; text-align: center; }
.event-detail__map { margin-top: 16px; --location-map-min-height: 180px; --location-map-border-radius: 0; }
.event-detail__header { margin-bottom: 2rem; padding-top: .7rem; }
.event-detail__header h1 { margin: 0; color: #666666; font-size: 24px; line-height: 1.25; font-weight: 700; text-transform: uppercase; letter-spacing: .03em; }
.event-detail__subtitle { margin: .15rem 0 1.2rem; color: #666666; font-size: 18px; font-weight: 400; }
.event-detail__meta { display: grid; gap: .3rem; }
.event-detail__meta p { margin: 0; display: flex; align-items: baseline; gap: .4rem; color: #666666; font-family: 'Gotham Book', sans-serif; font-size: 12px; font-weight: 400; font-style: italic; }
.event-detail__meta i { width: .65rem; flex: none; text-align: center; }
.event-detail__section-heading { border-bottom: 1px solid #e2e4e6; margin-bottom: .9rem; }
.event-detail__section-heading h2 { margin-bottom: .5rem; }
.event-detail__date-label { display: block; margin-bottom: .4rem; color: #6b7175; font-size: .75rem; }
.event-detail__date-select { width: min(100%, 320px); padding: .55rem .75rem; border: 1px solid #d7d9db; border-radius: 4px; background: #fff; color: #555b60; font-size: .82rem; }
.event-detail__product-list { display: grid; gap: 28px; margin-top: 1.1rem; }
.event-detail__product { min-height: 40px; }
.event-detail__product h3 { min-width: 0; margin: 0; color: #838383; font-size: 20px; line-height: 1.2; font-weight: 700; text-transform: uppercase; }
.event-detail__product-action { width: 110px; text-align: right; }
.event-detail__product-action > a { color: var(--tenant-primary); font-size: .62rem; white-space: nowrap; }
.event-detail__price { width: 120px; color: #838383; font-size: 22px; font-weight: 900; text-align: right; white-space: nowrap; }
.event-detail__total { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; padding: .75rem 0 0; border-top: 1px solid #dfe2e4; margin-top: 1.15rem; font-size: 13px; color: #999999; text-transform: uppercase; }
.event-detail__total strong { color: var(--tenant-primary); font-size: 22px; font-weight: 900; }
.event-detail__buttons { display: flex; justify-content: flex-end; gap: .5rem; margin-top: 1.6rem; }
.event-detail__buttons app-button { min-width: 0; flex: 0 0 36%; }
.event-detail__empty { color: #7c8286; font-size: .85rem; }
@media (max-width: 575.98px) {
.event-detail__main { margin-top: 2rem; }
.event-detail__description, .event-detail__social { margin-top: 1.5rem; }
.event-detail__header { min-height: 0; margin-bottom: 2rem; }
.event-detail__buttons { flex-wrap: wrap; }
.event-detail__buttons app-button { flex: 1 1 100%; }
}

View File

@@ -0,0 +1,193 @@
import { HttpErrorResponse } from '@angular/common/http';
import { ChangeDetectionStrategy, Component, DestroyRef, computed, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { catchError, map, of, switchMap } from 'rxjs';
import { AuthService } from '../../../../core/services/auth/auth.service';
import { CheckoutService, DirectCheckoutItem } from '../../../../core/services/checkout.service';
import { StoreEvent } from '../../../../core/services/event/event.interface';
import { EventService } from '../../../../core/services/event/event.service';
import { TenantService } from '../../../../core/services/tenant.service';
import { ToastService } from '../../../../core/services/toast.service';
import { CounterToComponent } from '../../../../shared/components/counter-to/counter-to.component';
import { ButtonComponent } from '../../../../shared/components/button/button.component';
import { LocationMapComponent, MapLocation } from '../../../../shared/components/location-map/location-map.component';
import { QuantitySelectorComponent } from '../../../../shared/components/quantity-selector/quantity-selector.component';
type EventProduct = NonNullable<StoreEvent['catalog_items']>[number];
@Component({
selector: 'app-event-detail-page',
imports: [RouterLink, ButtonComponent, CounterToComponent, LocationMapComponent, QuantitySelectorComponent],
templateUrl: './event-detail-page.component.html',
styleUrl: './event-detail-page.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EventDetailPageComponent {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly events = inject(EventService);
private readonly checkout = inject(CheckoutService);
private readonly auth = inject(AuthService);
private readonly tenant = inject(TenantService);
private readonly toast = inject(ToastService);
private readonly destroyRef = inject(DestroyRef);
protected readonly event = signal<StoreEvent | null>(null);
protected readonly loading = signal(true);
protected readonly selectedDateId = signal<number | null>(null);
protected readonly quantities = signal<Record<number, number>>({});
protected readonly submitting = signal(false);
protected readonly mapLocations = computed<MapLocation[]>(() => {
const event = this.event();
const coordinates = event?.exact_location;
if (!event || !coordinates) return [];
return [{
id: event.id,
label: event.title,
latitude: coordinates.latitude,
longitude: coordinates.longitude,
address: event.location ?? undefined,
}];
});
protected readonly total = computed(() => (this.event()?.catalog_items ?? []).reduce((sum, product) => {
const choice = this.resolveChoice(product);
return sum + (choice ? choice.price * this.quantityFor(product.id) : 0);
}, 0));
constructor() {
this.route.paramMap.pipe(
map((params) => Number(params.get('id'))),
switchMap((id) => Number.isInteger(id) && id > 0
? this.events.get(id).pipe(map((response) => response.data), catchError(() => of(null)))
: of(null)),
takeUntilDestroyed(this.destroyRef),
).subscribe((event) => {
this.event.set(event);
this.selectedDateId.set(event?.dates?.[0]?.id ?? null);
this.quantities.set({});
this.loading.set(false);
});
}
protected selectDate(event: Event): void {
const value = Number((event.target as HTMLSelectElement).value);
this.selectedDateId.set(Number.isInteger(value) ? value : null);
this.quantities.set({});
}
protected quantityFor(productId: number): number {
return this.quantities()[productId] ?? 0;
}
protected changeQuantity(product: EventProduct, amount: number): void {
const choice = this.resolveChoice(product);
if (!choice) return;
const maximum = choice.maximumQuantity ?? Number.MAX_SAFE_INTEGER;
const quantity = Math.min(maximum, Math.max(0, this.quantityFor(product.id) + amount));
this.quantities.update((current) => ({ ...current, [product.id]: quantity }));
}
protected maximumQuantityFor(product: EventProduct): number | null {
return this.resolveChoice(product)?.maximumQuantity ?? null;
}
protected needsSelection(product: EventProduct): boolean {
return this.resolveChoice(product) === null;
}
protected priceFor(product: EventProduct): string {
return this.formatPrice(this.resolveChoice(product)?.price ?? Number(product.price));
}
protected formatPrice(price: number): string {
return '$' + price.toLocaleString('es-AR', { maximumFractionDigits: 0 });
}
protected formatDate(date: string): string {
const [year, month, day] = date.split('-').map(Number);
if (!year || !month || !day) return date;
return new Intl.DateTimeFormat('es-AR', { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' })
.format(new Date(Date.UTC(year, month - 1, day)));
}
protected socialLabel(code: string): string {
const labels: Record<string, string> = {
instagram: 'Instagram', facebook: 'Facebook', whatsapp: 'WhatsApp',
youtube: 'YouTube', tiktok: 'TikTok', x: 'X', twitter: 'X',
};
return labels[code.toLowerCase()] ?? code;
}
protected socialIcon(code: string): string {
const brands = ['instagram', 'facebook', 'whatsapp', 'youtube', 'tiktok', 'x-twitter', 'linkedin'];
const normalized = code.toLowerCase() === 'twitter' || code.toLowerCase() === 'x'
? 'x-twitter'
: code.toLowerCase();
return brands.includes(normalized) ? `fa-brands fa-${normalized}` : 'fa-solid fa-link';
}
protected cancel(): void {
void this.router.navigate(['/']);
}
protected async buyNow(): Promise<void> {
const event = this.event();
if (!event || this.submitting()) return;
const items: DirectCheckoutItem[] = [];
for (const product of event.catalog_items ?? []) {
const quantity = this.quantityFor(product.id);
if (quantity === 0) continue;
const choice = this.resolveChoice(product);
if (!choice) return;
items.push({ catalog_item_id: product.id, variant_id: choice.variantId, cantidad: quantity });
}
if (items.length === 0) return;
if (!this.auth.user()) {
await this.router.navigate(['/login'], { queryParams: { returnUrl: this.router.url } });
return;
}
const tenant = this.tenant.tenant();
if (!tenant) {
this.toast.danger('No se pudo identificar la tienda.');
return;
}
this.submitting.set(true);
try {
const purchase = await this.checkout.startCheckout(tenant.codigo, { direct_items: items });
await this.router.navigate(['/checkout', purchase.id]);
} catch (error) {
const message = error instanceof HttpErrorResponse && typeof error.error?.message === 'string'
? error.error.message
: 'No se pudo iniciar la compra.';
this.toast.danger(message);
} finally {
this.submitting.set(false);
}
}
private resolveChoice(product: EventProduct): { variantId: number | null; price: number; maximumQuantity: number | null } | null {
if (product.requires_selection) return null;
if (product.variants.length === 0) {
return { variantId: null, price: Number(product.price), maximumQuantity: product.maximum_quantity };
}
const dateId = this.selectedDateId();
const candidates = product.variants.filter((variant) =>
dateId === null || variant.event_date_ids.length === 0 || variant.event_date_ids.includes(dateId));
if (candidates.length !== 1) return null;
return {
variantId: candidates[0].id,
price: Number(candidates[0].price),
maximumQuantity: candidates[0].maximum_quantity,
};
}
}

View File

@@ -0,0 +1,10 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { EventListComponent } from '../../components/event-list/event-list.component';
@Component({
selector: 'app-event-home-page',
imports: [EventListComponent],
template: '<app-event-list />',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EventHomePageComponent {}

View File

@@ -0,0 +1,10 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { EventListComponent } from '../../components/event-list/event-list.component';
@Component({
selector: 'app-event-search-page',
imports: [EventListComponent],
template: '<section aria-labelledby="event-search-title"><h1 id="event-search-title" class="h3 mb-4">Resultados de búsqueda</h1><app-event-list [search]="true" /></section>',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EventSearchPageComponent {}

View File

@@ -107,16 +107,7 @@ function createTenant(extras: Tenant['extras'] = {}): Tenant {
footer_bg_color: '#ffffff',
header_logo: '/header.png',
footer_logo: '/footer.png',
website_type_code: 'shopit',
website_type: {
codigo: 'shopit',
nombre: 'ShopIt',
primary_color: null,
secondary_color: null,
danger_color: null,
success_color: null,
site_logo: null,
},
storefront_website_type_code: 'shopit',
extras,
categories: [],
};
@@ -225,8 +216,20 @@ describe('StoreHomePageComponent', () => {
},
});
tenant.event = {
id: 1,
title: 'Fiesta Fútbol Infantil',
subtitle: null,
description: null,
location: 'Rosario, Santa Fe',
date_text: '5 y 6 de Diciembre 2026',
social_media: [
{
code: 'whatsapp',
icon: 'fa-brands fa-whatsapp',
name: 'WhatsApp',
url: 'https://wa.me/5493410000000',
},
],
dates: [
{
id: 20,
@@ -246,25 +249,7 @@ describe('StoreHomePageComponent', () => {
},
],
};
tenant.event_date_text = '5 y 6 de Diciembre 2026';
tenant.website_type_code = 'onticket';
tenant.social_media = [
{
code: 'whatsapp',
icon: 'fa-brands fa-whatsapp',
name: 'WhatsApp',
url: 'https://wa.me/5493410000000',
},
];
tenant.website_type = {
codigo: 'onticket',
nombre: 'OnTicket',
primary_color: null,
secondary_color: null,
danger_color: null,
success_color: null,
site_logo: null,
};
tenant.storefront_website_type_code = 'onticket';
await TestBed.configureTestingModule({
imports: [StoreHomePageComponent],
@@ -297,7 +282,7 @@ describe('StoreHomePageComponent', () => {
expect(hero.textContent).toContain('5 y 6 de Diciembre 2026');
const heroComponent = fixture.debugElement.query(By.directive(HeroBannerComponent))
.componentInstance as HeroBannerComponent;
expect(heroComponent.eventConfig?.contact).toEqual(tenant.social_media);
expect(heroComponent.eventConfig?.contact).toEqual(tenant.event.social_media);
});
it('renders additional information as an HTML section', async () => {
@@ -365,8 +350,13 @@ describe('StoreHomePageComponent', () => {
it('renders only the active event data when the tenant does not have the hero extra', async () => {
const tenant = createTenant();
tenant.event = {
id: 2,
title: 'Fiesta Fútbol Infantil',
subtitle: null,
description: null,
location: 'Sunchales, Santa Fe',
date_text: null,
social_media: [],
dates: [
{
id: 25,

View File

@@ -75,15 +75,14 @@ export class StoreHomePageComponent implements OnInit, OnDestroy {
);
protected readonly eventConfig = computed(() => {
const tenant = this.tenant();
const contact = tenant?.social_media ?? [];
const event = tenant?.event;
if (event) {
return {
id: tenant.id,
id: event.id,
title: event.title,
location: event.location,
dates_text: tenant.event_date_text,
dates_text: event.date_text,
dates: event.dates.map((eventDate) => ({
id: eventDate.id,
date: eventDate.date,
@@ -92,7 +91,7 @@ export class StoreHomePageComponent implements OnInit, OnDestroy {
info_text: eventDate.info_text,
isCanceled: eventDate.isCanceled,
})),
contact,
contact: event.social_media,
};
}

View File

@@ -1,4 +1,5 @@
import { Routes } from '@angular/router';
import { inject } from '@angular/core';
import { CanMatchFn, Routes } from '@angular/router';
import { SimpleLayoutComponent } from '../../core/layout/simple-layout/simple-layout.component';
import { StoreLayoutComponent } from '../../core/layout/store-layout/store-layout.component';
@@ -13,6 +14,10 @@ import { RegisterPageComponent } from './pages/register-page/register-page.compo
import { StoreHomePageComponent } from './pages/store-home-page/store-home-page.component';
import { storeHomeCatalogResolver } from './pages/store-home-page/store-home-page.resolver';
import { checkoutPendingPurchaseGuard } from './pages/checkout-page/checkout-page.guard';
import { TenantService } from '../../core/services/tenant.service';
const isMultiEventStorefront: CanMatchFn = () =>
inject(TenantService).tenant()?.storefront_website_type_code === 'onticket_multi_event';
export const routes: Routes = [
{
@@ -21,6 +26,15 @@ export const routes: Routes = [
children: [
{
path: '',
pathMatch: 'full',
canMatch: [isMultiEventStorefront],
canActivate: [hasMenuGuard('event.index')],
loadComponent: () =>
import('./pages/event-home-page/event-home-page.component').then((m) => m.EventHomePageComponent),
},
{
path: '',
pathMatch: 'full',
component: StoreHomePageComponent,
canActivate: [hasMenuGuard('index')],
resolve: {
@@ -68,11 +82,32 @@ export const routes: Routes = [
},
],
},
{
path: 'buscar',
canMatch: [isMultiEventStorefront],
loadComponent: () =>
import('./pages/event-search-page/event-search-page.component').then((m) => m.EventSearchPageComponent),
},
{
path: 'buscar',
loadComponent: () =>
import('./pages/search-page/search-page.component').then((m) => m.SearchPageComponent),
},
{
path: 'evento/:id',
canMatch: [isMultiEventStorefront],
canActivate: [hasMenuGuard('event.detail')],
loadComponent: () =>
import('./pages/event-detail-page/event-detail-page.component').then((m) => m.EventDetailPageComponent),
},
{
path: 'categoria/:id',
canMatch: [isMultiEventStorefront],
loadComponent: () =>
import('./pages/event-category-page/event-category-page.component').then(
(m) => m.EventCategoryPageComponent,
),
},
{
path: 'categoria/:id',
loadComponent: () =>

View File

@@ -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>
}

View File

@@ -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;
}
}

View File

@@ -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));
}

View File

@@ -0,0 +1,12 @@
@if (countdown(); as timer) {
<div class="counter-to" aria-label="Tiempo restante para el evento">
@if (timer.finished) {
<span>El evento comenzó</span>
} @else {
<div><strong>{{ timer.days }}</strong><small>DÍAS</small></div>
<div><strong>{{ timer.hours }}</strong><small>HORAS</small></div>
<div><strong>{{ timer.minutes }}</strong><small>MINUTOS</small></div>
<div><strong>{{ timer.seconds }}</strong><small>SEGUNDOS</small></div>
}
</div>
}

View File

@@ -0,0 +1,6 @@
:host { display: block; margin-top: 9px; }
.counter-to { min-height: 30px; padding-block: 12px; display: grid; grid-template-columns: repeat(4, 1fr); align-items: center; text-align: center; border: 1px solid #d7d9db; border-radius: 3px; }
.counter-to > div + div { border-left: 1px solid #e6e7e8; }
.counter-to strong { display: block; color: #666666; font-size: 16px; font-weight: 700; line-height: 1.1; }
.counter-to small { display: block; margin-top: 1px; color: #666666; font-size: 11px; font-weight: 400; letter-spacing: .02em; }
.counter-to > span { grid-column: 1 / -1; font-weight: 600; }

View File

@@ -0,0 +1,37 @@
import { isPlatformBrowser } from '@angular/common';
import { ChangeDetectionStrategy, Component, DestroyRef, PLATFORM_ID, computed, inject, input, signal } from '@angular/core';
@Component({
selector: 'app-counter-to',
templateUrl: './counter-to.component.html',
styleUrl: './counter-to.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CounterToComponent {
readonly startsAt = input<string | null | undefined>(null);
private readonly now = signal(Date.now());
protected readonly countdown = computed(() => {
const startsAt = this.startsAt();
const instant = startsAt ? Date.parse(startsAt) : NaN;
if (!Number.isFinite(instant)) return null;
const remaining = Math.max(0, instant - this.now());
const seconds = Math.floor(remaining / 1000);
return {
finished: remaining === 0,
days: Math.floor(seconds / 86400),
hours: Math.floor((seconds % 86400) / 3600),
minutes: Math.floor((seconds % 3600) / 60),
seconds: seconds % 60,
};
});
constructor() {
if (isPlatformBrowser(inject(PLATFORM_ID))) {
const timer = setInterval(() => this.now.set(Date.now()), 1000);
inject(DestroyRef).onDestroy(() => clearInterval(timer));
}
}
}

View File

@@ -5,10 +5,10 @@
.location-map {
width: 100%;
min-height: 22rem;
min-height: var(--location-map-min-height, 22rem);
overflow: hidden;
background: #e9ecef;
border-radius: 0.75rem;
border-radius: var(--location-map-border-radius, 0.75rem);
}
:host ::ng-deep .leaflet-popup-content {
@@ -27,6 +27,6 @@
@media (max-width: 575.98px) {
.location-map {
min-height: 18rem;
min-height: var(--location-map-min-height, 18rem);
}
}