28 Commits

Author SHA1 Message Date
b2ffa7bb6b feat(tickets): add multi-event ticket selector 2026-09-22 12:26:33 -03:00
3465352218 feat(tickets): support event-based ticket queries 2026-09-22 12:26:12 -03:00
8adbfecc35 feat(event): refactor event retrieval to use slug instead of id 2026-09-22 10:32:46 -03:00
1f35a6d709 feat(store-header): add fetchpriority and loading attributes to images for performance optimization 2026-09-22 10:02:53 -03:00
32f86d14d6 fix(store-footer): constrain brand logo dimensions 2026-09-21 15:19:45 -03:00
49eaf0ad65 feat(tenant): expose timezone in storefront model 2026-09-21 15:19:45 -03:00
db97de6711 feat(event-detail): disable cancel button when no items are selected 2026-09-21 11:19:38 -03:00
c876b2b171 feat(store-header): enhance immersive header with compact mode and dynamic background 2026-09-21 11:18:24 -03:00
f6ff91ff3b refactor(map): change map style from minimal-light to standard across components 2026-09-21 11:08:53 -03:00
acac2858c3 style(event-detail): enlarge social icons 2026-09-21 11:00:00 -03:00
cd1c99f487 feat(checkout): show event purchase summary 2026-09-21 10:59:51 -03:00
607892173d Reapply "feat(header): refactor immersive store header for improved search functionality and styling"
This reverts commit 52f7b98162.
2026-09-21 10:16:57 -03:00
52f7b98162 Revert "feat(header): refactor immersive store header for improved search functionality and styling"
This reverts commit a06f8146e5.
2026-09-21 10:08:55 -03:00
a06f8146e5 feat(header): refactor immersive store header for improved search functionality and styling 2026-09-21 10:07:49 -03:00
fd60880812 feat(event-category): add event category page and update event listing with category filtering 2026-09-21 09:40:50 -03:00
e0b1f27b97 feat(categories): separate event and product navigation 2026-09-21 09:24:24 -03:00
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
70 changed files with 2990 additions and 659 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-face {
font-family: "Gotham"; font-family: "Gotham";
src: url("/fonts/Gotham/Gotham Thin.otf") format("opentype"); src: url("/fonts/Gotham/Gotham Thin.otf") format("opentype");
@@ -30,6 +38,14 @@
font-display: swap; 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-face {
font-family: "Gotham"; font-family: "Gotham";
src: url("/fonts/Gotham/Gotham Italic.otf") format("opentype"); src: url("/fonts/Gotham/Gotham Italic.otf") format("opentype");

View File

@@ -14,7 +14,6 @@ describe('isStoreCatalogRequest', () => {
}); });
it.each([ 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/productos'],
['GET', 'http://localhost:8000/api/tenants/demo/categories/7'], ['GET', 'http://localhost:8000/api/tenants/demo/categories/7'],
['GET', 'http://localhost:8000/api/tenants/demo/catalog-items/42/variant-options'], ['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 { export function isStoreCatalogRequest(request: HttpRequest<unknown>): boolean {
return ( return (
request.method === 'GET' && 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, request.urlWithParams,
) )
); );

View File

@@ -90,6 +90,11 @@ describe('App', () => {
expect( expect(
document.head.querySelector<HTMLLinkElement>('link[rel~="icon"]')?.getAttribute('href'), document.head.querySelector<HTMLLinkElement>('link[rel~="icon"]')?.getAttribute('href'),
).toBe(tenant.favicon); ).toBe(tenant.favicon);
expect(
document.head
.querySelector<HTMLLinkElement>('link[rel~="icon"]')
?.getAttribute('fetchpriority'),
).toBe('high');
}); });
it('renders the tenant not found screen when the tenant is missing', async () => { it('renders the tenant not found screen when the tenant is missing', async () => {
@@ -114,6 +119,10 @@ describe('App', () => {
expect( expect(
document.head.querySelector<HTMLLinkElement>('link[rel~="icon"]')?.getAttribute('href'), document.head.querySelector<HTMLLinkElement>('link[rel~="icon"]')?.getAttribute('href'),
).toBe("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'/%3E"); ).toBe("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'/%3E");
expect(
document.head
.querySelector<HTMLLinkElement>('link[rel~="icon"]')
?.getAttribute('fetchpriority'),
).toBe('high');
}); });
}); });

View File

@@ -78,6 +78,7 @@ export class App {
} }
favicon.setAttribute('href', faviconHref); favicon.setAttribute('href', faviconHref);
favicon.setAttribute('fetchpriority', 'high');
}); });
} }

View File

@@ -1,8 +1,9 @@
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { provideRouter, Router, UrlTree } from '@angular/router'; import { provideRouter, Router, UrlSerializer, UrlTree } from '@angular/router';
import { Tenant } from '../services/tenant.interface'; import { Tenant } from '../services/tenant.interface';
import { TenantService } from '../services/tenant.service'; import { TenantService } from '../services/tenant.service';
import { TenantUrlSerializer } from '../services/tenant-url.serializer';
import { hasMenuGuard } from './menu.guard'; import { hasMenuGuard } from './menu.guard';
const tenant: Tenant = { const tenant: Tenant = {
@@ -36,9 +37,10 @@ function runGuard(menuCode: string, url: string, currentTenant: Tenant | null) {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [ providers: [
provideRouter([]), provideRouter([]),
{ provide: UrlSerializer, useClass: TenantUrlSerializer },
{ {
provide: TenantService, provide: TenantService,
useValue: { tenant: () => currentTenant }, useValue: { tenant: () => currentTenant, getTenant: () => currentTenant },
}, },
], ],
}); });
@@ -73,4 +75,16 @@ describe('hasMenuGuard', () => {
expect(runGuard('index', '/', tenantWithoutMenus)).toBe(true); expect(runGuard('index', '/', tenantWithoutMenus)).toBe(true);
}); });
it('allows a tenant base path as the store root when its menu is missing', () => {
const tenantWithoutMenus = { ...tenant, base_path: '/onticket', menues: [] };
expect(runGuard('index', '/onticket', tenantWithoutMenus)).toBe(true);
});
it('allows a tenant base path with query parameters as the store root', () => {
const tenantWithoutMenus = { ...tenant, base_path: '/onticket', menues: [] };
expect(runGuard('index', '/onticket?ref=home', tenantWithoutMenus)).toBe(true);
});
}); });

View File

@@ -1,5 +1,5 @@
import { inject } from '@angular/core'; import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router'; import { CanActivateFn, PRIMARY_OUTLET, Router } from '@angular/router';
import { findMenu } from '../services/menu.utils'; import { findMenu } from '../services/menu.utils';
import { TenantService } from '../services/tenant.service'; import { TenantService } from '../services/tenant.service';
@@ -16,7 +16,9 @@ export const hasMenuGuard = (menuCode: string): CanActivateFn => {
const hasMenu = findMenu(tenant.menues ?? [], menuCode) !== undefined; const hasMenu = findMenu(tenant.menues ?? [], menuCode) !== undefined;
if (hasMenu || state.url === '/') { const primarySegments = router.parseUrl(state.url).root.children[PRIMARY_OUTLET]?.segments ?? [];
if (hasMenu || primarySegments.length === 0) {
return true; return true;
} }

View File

@@ -22,8 +22,9 @@
.store-layout__brand-logo { .store-layout__brand-logo {
display: block; display: block;
width: 100%; width: auto;
height: 100%; height: auto;
max-width: 130px;
max-height: 65px; max-height: 65px;
object-fit: contain; object-fit: contain;
object-position: left center; object-position: left center;

View File

@@ -0,0 +1,120 @@
<header class="immersive-header" [class.immersive-header--compact]="!showHero()">
<div class="immersive-header__backdrop">
@if (showHero() && backgroundImageUrl(); as backgroundImage) {
<img
class="immersive-header__backdrop-image"
[src]="backgroundImage"
alt=""
loading="eager"
fetchpriority="high"
decoding="async"
/>
}
</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"
loading="eager"
fetchpriority="high"
decoding="async"
/>
}
</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>
<app-input
id="immersive-search-input"
placeholder="Buscar"
type="search"
[(value)]="searchTerm"
[invalid]="showSearchError()"
(valueChange)="showSearchError.set(false)"
/>
@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) {
<app-button
variant="secondary"
hostClass="immersive-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'
"
(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,197 @@
: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;
overflow: hidden;
background-color: #101010;
}
.immersive-header__backdrop-image {
position: absolute;
inset: 0;
z-index: 0;
display: block;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center 42%;
}
.immersive-header__backdrop::before,
.immersive-header__backdrop::after {
content: '';
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
}
.immersive-header__backdrop::before {
background: rgba(0, 0, 0, 0.7);
}
.immersive-header__backdrop::after {
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;
width: clamp(115px, 17vw, 205px);
}
.immersive-header__search-error {
position: absolute;
top: calc(100% + 0.25rem);
right: 0;
width: max-content;
color: var(--tenant-danger, #dc3545);
font-size: 0.7rem;
}
:host ::ng-deep .immersive-header__tickets {
width: auto;
}
.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;
color: #666666;
background: transparent;
}
.immersive-header--compact .immersive-header__backdrop {
display: none;
}
:host ::ng-deep .immersive-header--compact .immersive-header__actions .icon-btn,
:host ::ng-deep .immersive-header--compact .immersive-header__actions .cart-icon {
color: #666666;
}
@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);
}
}

View File

@@ -0,0 +1,110 @@
import {
Component,
ElementRef,
HostListener,
computed,
inject,
input,
output,
signal,
} from '@angular/core';
import { RouterLink } from '@angular/router';
import { AuthUser } from '../../../services/auth/auth.interfaces';
import { Menu, WebsiteExtras } from '../../../services/tenant.interface';
import { ButtonComponent } from '../../../../shared/components/button/button.component';
import { CartIconComponent } from '../../../../shared/components/cart-icon/cart-icon.component';
import { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.component';
import { InputComponent } from '../../../../shared/components/input/input.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,
ButtonComponent,
CartIconComponent,
IconButtonComponent,
InputComponent,
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 searchTerm = '';
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.searchTerm.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,199 @@
<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"
loading="eager"
fetchpriority="high"
decoding="async"
/>
} @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 @switch (headerType()) {
class="store-layout__header" @case ('immersive') {
[style.background-image]="backgroundImageUrl() ? 'url(' + backgroundImageUrl() + ')' : null" <app-immersive-store-header
> [logoUrl]="logoUrl()"
<div class="store-layout__header-main"> [backgroundImageUrl]="backgroundImageUrl()"
<div class="container-xl px-3 px-md-4 py-3 py-lg-4"> [heroContent]="heroContent()"
<div class="row align-items-center gy-3 gy-md-0"> [carouselImages]="carouselImages()"
<div class="col-auto col-md-auto order-1 store-layout__brand-column"> [showHero]="showHero()"
<a [cartQuantity]="cartQuantity()"
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()" [user]="user()"
[isAuthenticated]="isAuthenticated()"
[accountNavigationMenus]="accountNavigationMenus()" [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"> <ng-template #standardHeader>
<app-icon-button <app-standard-store-header
variant="bars" [logoUrl]="logoUrl()"
ariaLabel="Menu" [backgroundImageUrl]="backgroundImageUrl()"
ariaControls="store-mobile-menu" [cartQuantity]="cartQuantity()"
[ariaExpanded]="isMobileMenuOpen()" [user]="user()"
title="Menu" [isAuthenticated]="isAuthenticated()"
(clicked)="toggleMobileMenu()" [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()) { @if (headerType() === 'immersive' && displayCategories() && categories().length) {
<div <nav class="immersive-categories" aria-label="Categorías">
id="store-mobile-menu" <div class="immersive-categories__list">
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) { @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 <button
type="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" class="immersive-categories__item"
aria-label="Categorias" (click)="categorySelect.emit(category)"
aria-controls="store-category-dropdown"
[attr.aria-expanded]="isCategoryDropdownOpen()"
(click)="toggleCategoryDropdown()"
> >
<span>Categorias</span> {{ category.nombre | uppercase }}
<i class="fa-solid fa-chevron-down" aria-hidden="true"></i>
</button> </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>
</div> </nav>
</header> }

View File

@@ -1,246 +1,48 @@
.store-layout__header { :host {
color: #ffffff; display: block;
background-color: var(--tenant-header-bg); }
background-repeat: no-repeat;
background-position: center; :host(.immersive-shell--compact) {
background-color: #f5f5f5;
background-position: center 42%;
background-size: cover; background-size: cover;
} }
.store-layout__header-main { :host(.immersive-shell--compact) .immersive-categories {
border-bottom: 1px solid var(--border-color); border-top: 1px solid rgba(255, 255, 255, 0.6);
background: transparent;
} }
.store-layout__categories { .immersive-categories {
height: 3.5rem; width: 100%;
overflow-x: auto;
background: #f5f5f5;
} }
.store-layout__checkout-timer { .immersive-categories__list {
color: var(--tenant-primary); 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;
}
.immersive-categories__item {
flex: none;
border: 0;
padding: 0;
background: transparent;
color: #666666;
font-size: 15px;
font-weight: 700; font-weight: 700;
white-space: nowrap; 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; cursor: pointer;
color: #8a8a8a; }
&:hover, .immersive-categories__item:hover,
&:focus-visible { .immersive-categories__item:focus-visible {
color: #666666; text-decoration: underline;
}
} }

View File

@@ -1,44 +1,40 @@
import { import { NgTemplateOutlet, UpperCasePipe } from '@angular/common';
Component, import { Component, computed, input, output } from '@angular/core';
computed,
ElementRef,
HostListener,
inject,
input,
output,
signal,
} from '@angular/core';
import { Router, RouterLink } from '@angular/router';
import { AuthUser } from '../../../services/auth/auth.interfaces'; import { AuthUser } from '../../../services/auth/auth.interfaces';
import { CartIconComponent } from '../../../../shared/components/cart-icon/cart-icon.component'; import { Category, Menu, WebsiteExtras } from '../../../services/tenant.interface';
import { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.component'; import { ImmersiveStoreHeaderComponent } from './immersive-store-header.component';
import { UserDropdownComponent } from './user-dropdown/user-dropdown.component'; import { StandardStoreHeaderComponent } from './standard-store-header.component';
import { Menu } from '../../../services/tenant.interface';
import { ButtonComponent } from '../../../../shared/components/button/button.component'; export type StoreHeaderType = 'standard' | 'immersive';
import { ReactiveFormsModule, FormControl } from '@angular/forms';
import { Category } from '../../../services/tenant.interface';
import { CategoryDropdownComponent } from './category-dropdown/category-dropdown.component';
@Component({ @Component({
selector: 'app-store-header', selector: 'app-store-header',
imports: [ imports: [NgTemplateOutlet, UpperCasePipe, StandardStoreHeaderComponent, ImmersiveStoreHeaderComponent],
ButtonComponent,
CartIconComponent,
IconButtonComponent,
RouterLink,
ReactiveFormsModule,
UserDropdownComponent,
CategoryDropdownComponent,
],
templateUrl: './store-header.component.html', templateUrl: './store-header.component.html',
styleUrl: './store-header.component.scss', styleUrl: './store-header.component.scss',
host: {
'[class.immersive-shell--compact]': 'isCompactImmersive()',
'[style.background-image]': 'compactBackground()',
},
}) })
export class StoreHeaderComponent { export class StoreHeaderComponent {
private readonly elementRef = inject(ElementRef<HTMLElement>); readonly headerType = input<StoreHeaderType>('standard');
private readonly router = inject(Router);
readonly logoUrl = input<string | null>(null); readonly logoUrl = input<string | null>(null);
readonly backgroundImageUrl = input<string | null>(null); readonly backgroundImageUrl = input<string | null>(null);
readonly heroContent = input<WebsiteExtras['immersiveHero']>(undefined);
readonly carouselImages = input<readonly string[]>([]);
readonly showHero = input(true);
protected readonly isCompactImmersive = computed(
() => this.headerType() === 'immersive' && !this.showHero(),
);
protected readonly compactBackground = computed(() => {
if (!this.isCompactImmersive()) return null;
const gradient =
'linear-gradient(180deg, rgba(245, 245, 245, 0.72) 0%, rgba(245, 245, 245, 0.88) 58%, #f5f5f5 100%)';
const image = this.backgroundImageUrl();
return image ? `${gradient}, url(${JSON.stringify(image)})` : gradient;
});
readonly cartQuantity = input<number>(0); readonly cartQuantity = input<number>(0);
readonly user = input<AuthUser | null>(null); readonly user = input<AuthUser | null>(null);
readonly isAuthenticated = input<boolean>(false); readonly isAuthenticated = input<boolean>(false);
@@ -50,123 +46,11 @@ export class StoreHeaderComponent {
readonly displayCart = input(true); readonly displayCart = input(true);
readonly cartDisabled = input(false); readonly cartDisabled = input(false);
readonly checkoutRemainingSeconds = input<number | null>(null); readonly checkoutRemainingSeconds = input<number | null>(null);
readonly cartClick = output<void>(); readonly cartClick = output<void>();
readonly ticketsClick = output<void>(); readonly ticketsClick = output<void>();
readonly loginClick = output<void>(); readonly loginClick = output<void>();
readonly logoutClick = output<void>(); readonly logoutClick = output<void>();
readonly searchSubmit = output<string>(); readonly searchSubmit = output<string>();
readonly categorySelect = output<Category>(); 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"> <div class="store-layout d-flex flex-column flex-grow-1">
<app-store-header <app-store-header
ngSkipHydration ngSkipHydration
[headerType]="tenant()?.header_type ?? 'standard'"
[heroContent]="tenant()?.extras?.immersiveHero"
[carouselImages]="tenant()?.extras?.immersiveHeroCarousel ?? []"
[showHero]="isHomeRoute()"
[cartQuantity]="cartQuantity()" [cartQuantity]="cartQuantity()"
[logoUrl]="tenant()?.header_logo ?? null" [logoUrl]="tenant()?.header_logo ?? null"
[backgroundImageUrl]="tenant()?.header_bg_image ?? null" [backgroundImageUrl]="tenant()?.header_bg_image ?? null"
@@ -8,12 +12,12 @@
[isAuthenticated]="isAuthenticated()" [isAuthenticated]="isAuthenticated()"
[accountNavigationMenus]="accountNavigationMenus()" [accountNavigationMenus]="accountNavigationMenus()"
[ticketsMenu]="ticketsMenu() ?? null" [ticketsMenu]="ticketsMenu() ?? null"
[categories]="tenant()?.categories ?? []" [categories]="navigationCategories()"
[displayCategories]="tenant()?.display_categories ?? true" [displayCategories]="displayCategories()"
[displaySeachBar]="tenant()?.display_seach_bar ?? true" [displaySeachBar]="tenant()?.display_seach_bar ?? true"
[displayCart]="displayCart()" [displayCart]="displayCart()"
[cartDisabled]="isCheckoutRoute()" [cartDisabled]="isCheckoutRoute()"
[checkoutRemainingSeconds]="checkoutRemainingSeconds()" [checkoutRemainingSeconds]="headerCheckoutRemainingSeconds()"
(cartClick)="onCartClick()" (cartClick)="onCartClick()"
(ticketsClick)="onTicketsClick()" (ticketsClick)="onTicketsClick()"
(loginClick)="onLoginClick()" (loginClick)="onLoginClick()"

View File

@@ -23,6 +23,7 @@ import { AuthService } from '../../services/auth/auth.service';
import { AuthUser } from '../../services/auth/auth.interfaces'; import { AuthUser } from '../../services/auth/auth.interfaces';
import { StoreLayoutComponent } from './store-layout.component'; import { StoreLayoutComponent } from './store-layout.component';
import { StoreHeaderComponent } from './store-header/store-header.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 { CheckoutService } from '../../services/checkout.service';
import { CheckoutCountdownService } from '../../services/checkout-countdown.service'; import { CheckoutCountdownService } from '../../services/checkout-countdown.service';
import { TenantUrlSerializer } from '../../services/tenant-url.serializer'; import { TenantUrlSerializer } from '../../services/tenant-url.serializer';
@@ -247,6 +248,147 @@ describe('StoreLayoutComponent', () => {
expect(compiled.querySelector('app-store-footer .store-layout__footer')).not.toBeNull(); expect(compiled.querySelector('app-store-footer .store-layout__footer')).not.toBeNull();
}); });
it('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',
);
const backdropImage = element.querySelector<HTMLImageElement>(
'.immersive-header__backdrop-image',
);
expect(backdropImage?.getAttribute('src')).toBe(tenant.header_bg_image);
expect(backdropImage?.getAttribute('loading')).toBe('eager');
expect(backdropImage?.getAttribute('fetchpriority')).toBe('high');
expect(backdropImage?.getAttribute('decoding')).toBe('async');
const headerLogo = element.querySelector<HTMLImageElement>('.immersive-header__brand img');
expect(headerLogo?.getAttribute('loading')).toBe('eager');
expect(headerLogo?.getAttribute('fetchpriority')).toBe('high');
expect(headerLogo?.getAttribute('decoding')).toBe('async');
expect(element.querySelectorAll('.continuous-carousel__set')).toHaveLength(2);
const carouselImages = element.querySelectorAll<HTMLImageElement>(
'.continuous-carousel__image',
);
expect(carouselImages[0]?.getAttribute('src')).toBe('/first.jpg');
expect(carouselImages[0]?.getAttribute('loading')).toBe('eager');
expect(carouselImages[0]?.getAttribute('fetchpriority')).toBe('low');
expect(carouselImages[1]?.getAttribute('fetchpriority')).toBe('low');
expect(carouselImages[5]?.getAttribute('loading')).toBe('lazy');
expect(carouselImages[5]?.getAttribute('fetchpriority')).toBe('low');
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: [] }],
menues: [
...(tenant.menues ?? []),
{
id: 20,
code: 'product.category',
label: 'Categoría de productos',
parent_menu_code: null,
content_type: 'dynamic',
route: '/categoria/:id',
submenues: [],
},
],
});
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('uses event categories and their dedicated route for multi-event storefronts', () => {
tenantState.set({
...tenant,
storefront_website_type_code: 'onticket_multi_event',
header_type: 'immersive',
display_categories: true,
categories: [],
event_categories: [{ id: 12, nombre: 'Teatro', subcategories: [] }],
menues: [
...(tenant.menues ?? []),
{
id: 21,
code: 'event.category',
label: 'Categoría de eventos',
parent_menu_code: null,
content_type: 'dynamic',
route: '/eventos/categoria/:id',
submenues: [],
},
],
});
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
const categoryButton = (fixture.nativeElement as HTMLElement).querySelector<HTMLButtonElement>(
'.immersive-categories button',
);
const navigate = vi.spyOn(TestBed.inject(Router), 'navigate');
expect(categoryButton?.textContent?.trim()).toBe('TEATRO');
categoryButton?.click();
expect(navigate).toHaveBeenCalledWith(['/eventos/categoria', 12], {
queryParams: { page: 1 },
});
});
it('opens the cart when requested through the openCart query parameter', () => { it('opens the cart when requested through the openCart query parameter', () => {
const fixture = TestBed.createComponent(StoreLayoutComponent); const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges(); fixture.detectChanges();
@@ -303,6 +445,26 @@ describe('StoreLayoutComponent', () => {
); );
}); });
it('moves the countdown out of the header on multi-event checkout', () => {
tenantState.set({
...tenant,
storefront_website_type_code: 'onticket_multi_event',
checkout_summary_type: 'event',
});
checkoutRemainingSecondsState.set(587);
const router = TestBed.inject(Router);
vi.spyOn(router, 'url', 'get').mockReturnValue('/checkout/25');
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const header = fixture.debugElement.query(By.directive(StoreHeaderComponent));
expect(header.componentInstance.checkoutRemainingSeconds()).toBeNull();
expect(element.querySelector('.store-layout__checkout-timer')).toBeNull();
});
it('hides the configured header elements when the tenant disables them', () => { it('hides the configured header elements when the tenant disables them', () => {
tenantState.set({ tenantState.set({
...tenant, ...tenant,
@@ -327,7 +489,7 @@ describe('StoreLayoutComponent', () => {
expect(compiled.querySelector('app-cart')).toBeNull(); expect(compiled.querySelector('app-cart')).toBeNull();
expect(compiled.querySelector('.store-layout__cart-overlay')).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(); (header.componentInstance as any).toggleMobileMenu();
fixture.detectChanges(); fixture.detectChanges();
@@ -342,11 +504,13 @@ describe('StoreLayoutComponent', () => {
expect(compiled.querySelector('[data-testid="store-header-brand-slot"]')).not.toBeNull(); expect(compiled.querySelector('[data-testid="store-header-brand-slot"]')).not.toBeNull();
expect(compiled.querySelector('[data-testid="store-footer-brand-slot"]')).not.toBeNull(); expect(compiled.querySelector('[data-testid="store-footer-brand-slot"]')).not.toBeNull();
expect( const headerLogo = compiled.querySelector<HTMLImageElement>(
compiled.querySelector(
`app-store-header img.store-layout__brand-logo[src="${tenant.header_logo}"]`, `app-store-header img.store-layout__brand-logo[src="${tenant.header_logo}"]`,
), );
).not.toBeNull(); expect(headerLogo).not.toBeNull();
expect(headerLogo?.getAttribute('loading')).toBe('eager');
expect(headerLogo?.getAttribute('fetchpriority')).toBe('high');
expect(headerLogo?.getAttribute('decoding')).toBe('async');
expect( expect(
compiled.querySelector( compiled.querySelector(
`app-store-footer img.store-layout__brand-logo[src="${tenant.footer_logo}"]`, `app-store-footer img.store-layout__brand-logo[src="${tenant.footer_logo}"]`,
@@ -378,7 +542,7 @@ describe('StoreLayoutComponent', () => {
const fixture = TestBed.createComponent(StoreLayoutComponent); const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges(); 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')); const searchButton = header.query(By.css('.store-layout__search-button'));
(header.componentInstance as any).searchControl.setValue(' zapatillas '); (header.componentInstance as any).searchControl.setValue(' zapatillas ');
@@ -396,7 +560,7 @@ describe('StoreLayoutComponent', () => {
const fixture = TestBed.createComponent(StoreLayoutComponent); const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges(); 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 input = header.query(By.css('#store-search-input'));
const keyboardEvent = { const keyboardEvent = {
preventDefault: vi.fn(), preventDefault: vi.fn(),
@@ -417,7 +581,7 @@ describe('StoreLayoutComponent', () => {
const fixture = TestBed.createComponent(StoreLayoutComponent); const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges(); 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 input = header.query(By.css('#store-search-input'));
const searchButton = header.query(By.css('.store-layout__search-button')); const searchButton = header.query(By.css('.store-layout__search-button'));

View File

@@ -68,8 +68,31 @@ export class StoreLayoutComponent implements OnInit {
protected readonly isCartOpen = signal(false); protected readonly isCartOpen = signal(false);
protected readonly isCheckoutRoute = signal(this.isCheckoutUrl(this.router.url)); protected readonly isCheckoutRoute = signal(this.isCheckoutUrl(this.router.url));
protected readonly isHomeRoute = signal(this.isHomeUrl(this.router.url));
protected readonly checkoutRemainingSeconds = this.checkoutCountdownService.remainingSeconds; protected readonly checkoutRemainingSeconds = this.checkoutCountdownService.remainingSeconds;
protected readonly isCreatingPurchase = signal(false); protected readonly isCreatingPurchase = signal(false);
private readonly isMultiEventStorefront = computed(
() => this.tenant()?.storefront_website_type_code === 'onticket_multi_event',
);
protected readonly headerCheckoutRemainingSeconds = computed(() =>
this.isCheckoutRoute() && this.tenant()?.checkout_summary_type === 'event'
? null
: this.checkoutRemainingSeconds(),
);
protected readonly navigationCategories = computed(() =>
this.isMultiEventStorefront()
? (this.tenant()?.event_categories ?? [])
: (this.tenant()?.categories ?? []),
);
private readonly categoryMenu = computed(() =>
findMenu(
this.tenant()?.menues ?? [],
this.isMultiEventStorefront() ? 'event.category' : 'product.category',
),
);
protected readonly displayCategories = computed(
() => (this.tenant()?.display_categories ?? true) && this.categoryMenu() !== undefined,
);
protected readonly displayCart = computed(() => this.tenant()?.display_cart ?? true); protected readonly displayCart = computed(() => this.tenant()?.display_cart ?? true);
protected readonly cartEditingPolicy = computed(() => this.tenant()?.cart_editing_policy ?? null); protected readonly cartEditingPolicy = computed(() => this.tenant()?.cart_editing_policy ?? null);
protected readonly canModifyCart = computed( protected readonly canModifyCart = computed(
@@ -210,6 +233,7 @@ export class StoreLayoutComponent implements OnInit {
.subscribe((event) => { .subscribe((event) => {
const isCheckoutRoute = this.isCheckoutUrl(event.urlAfterRedirects); const isCheckoutRoute = this.isCheckoutUrl(event.urlAfterRedirects);
this.isCheckoutRoute.set(isCheckoutRoute); this.isCheckoutRoute.set(isCheckoutRoute);
this.isHomeRoute.set(this.isHomeUrl(event.urlAfterRedirects));
if (isCheckoutRoute) { if (isCheckoutRoute) {
this.isCartOpen.set(false); this.isCartOpen.set(false);
@@ -233,6 +257,11 @@ export class StoreLayoutComponent implements OnInit {
return primarySegments[0]?.path === 'checkout'; return primarySegments[0]?.path === 'checkout';
} }
private isHomeUrl(url: string): boolean {
const primarySegments = this.router.parseUrl(url).root.children[PRIMARY_OUTLET]?.segments ?? [];
return primarySegments.length === 0;
}
protected onLoginClick(): void { protected onLoginClick(): void {
void this.router.navigate(['/login']); void this.router.navigate(['/login']);
} }
@@ -274,7 +303,9 @@ export class StoreLayoutComponent implements OnInit {
} }
protected onCategorySelect(category: Category): void { protected onCategorySelect(category: Category): void {
void this.router.navigate(['/categoria', category.id], { const route = this.isMultiEventStorefront() ? '/eventos/categoria' : '/categoria';
void this.router.navigate([route, category.id], {
queryParams: { page: 1 }, queryParams: { page: 1 },
}); });
} }

View File

@@ -121,11 +121,17 @@ export interface PurchaseDetailItemResponse {
nombre: string; nombre: string;
descripcion: string | null; descripcion: string | null;
slug: string; slug: string;
imagen: string | null; imagen?: string | null;
attributes: PurchaseDetailAttributeResponse[]; attributes: PurchaseDetailAttributeResponse[];
}; };
} }
export interface PurchaseCheckoutEventResponse {
id: number;
title: string;
image: string | null;
}
export interface PurchaseDetailResponse extends PurchaseStatusResponse { export interface PurchaseDetailResponse extends PurchaseStatusResponse {
id: number; id: number;
cart_id: number | null; cart_id: number | null;
@@ -140,6 +146,7 @@ export interface PurchaseDetailResponse extends PurchaseStatusResponse {
email: string | null; email: string | null;
items_source: 'purchase'; items_source: 'purchase';
items: PurchaseDetailItemResponse[]; items: PurchaseDetailItemResponse[];
event?: PurchaseCheckoutEventResponse | null;
tickets_count?: number; tickets_count?: number;
has_generated_tickets?: boolean; has_generated_tickets?: boolean;
subtotal: string; subtotal: string;

View File

@@ -0,0 +1,36 @@
import { ApiPaginatedResponse } from '../api-paginated-response.interface';
export interface StoreEvent {
id: number;
slug: string;
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(slug: string): Observable<ApiResponse<StoreEvent>> {
return this.http.get<ApiResponse<StoreEvent>>(
`${this.tenantService.getTenantApiUrl()}/events/${slug}`,
);
}
}

View File

@@ -19,16 +19,6 @@ export interface BankAccount {
cvu: string; 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 { export interface HeroConfig {
background_image_id?: string | ResponsiveImage | null; background_image_id?: string | ResponsiveImage | null;
title_html?: string | null; title_html?: string | null;
@@ -74,13 +64,24 @@ export interface ActiveEventDate {
} }
export interface TenantEvent { export interface TenantEvent {
id: number;
title: string; title: string;
location: string; subtitle: string | null;
description: string | null;
location: string | null;
date_text: string | null;
dates: ActiveEventDate[]; dates: ActiveEventDate[];
social_media: SocialMedia[];
} }
export interface WebsiteExtras { export interface WebsiteExtras {
carousel?: string[]; carousel?: string[];
immersiveHeroCarousel?: string[];
immersiveHero?: {
eyebrow: string;
title: string;
description: string;
};
heroConfig?: HeroConfig | null; heroConfig?: HeroConfig | null;
additionalInfoConfig?: AdditionalInfoConfig | null; additionalInfoConfig?: AdditionalInfoConfig | null;
[extraName: string]: unknown; [extraName: string]: unknown;
@@ -116,6 +117,7 @@ export interface Tenant {
nombre: string; nombre: string;
dominio: string; dominio: string;
base_path?: string; base_path?: string;
timezone?: string;
site_title?: string | null; site_title?: string | null;
asset_url?: string | null; asset_url?: string | null;
address?: string | null; address?: string | null;
@@ -131,9 +133,9 @@ export interface Tenant {
footer_logo: string; footer_logo: string;
header_bg_image?: string | null; header_bg_image?: string | null;
footer_bg_image?: string | null; footer_bg_image?: string | null;
website_type_code?: string | null; storefront_website_type_code?: string | null;
website_type?: WebsiteType | null; header_type?: 'standard' | 'immersive';
event_date_text?: string | null; checkout_summary_type?: 'default' | 'event';
extras?: WebsiteExtras; extras?: WebsiteExtras;
event?: TenantEvent | null; event?: TenantEvent | null;
selected_bank_account_id?: number | null; selected_bank_account_id?: number | null;
@@ -153,7 +155,8 @@ export interface Tenant {
ticket_partial_refund_percentage?: string; ticket_partial_refund_percentage?: string;
social_media?: SocialMedia[]; social_media?: SocialMedia[];
menues?: Menu[]; menues?: Menu[];
categories: Category[]; categories?: Category[];
event_categories?: Category[];
} }
export type TenantBootstrapResponse = ApiResponse<Tenant>; export type TenantBootstrapResponse = ApiResponse<Tenant>;

View File

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

View File

@@ -706,7 +706,7 @@
<app-location-map <app-location-map
[locations]="mapLocations" [locations]="mapLocations"
[markerColor]="tenant()?.primary_color ?? '#24a9e0'" [markerColor]="tenant()?.primary_color ?? '#24a9e0'"
mapStyle="minimal-light" mapStyle="standard"
ariaLabel="Mapa de puntos de atención en Rosario" ariaLabel="Mapa de puntos de atención en Rosario"
/> />
</div> </div>

View File

@@ -0,0 +1,19 @@
<a class="event-card" [routerLink]="['/evento', event().slug]">
<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

@@ -103,7 +103,7 @@ export class PurchaseDetailPage implements OnInit {
id: item.id, id: item.id,
title: item.item_details.nombre || 'Producto sin nombre', title: item.item_details.nombre || 'Producto sin nombre',
description: item.item_details.descripcion, description: item.item_details.descripcion,
imageUrl: item.item_details.imagen, imageUrl: item.item_details.imagen ?? null,
quantity: item.quantity, quantity: item.quantity,
attributes: item.item_details.attributes.map((attribute) => ({ attributes: item.item_details.attributes.map((attribute) => ({
label: attribute.name, label: attribute.name,

View File

@@ -0,0 +1,30 @@
<a class="ticket-event row flex-nowrap align-items-center gx-3 mx-0" [routerLink]="[event().id]">
<span class="col-4 col-sm-3">
<span class="ticket-event__image-wrapper">
@if (event().image; as image) {
<img class="ticket-event__image" [src]="image" alt="" />
}
</span>
</span>
<span class="ticket-event__title col">{{ event().title }}</span>
<span class="col-auto">
<svg
class="ticket-event__arrow"
width="8"
height="14"
viewBox="0 0 8 14"
fill="none"
aria-hidden="true"
>
<path
d="M1 1L7 7L1 13"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</span>
</a>

View File

@@ -0,0 +1,60 @@
:host {
display: block;
width: 100%;
}
.ticket-event {
min-height: 84px;
padding: 12px 8px;
color: #666666;
text-decoration: none;
transition:
box-shadow 0.2s,
border-radius 0.2s;
}
.ticket-event:hover,
.ticket-event:focus-visible {
border-radius: 8px;
box-shadow: 0 0 30px rgba(0, 0, 0, 0.07);
}
.ticket-event:focus-visible {
outline: 2px solid var(--color-primary, var(--tenant-primary, #5b75ff));
outline-offset: 2px;
}
.ticket-event__image-wrapper {
display: block;
width: 100%;
aspect-ratio: 19 / 13;
overflow: hidden;
border-radius: 3px;
background: #dddddd;
}
.ticket-event__image {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.ticket-event__title {
overflow: hidden;
font-size: 13px;
font-weight: 700;
line-height: 1.3;
text-overflow: ellipsis;
text-transform: uppercase;
}
.ticket-event__arrow {
color: #888888;
transition: color 0.2s;
}
.ticket-event:hover .ticket-event__arrow,
.ticket-event:focus-visible .ticket-event__arrow {
color: var(--color-primary, var(--tenant-primary, #5b75ff));
}

View File

@@ -0,0 +1,14 @@
import { Component, input } from '@angular/core';
import { RouterLink } from '@angular/router';
import { TicketEventResponse } from '../../../tickets-page/ticket.service';
@Component({
selector: 'app-ticket-event-list-item',
imports: [RouterLink],
templateUrl: './ticket-event-list-item.html',
styleUrl: './ticket-event-list-item.scss',
})
export class TicketEventListItem {
readonly event = input.required<TicketEventResponse>();
}

View File

@@ -0,0 +1,35 @@
<app-menu-content-section
class="ticket-events-page"
menuCode="account.tickets"
titleId="ticket-events-page-title"
>
<div class="ticket-events-list" [attr.aria-busy]="isLoading()">
@if (isLoading()) {
@for (item of [1, 2, 3, 4]; track item) {
<div
class="ticket-events-list__skeleton row flex-nowrap align-items-center gx-3 mx-0"
aria-hidden="true"
>
<span class="col-4 col-sm-3">
<span class="ticket-events-list__skeleton-image"></span>
</span>
<span class="col">
<span class="ticket-events-list__skeleton-title"></span>
</span>
<span class="col-auto">
<span class="ticket-events-list__skeleton-arrow"></span>
</span>
</div>
}
} @else if (events().length) {
@for (event of events(); track event.id; let last = $last) {
<app-ticket-event-list-item [event]="event" />
@if (!last) {
<div class="ticket-events-list__divider"></div>
}
}
} @else {
<p class="ticket-events-list__empty">Aún no tenés eventos con tickets.</p>
}
</div>
</app-menu-content-section>

View File

@@ -0,0 +1,66 @@
:host {
display: block;
width: 100%;
}
.ticket-events-page {
display: block;
width: 100%;
max-width: 600px;
}
.ticket-events-list {
display: flex;
flex-direction: column;
width: 100%;
}
.ticket-events-list__divider {
width: 100%;
height: 1px;
background: var(--border-color, #dddddd);
}
.ticket-events-list__empty {
margin: 0;
padding: 2rem 0;
color: #666666;
font-size: 1rem;
text-align: center;
}
.ticket-events-list__skeleton {
min-height: 84px;
padding: 12px 8px;
border-bottom: 1px solid var(--border-color, #dddddd);
}
.ticket-events-list__skeleton-image,
.ticket-events-list__skeleton-title,
.ticket-events-list__skeleton-arrow {
display: block;
border-radius: 4px;
background: #eeeeee;
animation: ticket-events-pulse 1.5s infinite ease-in-out;
}
.ticket-events-list__skeleton-image {
width: 100%;
aspect-ratio: 19 / 13;
}
.ticket-events-list__skeleton-title {
width: min(250px, 80%);
height: 14px;
}
.ticket-events-list__skeleton-arrow {
width: 8px;
height: 14px;
}
@keyframes ticket-events-pulse {
50% {
background-color: #e0e0e0;
}
}

View File

@@ -0,0 +1,99 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { TenantService } from '../../../../../../core/services/tenant.service';
import { ToastService } from '../../../../../../core/services/toast.service';
import { TicketService } from '../tickets-page/ticket.service';
import { TicketEventsPage } from './ticket-events-page';
function withCustomLoading<T extends object>(service: T): T & { withCustomLoading: () => T } {
return Object.assign(service, { withCustomLoading: () => service });
}
describe('TicketEventsPage', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideRouter([]),
{
provide: TenantService,
useValue: {
tenant: signal({
menues: [
{
id: 4,
code: 'account.tickets',
label: 'Tickets',
parent_menu_code: 'account',
content_type: 'dynamic',
route: '/mi-cuenta/tickets',
submenues: [],
},
],
}).asReadonly(),
},
},
{ provide: ToastService, useValue: { danger: vi.fn() } },
],
});
});
it('shows the users events with image, title and detail link', async () => {
await TestBed.configureTestingModule({ imports: [TicketEventsPage] })
.overrideComponent(TicketEventsPage, {
set: {
providers: [
{
provide: TicketService,
useValue: withCustomLoading({
getTicketEvents: () =>
Promise.resolve([
{ id: 27, title: 'Copa del mundo', image: 'https://example.com/event.jpg' },
]),
}),
},
],
},
})
.compileComponents();
const fixture = TestBed.createComponent(TicketEventsPage);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const page = fixture.nativeElement as HTMLElement;
const link = page.querySelector<HTMLAnchorElement>('.ticket-event');
expect(page.textContent).toContain('TICKETS');
expect(page.textContent).toContain('Copa del mundo');
expect(link?.getAttribute('href')).toBe('/27');
expect(link?.querySelector('img')?.getAttribute('src')).toBe('https://example.com/event.jpg');
});
it('shows the empty state when the user has no ticket events', async () => {
await TestBed.configureTestingModule({ imports: [TicketEventsPage] })
.overrideComponent(TicketEventsPage, {
set: {
providers: [
{
provide: TicketService,
useValue: withCustomLoading({ getTicketEvents: () => Promise.resolve([]) }),
},
],
},
})
.compileComponents();
const fixture = TestBed.createComponent(TicketEventsPage);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
expect((fixture.nativeElement as HTMLElement).textContent).toContain(
'Aún no tenés eventos con tickets.',
);
});
});

View File

@@ -0,0 +1,31 @@
import { Component, inject, OnInit, signal } from '@angular/core';
import { ToastService } from '../../../../../../core/services/toast.service';
import { MenuContentSectionComponent } from '../../../../components/menu-content-section/menu-content-section.component';
import { TicketEventResponse, TicketService } from '../tickets-page/ticket.service';
import { TicketEventListItem } from './components/ticket-event-list-item/ticket-event-list-item';
@Component({
selector: 'app-ticket-events-page',
imports: [MenuContentSectionComponent, TicketEventListItem],
providers: [TicketService],
templateUrl: './ticket-events-page.html',
styleUrl: './ticket-events-page.scss',
})
export class TicketEventsPage implements OnInit {
private readonly ticketService = inject(TicketService);
private readonly toastService = inject(ToastService);
protected readonly events = signal<TicketEventResponse[]>([]);
protected readonly isLoading = signal(true);
async ngOnInit(): Promise<void> {
try {
this.events.set(await this.ticketService.withCustomLoading().getTicketEvents());
} catch {
this.toastService.danger('Hubo un error al cargar los eventos');
} finally {
this.isLoading.set(false);
}
}
}

View File

@@ -7,6 +7,7 @@ import { TenantService } from '../../../../../../core/services/tenant.service';
export interface TicketResponse { export interface TicketResponse {
id: number; id: number;
tenant_code: string; tenant_code: string;
event_id: number | null;
ticket: string; ticket: string;
name: string; name: string;
description: string | null; description: string | null;
@@ -22,13 +23,29 @@ export interface TicketResponse {
is_used: boolean; is_used: boolean;
} }
export interface TicketEventResponse {
id: number;
title: string;
image: string | null;
}
@Injectable() @Injectable()
export class TicketService extends BaseApiService { export class TicketService extends BaseApiService {
private readonly tenantService = inject(TenantService); private readonly tenantService = inject(TenantService);
getTickets(): Promise<TicketResponse[]> { getTickets(eventId?: number): Promise<TicketResponse[]> {
return firstValueFrom( return firstValueFrom(
this.http.get<{ data: TicketResponse[] }>(`${this.tenantService.getTenantApiUrl()}/tickets`), this.http.get<{ data: TicketResponse[] }>(`${this.tenantService.getTenantApiUrl()}/tickets`, {
params: eventId === undefined ? {} : { event_id: eventId },
}),
).then((response) => response.data ?? []);
}
getTicketEvents(): Promise<TicketEventResponse[]> {
return firstValueFrom(
this.http.get<{ data: TicketEventResponse[] }>(
`${this.tenantService.getTenantApiUrl()}/tickets/events`,
),
).then((response) => response.data ?? []); ).then((response) => response.data ?? []);
} }

View File

@@ -19,6 +19,7 @@ function withCustomLoading<T extends object>(service: T): T & { withCustomLoadin
const ticket = (overrides: Partial<TicketResponse>): TicketResponse => ({ const ticket = (overrides: Partial<TicketResponse>): TicketResponse => ({
id: 1, id: 1,
tenant_code: 'tenant', tenant_code: 'tenant',
event_id: null,
ticket: 'ticket-1', ticket: 'ticket-1',
name: 'Entrada general', name: 'Entrada general',
description: null, description: null,

View File

@@ -1,4 +1,5 @@
import { Component, computed, inject, OnInit, signal } from '@angular/core'; import { Component, computed, inject, OnInit, signal } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { ModalService } from '../../../../../../core/services/modal.service'; import { ModalService } from '../../../../../../core/services/modal.service';
import { ToastService } from '../../../../../../core/services/toast.service'; import { ToastService } from '../../../../../../core/services/toast.service';
@@ -18,6 +19,7 @@ export class TicketsPage implements OnInit {
private readonly ticketService = inject(TicketService); private readonly ticketService = inject(TicketService);
private readonly toastService = inject(ToastService); private readonly toastService = inject(ToastService);
private readonly modalService = inject(ModalService); private readonly modalService = inject(ModalService);
private readonly route = inject(ActivatedRoute, { optional: true });
protected readonly tickets = signal<TicketResponse[]>([]); protected readonly tickets = signal<TicketResponse[]>([]);
protected readonly activeTickets = computed(() => protected readonly activeTickets = computed(() =>
@@ -36,7 +38,9 @@ export class TicketsPage implements OnInit {
async ngOnInit(): Promise<void> { async ngOnInit(): Promise<void> {
try { try {
this.tickets.set(await this.ticketService.withCustomLoading().getTickets()); this.tickets.set(
await this.ticketService.withCustomLoading().getTickets(this.eventIdFromRoute()),
);
} catch { } catch {
this.toastService.danger('Hubo un error al cargar los tickets'); this.toastService.danger('Hubo un error al cargar los tickets');
} finally { } finally {
@@ -44,6 +48,13 @@ export class TicketsPage implements OnInit {
} }
} }
private eventIdFromRoute(): number | undefined {
const value = this.route?.snapshot.paramMap.get('eventId');
const eventId = value === null || value === undefined ? Number.NaN : Number(value);
return Number.isInteger(eventId) && eventId > 0 ? eventId : undefined;
}
protected toggleAll(selected: boolean): void { protected toggleAll(selected: boolean): void {
this.selectedIds.set( this.selectedIds.set(
selected ? new Set(this.activeTickets().map((ticket) => ticket.id)) : new Set(), selected ? new Set(this.activeTickets().map((ticket) => ticket.id)) : new Set(),

View File

@@ -47,7 +47,26 @@
</div> </div>
<div class="checkout-page__cart-col"> <div class="checkout-page__cart-col">
@if (usesEventCheckoutSummary()) {
@if (checkoutRemainingTime(); as remainingTime) { @if (checkoutRemainingTime(); as remainingTime) {
<div
class="checkout-page__summary-countdown d-flex align-items-baseline justify-content-center gap-3"
role="timer"
aria-label="Tiempo restante de compra"
>
<span class="checkout-page__summary-countdown-label">Tiempo restante de compra:</span>
<span class="checkout-page__summary-countdown-value">{{ remainingTime }}</span>
</div>
}
@if (checkoutEvent(); as event) {
@if (event.image) {
<div class="checkout-page__event-media">
<img [src]="event.image" [alt]="event.title" />
</div>
}
}
} @else if (checkoutRemainingTime(); as remainingTime) {
<div <div
class="checkout-page__mobile-countdown d-flex d-md-none align-items-baseline justify-content-center gap-3" class="checkout-page__mobile-countdown d-flex d-md-none align-items-baseline justify-content-center gap-3"
role="timer" role="timer"

View File

@@ -46,6 +46,38 @@
line-height: 1; line-height: 1;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
&__summary-countdown {
flex: 0 0 auto;
margin-bottom: 1.5rem;
color: var(--tenant-primary);
font-weight: 700;
white-space: nowrap;
}
&__summary-countdown-label {
font-size: 13px;
}
&__summary-countdown-value {
font-size: 20px;
line-height: 1;
font-variant-numeric: tabular-nums;
}
&__event-media {
flex: 0 0 auto;
width: 100%;
margin-bottom: 1rem;
overflow: hidden;
border-radius: 4px;
img {
display: block;
width: 100%;
height: auto;
}
}
} }
.checkout-page__loading { .checkout-page__loading {

View File

@@ -39,6 +39,8 @@ describe('CheckoutPageComponent payment validation', () => {
let tenantState: ReturnType< let tenantState: ReturnType<
typeof signal<{ typeof signal<{
codigo: string; codigo: string;
storefront_website_type_code?: string | null;
checkout_summary_type?: 'default' | 'event';
checkout_editing_policy?: CartEditingPolicy; checkout_editing_policy?: CartEditingPolicy;
}> }>
>; >;
@@ -468,6 +470,49 @@ describe('CheckoutPageComponent payment validation', () => {
}); });
}); });
it('uses the event image and removes item images for multi-event checkout', () => {
tenantState.set({
...tenantState(),
checkout_summary_type: 'event',
});
const { component } = createComponent();
component.createdPurchase.set({
id: 25,
status: 'created',
event: {
id: 12,
title: 'Festival de prueba',
image: 'https://example.com/event.jpg',
},
items: [
{
id: 91,
quantity: 1,
unit_price: '10000.00',
line_total: '10000.00',
source_catalog_item_id: 8,
source_variant_id: null,
item_details: {
nombre: 'Entrada general',
descripcion: null,
slug: 'entrada-general',
imagen: 'https://example.com/product.jpg',
attributes: [],
},
},
],
subtotal: '10000.00',
total: '10000.00',
});
expect(component.checkoutEvent()).toEqual({
id: 12,
title: 'Festival de prueba',
image: 'https://example.com/event.jpg',
});
expect(component.mappedCartItems()[0].imageUrl).toBeNull();
});
it('keeps the checkout hidden while the purchase is loading', async () => { it('keeps the checkout hidden while the purchase is loading', async () => {
let resolvePurchase!: (purchase: any) => void; let resolvePurchase!: (purchase: any) => void;
routeParamMap = convertToParamMap({ id: 25 }); routeParamMap = convertToParamMap({ id: 25 });

View File

@@ -138,6 +138,12 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
const purchase = this.createdPurchase(); const purchase = this.createdPurchase();
return purchase ? purchase.items.map((item) => this.mapPurchaseItemToMock(item)) : []; return purchase ? purchase.items.map((item) => this.mapPurchaseItemToMock(item)) : [];
}); });
protected readonly usesEventCheckoutSummary = computed(
() => this.tenantService.tenant()?.checkout_summary_type === 'event',
);
protected readonly checkoutEvent = computed(() =>
this.usesEventCheckoutSummary() ? (this.createdPurchase()?.event ?? null) : null,
);
protected readonly isStep1Valid = signal(this.form.valid); protected readonly isStep1Valid = signal(this.form.valid);
protected readonly paymentMethods: ReadonlyArray<PaymentMethodOption> = [ protected readonly paymentMethods: ReadonlyArray<PaymentMethodOption> = [
@@ -235,7 +241,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
private mapPurchaseItemToMock(item: PurchaseDetailItemResponse): CartItemMock { private mapPurchaseItemToMock(item: PurchaseDetailItemResponse): CartItemMock {
return { return {
cartItemId: item.id, cartItemId: item.id,
imageUrl: item.item_details.imagen, imageUrl: this.usesEventCheckoutSummary() ? null : (item.item_details.imagen ?? null),
product: item.item_details.nombre, product: item.item_details.nombre,
originalPrice: null, originalPrice: null,
discountedPrice: parseFloat(item.unit_price), discountedPrice: parseFloat(item.unit_price),

View File

@@ -0,0 +1,33 @@
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()?.event_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="standard"
/>
}
</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" [disabled]="!hasSelectedItems()" (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: none; }
.event-detail__social i { width: 18px; height: 18px; font-size: 18px; line-height: 18px; 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,196 @@
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));
protected readonly hasSelectedItems = computed(() =>
Object.values(this.quantities()).some((quantity) => quantity > 0),
);
constructor() {
this.route.paramMap.pipe(
map((params) => params.get('slug')?.trim() ?? ''),
switchMap((slug) => slug
? this.events.get(slug).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 {
this.quantities.set({});
}
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

@@ -45,7 +45,7 @@
class="contact-page__map" class="contact-page__map"
[locations]="contact().mapLocations" [locations]="contact().mapLocations"
[markerColor]="markerColor()" [markerColor]="markerColor()"
mapStyle="minimal-light" mapStyle="standard"
ariaLabel="Mapa de sucursales y puntos de atención" ariaLabel="Mapa de sucursales y puntos de atención"
/> />
} }

View File

@@ -128,7 +128,7 @@ describe('ContactPage', () => {
); );
expect(map).not.toBeNull(); expect(map).not.toBeNull();
expect(map?.getAttribute('mapstyle')).toBe('minimal-light'); expect(map?.getAttribute('mapstyle')).toBe('standard');
expect(accessibleLocations).toHaveLength(2); expect(accessibleLocations).toHaveLength(2);
expect(map?.textContent).toContain('Gigante de Arroyito'); expect(map?.textContent).toContain('Gigante de Arroyito');
expect(map?.textContent).toContain('Telepagos'); expect(map?.textContent).toContain('Telepagos');

View File

@@ -107,16 +107,7 @@ function createTenant(extras: Tenant['extras'] = {}): Tenant {
footer_bg_color: '#ffffff', footer_bg_color: '#ffffff',
header_logo: '/header.png', header_logo: '/header.png',
footer_logo: '/footer.png', footer_logo: '/footer.png',
website_type_code: 'shopit', storefront_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,
},
extras, extras,
categories: [], categories: [],
}; };
@@ -225,8 +216,20 @@ describe('StoreHomePageComponent', () => {
}, },
}); });
tenant.event = { tenant.event = {
id: 1,
title: 'Fiesta Fútbol Infantil', title: 'Fiesta Fútbol Infantil',
subtitle: null,
description: null,
location: 'Rosario, Santa Fe', 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: [ dates: [
{ {
id: 20, id: 20,
@@ -246,25 +249,7 @@ describe('StoreHomePageComponent', () => {
}, },
], ],
}; };
tenant.event_date_text = '5 y 6 de Diciembre 2026'; tenant.storefront_website_type_code = 'onticket';
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,
};
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [StoreHomePageComponent], imports: [StoreHomePageComponent],
@@ -297,7 +282,7 @@ describe('StoreHomePageComponent', () => {
expect(hero.textContent).toContain('5 y 6 de Diciembre 2026'); expect(hero.textContent).toContain('5 y 6 de Diciembre 2026');
const heroComponent = fixture.debugElement.query(By.directive(HeroBannerComponent)) const heroComponent = fixture.debugElement.query(By.directive(HeroBannerComponent))
.componentInstance as 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 () => { 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 () => { it('renders only the active event data when the tenant does not have the hero extra', async () => {
const tenant = createTenant(); const tenant = createTenant();
tenant.event = { tenant.event = {
id: 2,
title: 'Fiesta Fútbol Infantil', title: 'Fiesta Fútbol Infantil',
subtitle: null,
description: null,
location: 'Sunchales, Santa Fe', location: 'Sunchales, Santa Fe',
date_text: null,
social_media: [],
dates: [ dates: [
{ {
id: 25, id: 25,

View File

@@ -75,15 +75,14 @@ export class StoreHomePageComponent implements OnInit, OnDestroy {
); );
protected readonly eventConfig = computed(() => { protected readonly eventConfig = computed(() => {
const tenant = this.tenant(); const tenant = this.tenant();
const contact = tenant?.social_media ?? [];
const event = tenant?.event; const event = tenant?.event;
if (event) { if (event) {
return { return {
id: tenant.id, id: event.id,
title: event.title, title: event.title,
location: event.location, location: event.location,
dates_text: tenant.event_date_text, dates_text: event.date_text,
dates: event.dates.map((eventDate) => ({ dates: event.dates.map((eventDate) => ({
id: eventDate.id, id: eventDate.id,
date: eventDate.date, date: eventDate.date,
@@ -92,7 +91,7 @@ export class StoreHomePageComponent implements OnInit, OnDestroy {
info_text: eventDate.info_text, info_text: eventDate.info_text,
isCanceled: eventDate.isCanceled, 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 { SimpleLayoutComponent } from '../../core/layout/simple-layout/simple-layout.component';
import { StoreLayoutComponent } from '../../core/layout/store-layout/store-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 { StoreHomePageComponent } from './pages/store-home-page/store-home-page.component';
import { storeHomeCatalogResolver } from './pages/store-home-page/store-home-page.resolver'; import { storeHomeCatalogResolver } from './pages/store-home-page/store-home-page.resolver';
import { checkoutPendingPurchaseGuard } from './pages/checkout-page/checkout-page.guard'; 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 = [ export const routes: Routes = [
{ {
@@ -21,6 +26,17 @@ export const routes: Routes = [
children: [ children: [
{ {
path: '', 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, component: StoreHomePageComponent,
canActivate: [hasMenuGuard('index')], canActivate: [hasMenuGuard('index')],
resolve: { resolve: {
@@ -68,13 +84,40 @@ export const routes: Routes = [
}, },
], ],
}, },
{
path: 'buscar',
canMatch: [isMultiEventStorefront],
loadComponent: () =>
import('./pages/event-search-page/event-search-page.component').then(
(m) => m.EventSearchPageComponent,
),
},
{ {
path: 'buscar', path: 'buscar',
loadComponent: () => loadComponent: () =>
import('./pages/search-page/search-page.component').then((m) => m.SearchPageComponent), import('./pages/search-page/search-page.component').then((m) => m.SearchPageComponent),
}, },
{
path: 'evento/:slug',
canMatch: [isMultiEventStorefront],
canActivate: [hasMenuGuard('event.detail')],
loadComponent: () =>
import('./pages/event-detail-page/event-detail-page.component').then(
(m) => m.EventDetailPageComponent,
),
},
{
path: 'eventos/categoria/:id',
canMatch: [isMultiEventStorefront],
canActivate: [hasMenuGuard('event.category')],
loadComponent: () =>
import('./pages/event-category-page/event-category-page.component').then(
(m) => m.EventCategoryPageComponent,
),
},
{ {
path: 'categoria/:id', path: 'categoria/:id',
canActivate: [hasMenuGuard('product.category')],
loadComponent: () => loadComponent: () =>
import('./pages/category-items-page/category-items-page.component').then( import('./pages/category-items-page/category-items-page.component').then(
(m) => m.CategoryItemsPageComponent, (m) => m.CategoryItemsPageComponent,
@@ -188,6 +231,24 @@ export const routes: Routes = [
(m) => m.PurchaseDetailPage, (m) => m.PurchaseDetailPage,
), ),
}, },
{
path: 'tickets/:eventId',
canMatch: [isMultiEventStorefront],
canActivate: [hasMenuGuard('account.tickets')],
loadComponent: () =>
import('./pages/account-page/pages/tickets-page/tickets-page').then(
(m) => m.TicketsPage,
),
},
{
path: 'tickets',
canMatch: [isMultiEventStorefront],
canActivate: [hasMenuGuard('account.tickets')],
loadComponent: () =>
import('./pages/account-page/pages/ticket-events-page/ticket-events-page').then(
(m) => m.TicketEventsPage,
),
},
{ {
path: 'tickets', path: 'tickets',
canActivate: [hasMenuGuard('account.tickets')], canActivate: [hasMenuGuard('account.tickets')],

View File

@@ -0,0 +1,20 @@
@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'"
fetchpriority="low"
decoding="async"
/>
}
</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 { .location-map {
width: 100%; width: 100%;
min-height: 22rem; min-height: var(--location-map-min-height, 22rem);
overflow: hidden; overflow: hidden;
background: #e9ecef; background: #e9ecef;
border-radius: 0.75rem; border-radius: var(--location-map-border-radius, 0.75rem);
} }
:host ::ng-deep .leaflet-popup-content { :host ::ng-deep .leaflet-popup-content {
@@ -27,6 +27,6 @@
@media (max-width: 575.98px) { @media (max-width: 575.98px) {
.location-map { .location-map {
min-height: 18rem; min-height: var(--location-map-min-height, 18rem);
} }
} }

View File

@@ -13,7 +13,7 @@ import {
} from '@angular/core'; } from '@angular/core';
import type { LayerGroup, Map as LeafletMap, LatLngBounds, LatLngTuple, TileLayer } from 'leaflet'; import type { LayerGroup, Map as LeafletMap, LatLngBounds, LatLngTuple, TileLayer } from 'leaflet';
export type MapStyle = 'minimal-light' | 'standard'; export type MapStyle = 'standard';
const MAP_LAYERS: Record< const MAP_LAYERS: Record<
MapStyle, MapStyle,
@@ -23,12 +23,6 @@ const MAP_LAYERS: Record<
maxZoom: number; maxZoom: number;
} }
> = { > = {
'minimal-light': {
url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>',
maxZoom: 20,
},
standard: { standard: {
url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
attribution: attribution:
@@ -56,7 +50,7 @@ export class LocationMapComponent implements AfterViewInit, OnChanges, OnDestroy
@Input() fallbackCenter: LatLngTuple = [-32.94682, -60.63932]; @Input() fallbackCenter: LatLngTuple = [-32.94682, -60.63932];
@Input() fallbackZoom = 13; @Input() fallbackZoom = 13;
@Input() maxFitZoom = 15; @Input() maxFitZoom = 15;
@Input() mapStyle: MapStyle = 'minimal-light'; @Input() mapStyle: MapStyle = 'standard';
@Input() markerColor = '#24a9e0'; @Input() markerColor = '#24a9e0';
@ViewChild('mapContainer', { static: true }) @ViewChild('mapContainer', { static: true })