Compare commits

..

34 Commits

Author SHA1 Message Date
d49c78b993 refactor(store-header): update class names for consistency and readability 2026-09-24 16:59:55 -03:00
4b9343a867 Merge branch 'tenant/onticket' into homologacion 2026-09-23 15:45:46 -03:00
114a986205 Merge pull request 'tenant/onticket' (#8) from tenant/onticket into homologacion
Reviewed-on: https://gitea.quo.ar/tbianchini/shopit-front/pulls/8
2026-09-23 18:43:51 +00:00
b320501650 style(simple-modal): adjust heading styles for improved readability
style(toast-container): reduce gap between toast items for better spacing
2026-09-23 15:18:52 -03:00
2162ae3963 feat(store-header): enhance search functionality with responsive styles and improved visibility 2026-09-23 12:16:18 -03:00
92cc1c0275 feat(store-header): enhance immersive header styles and adjust background handling for better visual consistency 2026-09-23 12:04:23 -03:00
ed806abb9f feat(event-detail): adjust product layout and quantity selector styles for improved responsiveness 2026-09-23 11:55:10 -03:00
321f2c08cd feat(event-detail): restructure layout and enhance styles for better responsiveness 2026-09-23 11:48:24 -03:00
9ba3b24b7e feat(store-header): enhance search functionality and improve mobile layout with new styles 2026-09-23 11:39:36 -03:00
b8b5292200 feat(store-header): enhance mobile layout with improved spacing and responsive styles 2026-09-23 10:39:10 -03:00
e8602cca13 feat(store-header): adjust padding, margin, and font styles for improved layout and readability 2026-09-23 10:39:00 -03:00
36fda9f7ea feat(store-header): integrate immersive categories within the header and update styles for improved layout 2026-09-23 10:29:47 -03:00
230e5b01ac feat(store-header): update button variant and adjust z-index for improved navigation 2026-09-22 16:01:47 -03:00
85f1b820b4 feat(store-header): refine immersive mobile layout 2026-09-22 15:17:11 -03:00
0fffdc5c5a feat(carousel): add reusable horizontal carousel 2026-09-22 15:17:04 -03:00
0e6c949513 feat(tickets): include tickets in immersive account dropdown and adjust visibility logic 2026-09-22 14:31:24 -03:00
fc04992ebb feat(tickets): show event context in account sidebar 2026-09-22 13:54:30 -03:00
7d27d77629 feat(menu-layout): support contextual sidebar content 2026-09-22 13:54:19 -03:00
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
65 changed files with 2099 additions and 299 deletions

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

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

@@ -1,48 +1,84 @@
<header <header class="immersive-header" [class.immersive-header--compact]="!showHero()">
class="immersive-header" <div class="ih-backdrop">
[class.immersive-header--compact]="!showHero()" @if (showHero() && backgroundImageUrl(); as backgroundImage) {
> <img
<div class="ih-backdrop-image"
class="immersive-header__backdrop" [src]="backgroundImage"
[style.background-image]="backgroundImageUrl() ? 'url(' + backgroundImageUrl() + ')' : null" alt=""
></div> loading="eager"
fetchpriority="high"
decoding="async"
/>
}
</div>
<div class="immersive-header__content"> <div class="ih-content">
<nav class="immersive-header__nav container-xl px-3 px-md-4" aria-label="Navegación de la tienda"> <nav
<a routerLink="/" class="immersive-header__brand" aria-label="Ir al inicio"> class="ih-nav container-xl px-3 px-md-4"
aria-label="Navegación de la tienda"
>
<a routerLink="/" class="ih-brand" aria-label="Ir al inicio">
@if (logoUrl()) { @if (logoUrl()) {
<img [src]="logoUrl()" alt="Logo del comercio" /> <img
[src]="logoUrl()"
alt="Logo del comercio"
loading="eager"
fetchpriority="high"
decoding="async"
/>
} }
</a> </a>
<div class="immersive-header__actions"> <div class="ih-actions">
@if (displaySearchBar()) { @if (displaySearchBar()) {
<form class="immersive-header__search" role="search" (submit)="submitSearch($event)"> <form
id="immersive-search"
class="ih-search"
[class.ih-search--open]="isSearchOpen()"
role="search"
(submit)="submitSearch($event)"
>
<label class="visually-hidden" for="immersive-search-input">Buscar productos</label> <label class="visually-hidden" for="immersive-search-input">Buscar productos</label>
<input <app-input
id="immersive-search-input" id="immersive-search-input"
type="search"
placeholder="Buscar" placeholder="Buscar"
autocomplete="off" type="search"
[formControl]="searchControl" [(value)]="searchTerm"
[attr.aria-invalid]="showSearchError()" [invalid]="showSearchError()"
[attr.aria-describedby]="showSearchError() ? 'immersive-search-error' : null" (valueChange)="showSearchError.set(false)"
(input)="showSearchError.set(false)"
/> />
<button type="submit" aria-label="Buscar">
<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
</button>
@if (showSearchError()) { @if (showSearchError()) {
<span id="immersive-search-error" class="immersive-header__search-error" role="alert" <span id="immersive-search-error" class="ih-search-error" role="alert"
>Ingresá al menos 3 caracteres.</span >Ingresá al menos 3 caracteres.</span
> >
} }
</form> </form>
} }
@if (ticketsMenu(); as menu) { @if (!showHero()) {
<button class="immersive-header__tickets" type="button" (click)="ticketsClick.emit()"> @if (ticketsMenu(); as menu) {
{{ menu.label }} <app-button
variant="primary"
hostClass="ih-tickets"
data-testid="store-header-tickets"
(click)="ticketsClick.emit()"
>
{{ menu.label }}
</app-button>
}
}
@if (!showHero() && displaySearchBar()) {
<button
#searchToggle
type="button"
class="ih-search-toggle"
aria-label="Buscar productos"
aria-controls="immersive-search"
[attr.aria-expanded]="isSearchOpen()"
(click)="toggleSearch()"
>
<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
</button> </button>
} }
@@ -57,7 +93,7 @@
/> />
} }
<div class="immersive-header__account"> <div class="ih-account">
<app-icon-button <app-icon-button
variant="user" variant="user"
ariaLabel="Mi cuenta" ariaLabel="Mi cuenta"
@@ -65,30 +101,40 @@
[ariaExpanded]="isUserDropdownOpen()" [ariaExpanded]="isUserDropdownOpen()"
(clicked)="toggleUserDropdown()" (clicked)="toggleUserDropdown()"
/> />
@if (isUserDropdownOpen()) { @if (isUserDropdownOpen() && isAuthenticated()) {
<app-user-dropdown <app-user-dropdown
id="immersive-user-dropdown" id="immersive-user-dropdown"
[user]="user()" [user]="user()"
[accountNavigationMenus]="accountNavigationMenus()" [accountNavigationMenus]="accountNavigationMenus()"
(logoutClick)="onLogoutClick()" (logoutClick)="onLogoutClick()"
/> />
} @else if (isUserDropdownOpen()) {
<div id="immersive-user-dropdown" class="ih-guest-menu">
<button type="button" (click)="isUserDropdownOpen.set(false); loginClick.emit()">
Iniciar sesión
</button>
</div>
} }
</div> </div>
</div> </div>
</nav> </nav>
@if (checkoutRemainingTime(); as remainingTime) { @if (checkoutRemainingTime(); as remainingTime) {
<div class="immersive-header__timer container-xl px-3 px-md-4" role="timer" aria-label="Tiempo restante de compra"> <div
class="ih-timer container-xl px-3 px-md-4"
role="timer"
aria-label="Tiempo restante de compra"
>
Tiempo restante de compra: <strong>{{ remainingTime }}</strong> Tiempo restante de compra: <strong>{{ remainingTime }}</strong>
</div> </div>
} }
@if (showHero()) { @if (showHero()) {
@if (heroContent(); as content) { @if (heroContent(); as content) {
<div class="immersive-header__copy"> <div class="ih-copy">
<p class="immersive-header__eyebrow">{{ content.eyebrow }}</p> <p class="ih-eyebrow">{{ content.eyebrow }}</p>
<h1 class="immersive-header__title">{{ content.title }}</h1> <h1 class="ih-title">{{ content.title }}</h1>
<p class="immersive-header__description">{{ content.description }}</p> <p class="ih-description">{{ content.description }}</p>
</div> </div>
} }
@@ -99,5 +145,7 @@
/> />
} }
} }
<ng-content />
</div> </div>
</header> </header>

View File

@@ -7,44 +7,61 @@
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
isolation: isolate; isolation: isolate;
min-height: 640px;
color: #fff; color: #fff;
background: #f5f5f5; background: #f5f5f5;
} }
.immersive-header__content { .ih-content {
grid-area: 1 / 1; grid-area: 1 / 1;
position: relative; position: relative;
z-index: 1; z-index: 1;
min-width: 0; min-width: 0;
padding-top: clamp(1.75rem, 4vw, 2.5rem); padding-top: clamp(1.75rem, 4vw, 2.5rem);
padding-bottom: 0.5rem; padding-bottom: clamp(1rem, 2vw, 1.5rem);
} }
.immersive-header__backdrop { .ih-backdrop {
grid-area: 1 / 1; grid-area: 1 / 1;
position: relative; position: relative;
z-index: 0; z-index: 0;
overflow: hidden;
background-color: #101010; background-color: #101010;
background-position: center 42%;
background-size: cover;
} }
.immersive-header__backdrop::after { .ih-backdrop-image {
position: absolute;
inset: 0;
z-index: 0;
display: block;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center 42%;
}
.ih-backdrop::before,
.ih-backdrop::after {
content: ''; content: '';
position: absolute; position: absolute;
inset: 0; inset: 0;
background: linear-gradient( z-index: 1;
180deg, pointer-events: none;
rgba(0, 0, 0, 0.48) 0%,
rgba(0, 0, 0, 0.4) 47%,
#f5f5f5 100%
);
} }
.immersive-header__nav { .ih-backdrop::before {
background: rgba(0, 0, 0, 0.7);
}
.ih-backdrop::after {
background: linear-gradient(180deg, rgba(0, 0, 0, 0.48) 0%, rgba(0, 0, 0, 0.4) 47%, #f5f5f5 100%);
}
.ih-nav {
position: relative; position: relative;
z-index: 1; // Keep the whole navigation stacking context above the hero copy and carousel.
// Otherwise those later siblings can sit on top of the dropdown and capture
// pointer events even though the dropdown itself has a high z-index.
z-index: 10;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
@@ -52,122 +69,82 @@
min-height: 2.25rem; min-height: 2.25rem;
} }
.immersive-header__brand { .ih-brand {
display: inline-flex; display: inline-flex;
flex: none; flex: none;
align-items: center; align-items: center;
} }
.immersive-header__brand img { .ih-brand img {
display: block; display: block;
width: clamp(105px, 13vw, 145px); width: clamp(105px, 13vw, 145px);
max-height: 2.25rem; max-height: 2.25rem;
object-fit: contain; object-fit: contain;
} }
.immersive-header__actions { .ih-actions {
display: flex; display: flex;
align-items: center; align-items: center;
gap: clamp(0.35rem, 1vw, 1rem); gap: clamp(0.35rem, 1vw, 1rem);
} }
.immersive-header__search { .ih-search {
position: relative; position: relative;
display: flex;
width: clamp(115px, 17vw, 205px); width: clamp(115px, 17vw, 205px);
height: 2rem;
border: 1px solid var(--tenant-primary, #ff7006);
border-radius: 3px;
background: rgba(0, 0, 0, 0.1);
} }
.immersive-header__search input { .ih-search-error {
width: 100%;
min-width: 0;
padding: 0 0.5rem;
border: 0;
outline: 0;
color: #fff;
background: transparent;
font-size: 0.75rem;
}
.immersive-header__search input::placeholder {
color: rgba(255, 255, 255, 0.78);
}
.immersive-header__search input::-webkit-search-cancel-button {
filter: invert(1);
}
.immersive-header__search button {
border: 0;
padding-inline: 0.55rem;
color: var(--tenant-primary, #ff7006);
background: transparent;
}
.immersive-header__search:focus-within {
outline: 2px solid var(--tenant-primary, #ff7006);
outline-offset: 2px;
}
.immersive-header__search-error {
position: absolute; position: absolute;
top: calc(100% + 0.25rem); top: calc(100% + 0.25rem);
right: 0; right: 0;
width: max-content; width: max-content;
color: #fff; color: var(--tenant-danger, #dc3545);
font-size: 0.7rem; font-size: 0.7rem;
} }
.immersive-header__tickets {
border: 1px solid var(--tenant-primary, #ff7006); :host ::ng-deep .ih-tickets {
border-radius: 3px; width: auto;
padding: 0.25rem 0.6rem;
color: #fff;
background: rgba(0, 0, 0, 0.2);
font-size: 0.75rem;
} }
.immersive-header__tickets:hover { .ih-account {
background: var(--tenant-primary, #ff7006);
}
.immersive-header__account {
position: relative; position: relative;
// The nav becomes display: contents on mobile, so it cannot provide the
// stacking context there. Keep the dropdown trigger above following rows.
z-index: 10;
display: flex; display: flex;
} }
.immersive-header__account app-user-dropdown { :host ::ng-deep .ih-actions .icon-btn,
position: absolute; :host ::ng-deep .ih-actions .cart-icon {
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); color: var(--tenant-primary, #ff7006);
} }
.immersive-header__copy { .ih-copy {
position: relative; position: relative;
z-index: 1; z-index: 1;
margin: clamp(2.1rem, 5vw, 3.25rem) auto 0; margin: clamp(2.1rem, 5vw, 3.25rem) auto 0;
padding-inline: 1rem; padding-inline: 1rem;
text-align: center; text-align: center;
} }
.immersive-header__eyebrow, .ih-eyebrow,
.immersive-header__title { .ih-title {
font-family: 'Smooch', cursive; font-family: 'Smooch', cursive;
font-weight: 400; font-weight: 400;
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.55); text-shadow: 0 2px 12px rgba(0, 0, 0, 0.55);
} }
.immersive-header__eyebrow { .ih-eyebrow {
margin: 0; margin: 0;
font-size: 50px; font-size: 50px;
line-height: 1; line-height: 1;
} }
.immersive-header__title { .ih-title {
margin: -0.15rem 0 0; margin: -0.15rem 0 0;
font-size: 140px; font-size: 140px;
line-height: 1.05; line-height: 0.7;
} }
.immersive-header__description { .ih-description {
margin: 1.15rem 0 0; margin: 30px 0 0;
font-size: clamp(0.75rem, 1.4vw, 0.95rem); font-family: 'Gotham Book', sans-serif;
font-size: 18px;
font-weight: 300;
text-shadow: 0 1px 5px rgba(0, 0, 0, 0.6); text-shadow: 0 1px 5px rgba(0, 0, 0, 0.6);
} }
.immersive-header__timer { .ih-timer {
position: relative; position: relative;
z-index: 1; z-index: 1;
margin-top: 0.75rem; margin-top: 0.75rem;
@@ -182,43 +159,298 @@
} }
.immersive-header--compact { .immersive-header--compact {
min-height: 0; min-height: 115px;
padding-bottom: 1.5rem; color: #666666;
background: #171717; background: transparent;
.ih-content {
padding: 35px 0 36px;
}
.ih-nav {
flex-wrap: nowrap;
gap: 0.75rem;
min-height: 44px;
}
.ih-brand img {
width: clamp(92px, 24vw, 145px);
}
.ih-actions {
flex: 0 1 auto;
gap: 0.375rem;
}
} }
.immersive-header--compact .immersive-header__backdrop {
.ih-search-toggle {
display: none;
flex: none;
align-items: center;
justify-content: center;
width: 32px;
height: 44px;
padding: 0;
border: 0;
border-radius: 4px;
background: transparent;
color: #888;
font-size: 20px;
&:focus-visible {
outline: 2px solid var(--tenant-primary);
outline-offset: 2px;
}
}
.ih-guest-menu {
position: absolute; position: absolute;
inset: 0; top: calc(100% + 0.5rem);
right: 0;
min-width: 180px;
padding: 0.5rem;
border-radius: 6px;
background: #fff;
box-shadow: 0 4px 16px #0002;
button {
width: 100%;
padding: 0.75rem;
border: 0;
background: transparent;
color: #666;
text-align: left;
}
} }
.immersive-header--compact .immersive-header__backdrop::after {
background: rgba(0, 0, 0, 0.65); :host ::ng-deep .immersive-header--compact {
.ih-tickets {
flex: none;
width: auto;
.btn {
min-width: 120px;
white-space: nowrap;
}
}
.ih-account .icon-btn {
width: 32px;
height: 44px;
color: #888;
}
}
.immersive-header--compact .ih-backdrop {
display: none;
}
:host ::ng-deep .immersive-header--compact .ih-actions .icon-btn,
:host ::ng-deep .immersive-header--compact .ih-actions .cart-icon {
color: #888;
}
@media (max-width: 359.98px) {
.immersive-header--compact .ih-nav {
padding-inline: 1rem !important;
gap: 0.375rem;
}
.immersive-header--compact .ih-actions {
gap: 0.125rem;
}
} }
@media (max-width: 600px) { @media (max-width: 600px) {
.immersive-header__nav { .ih-nav {
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.5rem; gap: 0.5rem;
} }
.immersive-header__actions { .ih-actions {
flex: 1; flex: 1;
justify-content: flex-end; justify-content: flex-end;
} }
.immersive-header__search { .ih-search {
width: clamp(105px, 38vw, 180px); width: clamp(105px, 38vw, 180px);
} }
.immersive-header__copy { .ih-copy {
margin-top: 3rem; margin-top: 3rem;
} }
.immersive-header__eyebrow { .ih-eyebrow {
font-size: clamp(2rem, 8vw, 50px); font-size: clamp(2rem, 8vw, 50px);
} }
.immersive-header__title { .ih-title {
font-size: clamp(3rem, 12vw, 5rem); font-size: clamp(3rem, 12vw, 5rem);
} }
} }
@media (prefers-reduced-motion: reduce) { @media (max-width: 767.98px) {
.immersive-header__tickets { .immersive-header--compact {
transition: none; .ih-search {
position: absolute;
top: calc(100% + 0.5rem);
right: 8vw;
display: none;
width: min(320px, 84vw);
padding: 0.75rem;
border-radius: 6px;
background: #f5f5f5;
box-shadow: 0 4px 16px #0002;
}
.ih-search--open {
display: block;
}
.ih-search-error {
position: static;
display: block;
margin-top: 0.25rem;
}
.ih-search-toggle {
display: inline-flex;
}
}
.immersive-header:not(.immersive-header--compact) {
min-height: 0;
.ih-content {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
align-content: start;
padding: 1.875rem 0 1.5rem;
}
// Keep one search form while placing it below the hero copy on mobile.
.ih-nav,
.ih-actions {
display: contents;
}
.ih-brand {
grid-area: 1 / 1;
margin-left: 8vw;
}
.ih-brand img {
width: clamp(105px, 34vw, 145px);
}
app-cart-icon {
grid-area: 1 / 2;
}
.ih-account {
grid-area: 1 / 3;
margin-right: calc(8vw - 0.75rem);
}
.ih-timer {
grid-area: 2 / 1 / auto / -1;
}
.ih-copy {
grid-area: 3 / 1 / auto / -1;
margin: 1.25rem 0 0;
}
.ih-eyebrow {
font-size: clamp(18px, 5.5vw, 24px);
}
.ih-title {
font-size: clamp(40px, 12vw, 56px);
line-height: 0.7;
}
.ih-description {
margin-top: 30px;
font-size: clamp(11px, 3vw, 14px);
line-height: 1.5;
color: #666;
text-shadow: none;
}
.ih-search {
grid-area: 4 / 1 / auto / -1;
width: 74%;
max-width: 28rem;
margin: 0.5rem auto 0;
}
.ih-search-error {
right: auto;
left: 0;
width: 100%;
color: var(--tenant-danger, #dc3545);
}
app-continuous-image-carousel {
grid-area: 6 / 1 / auto / -1;
margin-top: 1.5rem;
--carousel-card-width: 27vw;
--carousel-gap: 3.3vw;
--carousel-offset: -9vw;
}
.ih-backdrop-image {
object-position: center 42%;
}
.ih-backdrop {
// Keep the fade tied to the hero, independent of the content below it.
align-self: start;
height: 12.5rem;
// Fade the entire layer so its dark base cannot leave a subpixel seam.
mask-image: linear-gradient(#000 calc(100% - 8px), transparent 100%);
}
.ih-backdrop::after {
background: linear-gradient(
180deg,
rgba(0, 0, 0, 0.25) 0%,
rgba(245, 245, 245, 0.1) 30%,
rgba(245, 245, 245, 0.65) 65%,
#f5f5f5 98%,
#f5f5f5 100%
);
}
}
:host ::ng-deep .immersive-header:not(.immersive-header--compact) {
.ih-search .form-control {
min-height: 36px;
padding: 0.25rem 2.5rem 0.25rem 0.75rem;
border: 1px solid #888;
border-radius: 3px;
background: transparent;
color: #666;
font-size: 16px;
&::placeholder {
color: transparent;
}
&:focus-visible {
outline: 2px solid #666;
outline-offset: 2px;
}
}
.ih-search .input-search-icon {
color: #666;
right: 0.75rem;
}
.ih-account .icon-btn {
min-width: 44px;
min-height: 44px;
i {
font-size: 18px;
}
}
} }
} }

View File

@@ -7,13 +7,16 @@ import {
input, input,
output, output,
signal, signal,
afterRenderEffect,
viewChild,
} from '@angular/core'; } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { AuthUser } from '../../../services/auth/auth.interfaces'; import { AuthUser } from '../../../services/auth/auth.interfaces';
import { Menu, WebsiteExtras } from '../../../services/tenant.interface'; 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 { CartIconComponent } from '../../../../shared/components/cart-icon/cart-icon.component';
import { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.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 { ContinuousImageCarouselComponent } from '../../../../shared/components/continuous-image-carousel/continuous-image-carousel.component';
import { UserDropdownComponent } from './user-dropdown/user-dropdown.component'; import { UserDropdownComponent } from './user-dropdown/user-dropdown.component';
@@ -21,9 +24,10 @@ import { UserDropdownComponent } from './user-dropdown/user-dropdown.component';
selector: 'app-immersive-store-header', selector: 'app-immersive-store-header',
imports: [ imports: [
RouterLink, RouterLink,
ReactiveFormsModule, ButtonComponent,
CartIconComponent, CartIconComponent,
IconButtonComponent, IconButtonComponent,
InputComponent,
ContinuousImageCarouselComponent, ContinuousImageCarouselComponent,
UserDropdownComponent, UserDropdownComponent,
], ],
@@ -54,9 +58,23 @@ export class ImmersiveStoreHeaderComponent {
readonly logoutClick = output<void>(); readonly logoutClick = output<void>();
readonly searchSubmit = output<string>(); readonly searchSubmit = output<string>();
protected readonly searchControl = new FormControl('', { nonNullable: true }); protected searchTerm = '';
protected readonly showSearchError = signal(false); protected readonly showSearchError = signal(false);
protected readonly isUserDropdownOpen = signal(false); protected readonly isUserDropdownOpen = signal(false);
protected readonly isSearchOpen = signal(false);
private readonly searchInput = viewChild(InputComponent);
private readonly searchToggle = viewChild<ElementRef<HTMLButtonElement>>('searchToggle');
constructor() {
afterRenderEffect(() => {
if (this.isSearchOpen()) this.searchInput()?.focus();
});
}
protected toggleSearch(): void {
this.isUserDropdownOpen.set(false);
this.isSearchOpen.update((open) => !open);
}
protected readonly checkoutRemainingTime = computed(() => { protected readonly checkoutRemainingTime = computed(() => {
const seconds = this.checkoutRemainingSeconds(); const seconds = this.checkoutRemainingSeconds();
if (seconds === null) return null; if (seconds === null) return null;
@@ -68,17 +86,19 @@ export class ImmersiveStoreHeaderComponent {
protected submitSearch(event?: Event): void { protected submitSearch(event?: Event): void {
event?.preventDefault(); event?.preventDefault();
const term = this.searchControl.value.trim(); const term = this.searchTerm.trim();
if (term.length < 3) { if (term.length < 3) {
this.showSearchError.set(true); this.showSearchError.set(true);
return; return;
} }
this.showSearchError.set(false); this.showSearchError.set(false);
this.isSearchOpen.set(false);
this.searchSubmit.emit(term); this.searchSubmit.emit(term);
} }
protected toggleUserDropdown(): void { protected toggleUserDropdown(): void {
if (!this.isAuthenticated()) { this.isSearchOpen.set(false);
if (this.showHero() && !this.isAuthenticated()) {
this.loginClick.emit(); this.loginClick.emit();
return; return;
} }
@@ -98,11 +118,14 @@ export class ImmersiveStoreHeaderComponent {
protected onDocumentClick(event: MouseEvent): void { protected onDocumentClick(event: MouseEvent): void {
if (event.target instanceof Node && !this.host.nativeElement.contains(event.target)) { if (event.target instanceof Node && !this.host.nativeElement.contains(event.target)) {
this.isUserDropdownOpen.set(false); this.isUserDropdownOpen.set(false);
this.isSearchOpen.set(false);
} }
} }
@HostListener('document:keydown.escape') @HostListener('document:keydown.escape')
protected onEscapeKey(): void { protected onEscapeKey(): void {
if (this.isSearchOpen()) this.searchToggle()?.nativeElement.focus();
this.isSearchOpen.set(false);
this.isUserDropdownOpen.set(false); this.isUserDropdownOpen.set(false);
} }
} }

View File

@@ -13,7 +13,14 @@
aria-label="Logo del comercio" aria-label="Logo del comercio"
> >
@if (logoUrl()) { @if (logoUrl()) {
<img class="store-layout__brand-logo" [src]="logoUrl()" alt="Logo del comercio" /> <img
class="store-layout__brand-logo"
[src]="logoUrl()"
alt="Logo del comercio"
loading="eager"
fetchpriority="high"
decoding="async"
/>
} @else { } @else {
<div class="store-layout__brand-logo-shell" aria-hidden="true"> <div class="store-layout__brand-logo-shell" aria-hidden="true">
<span class="store-layout__brand-logo-skeleton"></span> <span class="store-layout__brand-logo-skeleton"></span>

View File

@@ -6,10 +6,6 @@
background-size: cover; background-size: cover;
} }
.store-layout__header-main {
border-bottom: 1px solid var(--border-color);
}
.store-layout__categories { .store-layout__categories {
height: 3.5rem; height: 3.5rem;
} }

View File

@@ -20,7 +20,17 @@
(loginClick)="loginClick.emit()" (loginClick)="loginClick.emit()"
(logoutClick)="logoutClick.emit()" (logoutClick)="logoutClick.emit()"
(searchSubmit)="searchSubmit.emit($event)" (searchSubmit)="searchSubmit.emit($event)"
/> >
@if (showHero() && displayCategories() && categories().length) {
<nav class="immersive-categories" aria-label="Categorías">
<app-horizontal-carousel
[items]="categoryItems()"
ariaLabel="Categorías"
(itemSelect)="onCategoryItemSelect($event)"
/>
</nav>
}
</app-immersive-store-header>
} }
@default { @default {
<ng-container *ngTemplateOutlet="standardHeader" /> <ng-container *ngTemplateOutlet="standardHeader" />
@@ -50,19 +60,3 @@
(categorySelect)="categorySelect.emit($event)" (categorySelect)="categorySelect.emit($event)"
/> />
</ng-template> </ng-template>
@if (headerType() === 'immersive' && displayCategories() && categories().length) {
<nav class="immersive-categories" aria-label="Categorías">
<div class="immersive-categories__list">
@for (category of categories(); track category.id) {
<button
type="button"
class="immersive-categories__item"
(click)="categorySelect.emit(category)"
>
{{ category.nombre | uppercase }}
</button>
}
</div>
</nav>
}

View File

@@ -2,36 +2,54 @@
display: block; display: block;
} }
app-immersive-store-header {
position: relative;
z-index: 1;
}
:host(.immersive-shell--compact) {
position: relative;
background-color: #f5f5f5;
background-image: none !important;
&::before {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
background-image: var(--compact-header-background);
background-position: left top, center 42%;
background-size: 100% 100%, cover;
background-repeat: no-repeat;
mask-image: linear-gradient(#000 calc(100% - 12px), transparent calc(100% - 4px));
}
}
.immersive-categories { .immersive-categories {
position: relative;
z-index: 1;
width: 100%; width: 100%;
overflow-x: auto; margin-top: clamp(5rem, 11vw, 9rem);
background: #f5f5f5; padding: 0.375rem 1rem;
}
.immersive-categories__list {
display: flex;
align-items: center;
justify-content: center;
gap: clamp(1rem, 3vw, 2.5rem);
width: max-content;
min-width: 100%;
min-height: 3.5rem;
padding: 0.75rem 1rem;
}
.immersive-categories__item {
flex: none;
border: 0;
padding: 0;
background: transparent; background: transparent;
color: #666666; color: #666;
font-size: 15px; font-size: 15px;
font-weight: 700; font-weight: 700;
white-space: nowrap; --horizontal-carousel-gap: 90px;
cursor: pointer; --horizontal-carousel-text-transform: uppercase;
} }
.immersive-categories__item:hover, @media (max-width: 767.98px) {
.immersive-categories__item:focus-visible { .immersive-categories {
text-decoration: underline; grid-area: 5 / 1 / auto / -1;
min-width: 0;
margin-top: 1.5rem;
padding: 0 1rem;
font-size: clamp(10px, 2.8vw, 12px);
--horizontal-carousel-visible-items: 3;
--horizontal-carousel-gap: 0.5rem;
--horizontal-carousel-item-width: calc((100cqw - 2 * var(--horizontal-carousel-gap)) / 3);
--horizontal-carousel-white-space: normal;
--horizontal-carousel-snap: x mandatory;
}
} }

View File

@@ -1,17 +1,32 @@
import { NgTemplateOutlet, UpperCasePipe } from '@angular/common'; import { NgTemplateOutlet } from '@angular/common';
import { Component, input, output } from '@angular/core'; import { Component, computed, input, output } from '@angular/core';
import { AuthUser } from '../../../services/auth/auth.interfaces'; import { AuthUser } from '../../../services/auth/auth.interfaces';
import { Category, Menu, WebsiteExtras } from '../../../services/tenant.interface'; import { Category, Menu, WebsiteExtras } from '../../../services/tenant.interface';
import { ImmersiveStoreHeaderComponent } from './immersive-store-header.component'; import { ImmersiveStoreHeaderComponent } from './immersive-store-header.component';
import { StandardStoreHeaderComponent } from './standard-store-header.component'; import { StandardStoreHeaderComponent } from './standard-store-header.component';
import {
HorizontalCarouselComponent,
HorizontalCarouselItem,
} from '../../../../shared/components/horizontal-carousel/horizontal-carousel.component';
export type StoreHeaderType = 'standard' | 'immersive'; export type StoreHeaderType = 'standard' | 'immersive';
@Component({ @Component({
selector: 'app-store-header', selector: 'app-store-header',
imports: [NgTemplateOutlet, UpperCasePipe, StandardStoreHeaderComponent, ImmersiveStoreHeaderComponent], imports: [
NgTemplateOutlet,
HorizontalCarouselComponent,
StandardStoreHeaderComponent,
ImmersiveStoreHeaderComponent,
],
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]': 'null',
'[style.--compact-header-background]': 'compactBackground()',
},
}) })
export class StoreHeaderComponent { export class StoreHeaderComponent {
readonly headerType = input<StoreHeaderType>('standard'); readonly headerType = input<StoreHeaderType>('standard');
@@ -20,6 +35,17 @@ export class StoreHeaderComponent {
readonly heroContent = input<WebsiteExtras['immersiveHero']>(undefined); readonly heroContent = input<WebsiteExtras['immersiveHero']>(undefined);
readonly carouselImages = input<readonly string[]>([]); readonly carouselImages = input<readonly string[]>([]);
readonly showHero = input(true); 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 calc(100% - 4px), #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);
@@ -38,4 +64,13 @@ export class StoreHeaderComponent {
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 categoryItems = computed(() =>
this.categories().map((category) => ({ value: category.id, label: category.nombre })),
);
protected onCategoryItemSelect(item: HorizontalCarouselItem): void {
const category = this.categories().find((candidate) => candidate.id === item.value);
if (category) this.categorySelect.emit(category);
}
} }

View File

@@ -71,6 +71,7 @@
@media (max-width: 575.98px) { @media (max-width: 575.98px) {
:host { :host {
right: -0.5rem; right: 0;
width: min(18rem, calc(100vw - 1rem));
} }
} }

View File

@@ -12,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

@@ -274,26 +274,82 @@ describe('StoreLayoutComponent', () => {
expect(header.componentInstance.headerType()).toBe('immersive'); expect(header.componentInstance.headerType()).toBe('immersive');
expect(element.querySelector('app-standard-store-header')).toBeNull(); expect(element.querySelector('app-standard-store-header')).toBeNull();
expect(element.querySelector('app-immersive-store-header')).not.toBeNull(); expect(element.querySelector('app-immersive-store-header')).not.toBeNull();
expect(element.querySelector('.immersive-header__eyebrow')?.textContent).toContain( expect(element.querySelector('.ih-eyebrow')?.textContent).toContain(
'Encendé tu', 'Encendé tu',
); );
expect(element.querySelector('.immersive-header__title')?.textContent).toContain('experiencia'); expect(element.querySelector('.ih-title')?.textContent).toContain('experiencia');
expect(element.querySelector('.immersive-header__description')?.textContent).toContain( expect(element.querySelector('.ih-description')?.textContent).toContain(
'Reservá tu entrada', 'Reservá tu entrada',
); );
expect(element.querySelectorAll('.continuous-carousel__set')).toHaveLength(2); const backdropImage = element.querySelector<HTMLImageElement>(
expect(element.querySelector('.continuous-carousel__image')?.getAttribute('src')).toBe( '.ih-backdrop-image',
'/first.jpg',
); );
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>('.ih-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(); expect(element.querySelector('app-cart-icon')).not.toBeNull();
}); });
it('shows immersive categories below the header and navigates on selection', () => { it('keeps tickets in the immersive account dropdown while hiding its root action', () => {
const authService = TestBed.inject(AuthService);
(authService.isAuthenticated as any).set(true);
(authService.user as any).set({
id: 1,
nombre_apellido: 'Ada Lovelace',
email: 'ada@example.com',
});
tenantState.set({ ...tenant, header_type: 'immersive' });
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('[data-testid="store-header-tickets"]')).toBeNull();
element.querySelector<HTMLButtonElement>('app-icon-button[variant="user"] button')?.click();
fixture.detectChanges();
expect(element.querySelector('[data-testid="user-dropdown-account.tickets"]')).not.toBeNull();
(fixture.componentInstance as any).isHomeRoute.set(false);
fixture.detectChanges();
expect(element.querySelector('[data-testid="store-header-tickets"]')).not.toBeNull();
});
it('shows immersive categories inside the header and navigates on selection', () => {
tenantState.set({ tenantState.set({
...tenant, ...tenant,
header_type: 'immersive', header_type: 'immersive',
display_categories: true, display_categories: true,
categories: [{ id: 11, nombre: 'Música', subcategories: [] }], 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); const fixture = TestBed.createComponent(StoreLayoutComponent);
@@ -306,7 +362,7 @@ describe('StoreLayoutComponent', () => {
expect(header).not.toBeNull(); expect(header).not.toBeNull();
expect(categories).not.toBeNull(); expect(categories).not.toBeNull();
expect(header?.contains(categories)).toBe(false); expect(header?.contains(categories)).toBe(true);
expect(categoryButton?.textContent?.trim()).toBe('MÚSICA'); expect(categoryButton?.textContent?.trim()).toBe('MÚSICA');
const navigate = vi.spyOn(TestBed.inject(Router), 'navigate'); const navigate = vi.spyOn(TestBed.inject(Router), 'navigate');
@@ -323,6 +379,43 @@ describe('StoreLayoutComponent', () => {
expect(element.querySelector('.immersive-categories')).toBeNull(); 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();
@@ -379,6 +472,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,
@@ -418,11 +531,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}"]`, );
), expect(headerLogo).not.toBeNull();
).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}"]`,
@@ -620,7 +735,7 @@ describe('StoreLayoutComponent', () => {
expect( expect(
compiled.querySelector('[data-testid="user-dropdown-account.purchases"]'), compiled.querySelector('[data-testid="user-dropdown-account.purchases"]'),
).not.toBeNull(); ).not.toBeNull();
expect(compiled.querySelector('[data-testid="user-dropdown-account.tickets"]')).toBeNull(); expect(compiled.querySelector('[data-testid="user-dropdown-account.tickets"]')).not.toBeNull();
expect(router.navigate).not.toHaveBeenCalled(); expect(router.navigate).not.toHaveBeenCalled();
}); });
@@ -695,7 +810,7 @@ describe('StoreLayoutComponent', () => {
expect(profileLink?.getAttribute('href')).toBe('/mi-cuenta/datos-personales'); expect(profileLink?.getAttribute('href')).toBe('/mi-cuenta/datos-personales');
expect(purchasesLink?.getAttribute('href')).toBe('/mi-cuenta/compras'); expect(purchasesLink?.getAttribute('href')).toBe('/mi-cuenta/compras');
expect(ticketsLink).toBeUndefined(); expect(ticketsLink?.getAttribute('href')).toBe('/mi-cuenta/tickets');
expect(logoutButton).toBeDefined(); expect(logoutButton).toBeDefined();
logoutButton!.click(); logoutButton!.click();

View File

@@ -71,6 +71,28 @@ export class StoreLayoutComponent implements OnInit {
protected readonly isHomeRoute = signal(this.isHomeUrl(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(
@@ -189,10 +211,12 @@ export class StoreLayoutComponent implements OnInit {
const profile = accountMenu.submenues.find((menu) => menu.code === 'account.profile'); const profile = accountMenu.submenues.find((menu) => menu.code === 'account.profile');
const purchases = accountMenu.submenues.find((menu) => menu.code === 'account.purchases'); const purchases = accountMenu.submenues.find((menu) => menu.code === 'account.purchases');
const tickets = accountMenu.submenues.find((menu) => menu.code === 'account.tickets');
return [ return [
...(profile ? [{ ...accountMenu, route: profile.route }] : []), ...(profile ? [{ ...accountMenu, route: profile.route }] : []),
...(purchases ? [purchases] : []), ...(purchases ? [purchases] : []),
...(tickets ? [tickets] : []),
]; ];
}); });
@@ -281,7 +305,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

@@ -2,6 +2,7 @@ import { ApiPaginatedResponse } from '../api-paginated-response.interface';
export interface StoreEvent { export interface StoreEvent {
id: number; id: number;
slug: string;
event_category_id: number | null; event_category_id: number | null;
title: string; title: string;
subtitle: string | null; subtitle: string | null;

View File

@@ -16,9 +16,9 @@ export class EventService extends BaseApiService {
}); });
} }
get(id: number): Observable<ApiResponse<StoreEvent>> { get(slug: string): Observable<ApiResponse<StoreEvent>> {
return this.http.get<ApiResponse<StoreEvent>>( return this.http.get<ApiResponse<StoreEvent>>(
`${this.tenantService.getTenantApiUrl()}/events/${id}`, `${this.tenantService.getTenantApiUrl()}/events/${slug}`,
); );
} }
} }

View File

@@ -117,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;
@@ -134,6 +135,7 @@ export interface Tenant {
footer_bg_image?: string | null; footer_bg_image?: string | null;
storefront_website_type_code?: string | null; storefront_website_type_code?: string | null;
header_type?: 'standard' | 'immersive'; header_type?: 'standard' | 'immersive';
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

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

@@ -1,4 +1,4 @@
<a class="event-card" [routerLink]="['/evento', event().id]"> <a class="event-card" [routerLink]="['/evento', event().slug]">
<div class="event-card__image"> <div class="event-card__image">
@if (event().image) { @if (event().image) {
<img [src]="event().image" [alt]="event().title" /> <img [src]="event().image" [alt]="event().title" />

View File

@@ -14,6 +14,12 @@
</a> </a>
} }
</nav> </nav>
@if (sidebarContent.template(); as sidebarTemplate) {
<div class="menu-page-layout__context">
<ng-container [ngTemplateOutlet]="sidebarTemplate" />
</div>
}
</aside> </aside>
<main class="menu-page-layout__content"> <main class="menu-page-layout__content">

View File

@@ -40,6 +40,10 @@
gap: 1rem; gap: 1rem;
} }
&__context {
margin-top: 2rem;
}
&__link { &__link {
color: #888888; color: #888888;
font-size: 0.875rem; font-size: 0.875rem;

View File

@@ -1,11 +1,34 @@
import { signal } from '@angular/core'; import { Component, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, provideRouter } from '@angular/router'; import { ActivatedRoute, provideRouter } from '@angular/router';
import { RouterTestingHarness } from '@angular/router/testing';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { Tenant } from '../../../../core/services/tenant.interface'; import { Tenant } from '../../../../core/services/tenant.interface';
import { TenantService } from '../../../../core/services/tenant.service'; import { TenantService } from '../../../../core/services/tenant.service';
import { MenuPageLayoutComponent } from './menu-page-layout.component'; import { MenuPageLayoutComponent } from './menu-page-layout.component';
import { MenuPageSidebarContentDirective } from './menu-page-sidebar-content.directive';
@Component({
selector: 'app-sidebar-test-badge',
template: '<strong class="sidebar-test-badge">Componente contextual</strong>',
})
class SidebarTestBadgeComponent {}
@Component({
imports: [MenuPageSidebarContentDirective, SidebarTestBadgeComponent],
template: `
<p class="routed-page-content">Contenido principal</p>
<ng-template menuPageSidebarContent>
<p class="sidebar-context-copy">{{ contextualCopy }}</p>
<app-sidebar-test-badge />
</ng-template>
`,
})
class SidebarTestPageComponent {
protected readonly contextualCopy = 'HTML declarado por la página';
}
function createTenant(): Tenant { function createTenant(): Tenant {
return { return {
@@ -133,4 +156,44 @@ describe('MenuPageLayoutComponent', () => {
'Consultas habituales', 'Consultas habituales',
); );
}); });
it('renders page-owned HTML and components below the sidebar navigation', async () => {
const tenant = signal(createTenant());
await TestBed.configureTestingModule({
providers: [
provideRouter([
{
path: 'mi-cuenta',
component: MenuPageLayoutComponent,
data: {
menuCode: 'account',
navigationLabel: 'Secciones de mi cuenta',
},
children: [{ path: 'tickets', component: SidebarTestPageComponent }],
},
]),
{
provide: TenantService,
useValue: { tenant: tenant.asReadonly() },
},
],
}).compileComponents();
const harness = await RouterTestingHarness.create('/mi-cuenta/tickets');
const element = harness.routeNativeElement as HTMLElement;
const navigation = element.querySelector('.menu-page-layout__nav');
const context = element.querySelector('.menu-page-layout__context');
expect(context?.previousElementSibling).toBe(navigation);
expect(context?.querySelector('.sidebar-context-copy')?.textContent?.trim()).toBe(
'HTML declarado por la página',
);
expect(context?.querySelector('.sidebar-test-badge')?.textContent?.trim()).toBe(
'Componente contextual',
);
expect(element.querySelector('.routed-page-content')?.textContent?.trim()).toBe(
'Contenido principal',
);
});
}); });

View File

@@ -1,18 +1,22 @@
import { NgTemplateOutlet } from '@angular/common';
import { Component, computed, inject } from '@angular/core'; import { Component, computed, inject } from '@angular/core';
import { ActivatedRoute, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; import { ActivatedRoute, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { findMenu } from '../../../../core/services/menu.utils'; import { findMenu } from '../../../../core/services/menu.utils';
import { TenantService } from '../../../../core/services/tenant.service'; import { TenantService } from '../../../../core/services/tenant.service';
import { MenuPageSidebarContentService } from './menu-page-sidebar-content.service';
@Component({ @Component({
selector: 'app-menu-page-layout', selector: 'app-menu-page-layout',
imports: [RouterLink, RouterLinkActive, RouterOutlet], imports: [NgTemplateOutlet, RouterLink, RouterLinkActive, RouterOutlet],
providers: [MenuPageSidebarContentService],
templateUrl: './menu-page-layout.component.html', templateUrl: './menu-page-layout.component.html',
styleUrl: './menu-page-layout.component.scss', styleUrl: './menu-page-layout.component.scss',
}) })
export class MenuPageLayoutComponent { export class MenuPageLayoutComponent {
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
private readonly tenantService = inject(TenantService); private readonly tenantService = inject(TenantService);
protected readonly sidebarContent = inject(MenuPageSidebarContentService);
protected readonly navigationLabel = protected readonly navigationLabel =
(this.route.snapshot.data['navigationLabel'] as string | undefined) ?? 'Secciones'; (this.route.snapshot.data['navigationLabel'] as string | undefined) ?? 'Secciones';

View File

@@ -0,0 +1,24 @@
import { Directive, inject, OnDestroy, OnInit, TemplateRef } from '@angular/core';
import { MenuPageSidebarContentService } from './menu-page-sidebar-content.service';
/**
* Projects page-owned content into the sidebar of the closest menu page layout.
* The template keeps the declaration context of the routed page, so it can use
* that page's fields, bindings and imported components.
*/
@Directive({
selector: 'ng-template[menuPageSidebarContent]',
})
export class MenuPageSidebarContentDirective implements OnInit, OnDestroy {
private readonly template = inject(TemplateRef<unknown>);
private readonly sidebarContent = inject(MenuPageSidebarContentService);
ngOnInit(): void {
this.sidebarContent.register(this.template);
}
ngOnDestroy(): void {
this.sidebarContent.unregister(this.template);
}
}

View File

@@ -0,0 +1,18 @@
import { Injectable, signal, TemplateRef } from '@angular/core';
@Injectable()
export class MenuPageSidebarContentService {
private readonly templateState = signal<TemplateRef<unknown> | null>(null);
readonly template = this.templateState.asReadonly();
register(template: TemplateRef<unknown>): void {
this.templateState.set(template);
}
unregister(template: TemplateRef<unknown>): void {
if (this.templateState() === template) {
this.templateState.set(null);
}
}
}

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

@@ -0,0 +1,9 @@
<section class="ticket-event-context" aria-labelledby="ticket-event-context-title">
@if (event().image; as image) {
<img class="ticket-event-context__image" [src]="image" alt="" />
}
<h2 id="ticket-event-context-title" class="ticket-event-context__title">
{{ event().title }}
</h2>
</section>

View File

@@ -0,0 +1,24 @@
:host {
display: block;
}
.ticket-event-context {
padding-top: 1.5rem;
border-top: 1px solid var(--border-color);
}
.ticket-event-context__image {
display: block;
width: 100%;
height: auto;
border-radius: 4px;
}
.ticket-event-context__title {
margin: 1rem 0 0;
color: #666666;
font-size: 13px;
font-weight: 700;
line-height: 1.4;
text-transform: uppercase;
}

View File

@@ -0,0 +1,15 @@
import { Component, input } from '@angular/core';
export interface TicketEventContext {
title: string;
image: string | null;
}
@Component({
selector: 'app-ticket-event-context',
templateUrl: './ticket-event-context.component.html',
styleUrl: './ticket-event-context.component.scss',
})
export class TicketEventContextComponent {
readonly event = input.required<TicketEventContext>();
}

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

@@ -109,3 +109,9 @@
} }
</div> </div>
</app-menu-content-section> </app-menu-content-section>
@if (eventId !== undefined) {
<ng-template menuPageSidebarContent>
<app-ticket-event-context [event]="eventContext()" />
</ng-template>
}

View File

@@ -1,24 +1,29 @@
import { signal } from '@angular/core'; import { signal } from '@angular/core';
import { By } from '@angular/platform-browser'; import { By } from '@angular/platform-browser';
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { ActivatedRoute } from '@angular/router';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { TenantService } from '../../../../../../core/services/tenant.service'; import { TenantService } from '../../../../../../core/services/tenant.service';
import { ToastService } from '../../../../../../core/services/toast.service'; import { ToastService } from '../../../../../../core/services/toast.service';
import { ModalService } from '../../../../../../core/services/modal.service'; import { ModalService } from '../../../../../../core/services/modal.service';
import { MenuPageSidebarContentService } from '../../../../components/menu-page-layout/menu-page-sidebar-content.service';
import { TicketComponent } from './components/ticket/ticket.component'; import { TicketComponent } from './components/ticket/ticket.component';
import { TicketResponse, TicketService } from './ticket.service'; import { TicketResponse, TicketService } from './ticket.service';
import { TicketsPage } from './tickets-page'; import { TicketsPage } from './tickets-page';
function withCustomLoading<T extends object>(service: T): T & { withCustomLoading: () => T } { function withCustomLoading<T extends object>(service: T): T & { withCustomLoading: () => T } {
return Object.assign(service, { const enhancedService = Object.assign({ getTicketEvents: () => Promise.resolve([]) }, service);
withCustomLoading: () => service,
return Object.assign(enhancedService, {
withCustomLoading: () => enhancedService,
}); });
} }
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,
@@ -299,4 +304,69 @@ describe('TicketsPage', () => {
ticket: 'ticket-1', ticket: 'ticket-1',
}); });
}); });
it('declares contextual test data when tickets belong to an event', async () => {
const getTickets = vi.fn().mockResolvedValue([]);
const getTicketEvents = vi.fn().mockResolvedValue([
{
id: 42,
title: 'XX Campeonato Mundial de Pelota Vasca',
image: 'https://example.com/pelota-vasca.jpg',
},
]);
await TestBed.configureTestingModule({
imports: [TicketsPage],
providers: [
MenuPageSidebarContentService,
{ provide: ToastService, useValue: { danger: vi.fn() } },
{
provide: ActivatedRoute,
useValue: { snapshot: { paramMap: { get: () => '42' } } },
},
],
})
.overrideComponent(TicketsPage, {
set: {
providers: [
{
provide: TicketService,
useValue: withCustomLoading({ getTickets, getTicketEvents }),
},
],
},
})
.compileComponents();
const fixture = TestBed.createComponent(TicketsPage);
fixture.detectChanges();
await fixture.whenStable();
await vi.waitFor(() => expect(getTicketEvents).toHaveBeenCalledOnce());
await vi.waitFor(() =>
expect(
(
fixture.componentInstance as unknown as { eventContext: () => { image: string | null } }
).eventContext().image,
).toBe('https://example.com/pelota-vasca.jpg'),
);
const sidebarContent = TestBed.inject(MenuPageSidebarContentService);
const sidebarView = sidebarContent.template()?.createEmbeddedView({});
sidebarView?.detectChanges();
const container = document.createElement('div');
sidebarView?.rootNodes.forEach((node) => container.appendChild(node));
expect(getTickets).toHaveBeenCalledWith(42);
expect(container.querySelector('.ticket-event-context')?.textContent).toContain(
'XX Campeonato Mundial de Pelota Vasca',
);
expect(container.querySelector<HTMLImageElement>('.ticket-event-context__image')?.src).toBe(
'https://example.com/pelota-vasca.jpg',
);
expect(container.querySelector('.ticket-event-context__details')).toBeNull();
expect(container.querySelector('.ticket-event-context__recommendation')).toBeNull();
sidebarView?.destroy();
});
}); });

View File

@@ -1,15 +1,27 @@
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';
import { IconButtonComponent } from '../../../../../../shared/components/icon-button/icon-button.component'; import { IconButtonComponent } from '../../../../../../shared/components/icon-button/icon-button.component';
import { MenuContentSectionComponent } from '../../../../components/menu-content-section/menu-content-section.component'; import { MenuContentSectionComponent } from '../../../../components/menu-content-section/menu-content-section.component';
import { MenuPageSidebarContentDirective } from '../../../../components/menu-page-layout/menu-page-sidebar-content.directive';
import {
TicketEventContext,
TicketEventContextComponent,
} from './components/ticket-event-context/ticket-event-context.component';
import { TicketComponent } from './components/ticket/ticket.component'; import { TicketComponent } from './components/ticket/ticket.component';
import { TicketResponse, TicketService } from './ticket.service'; import { TicketResponse, TicketService } from './ticket.service';
@Component({ @Component({
selector: 'app-tickets-page', selector: 'app-tickets-page',
imports: [IconButtonComponent, TicketComponent, MenuContentSectionComponent], imports: [
IconButtonComponent,
TicketComponent,
TicketEventContextComponent,
MenuContentSectionComponent,
MenuPageSidebarContentDirective,
],
providers: [TicketService], providers: [TicketService],
templateUrl: './tickets-page.html', templateUrl: './tickets-page.html',
styleUrl: './tickets-page.scss', styleUrl: './tickets-page.scss',
@@ -18,7 +30,13 @@ 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 eventId = this.eventIdFromRoute();
protected readonly eventContext = signal<TicketEventContext>({
title: 'XX Campeonato Mundial de Pelota Vasca',
image: null,
});
protected readonly tickets = signal<TicketResponse[]>([]); protected readonly tickets = signal<TicketResponse[]>([]);
protected readonly activeTickets = computed(() => protected readonly activeTickets = computed(() =>
this.tickets().filter((ticket) => !this.isInactive(ticket)), this.tickets().filter((ticket) => !this.isInactive(ticket)),
@@ -36,7 +54,25 @@ export class TicketsPage implements OnInit {
async ngOnInit(): Promise<void> { async ngOnInit(): Promise<void> {
try { try {
this.tickets.set(await this.ticketService.withCustomLoading().getTickets()); const service = this.ticketService.withCustomLoading();
this.tickets.set(await service.getTickets(this.eventId));
if (this.eventId !== undefined) {
void service
.getTicketEvents()
.then((events) => {
const event = events.find((candidate) => candidate.id === this.eventId);
if (event) {
this.eventContext.update((context) => ({
...context,
title: event.title,
image: event.image,
}));
}
})
.catch(() => undefined);
}
} 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 +80,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 (checkoutRemainingTime(); as remainingTime) { @if (usesEventCheckoutSummary()) {
@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

@@ -22,10 +22,12 @@ import { EventListComponent } from '../../components/event-list/event-list.compo
export class EventCategoryPageComponent { export class EventCategoryPageComponent {
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
private readonly tenant = inject(TenantService).tenant; private readonly tenant = inject(TenantService).tenant;
private readonly params = toSignal(this.route.paramMap, { initialValue: this.route.snapshot.paramMap }); private readonly params = toSignal(this.route.paramMap, {
initialValue: this.route.snapshot.paramMap,
});
protected readonly category = computed(() => { protected readonly category = computed(() => {
const id = Number(this.params().get('id')); const id = Number(this.params().get('id'));
return this.tenant()?.categories.find((category) => category.id === id) ?? null; return this.tenant()?.event_categories?.find((category) => category.id === id) ?? null;
}); });
} }

View File

@@ -1,6 +1,6 @@
@if (event(); as item) { @if (event(); as item) {
<div class="event-detail row gx-sm-4 gx-lg-5"> <div class="event-detail">
<aside class="event-detail__sidebar col-12 col-sm-4"> <aside class="event-detail__sidebar">
<div class="event-detail__image" [class.event-detail__image--placeholder]="!item.image"> <div class="event-detail__image" [class.event-detail__image--placeholder]="!item.image">
@if (item.image) { @if (item.image) {
<img [src]="item.image" [alt]="item.title" /> <img [src]="item.image" [alt]="item.title" />
@@ -12,37 +12,9 @@
@if (item.starts_at) { @if (item.starts_at) {
<app-counter-to [startsAt]="item.starts_at" /> <app-counter-to [startsAt]="item.starts_at" />
} }
@if (item.description) {
<section class="event-detail__description" aria-labelledby="event-description-title">
<h2 id="event-description-title">Descripción</h2>
<p>{{ item.description }}</p>
</section>
}
@if (item.social_media?.length) {
<section class="event-detail__social" aria-labelledby="event-social-title">
<h2 id="event-social-title">Información</h2>
@for (social of item.social_media; track social.code) {
<a [href]="social.url" target="_blank" rel="noopener noreferrer">
<i [class]="socialIcon(social.code)" aria-hidden="true"></i>
<span>{{ socialLabel(social.code) }}</span>
</a>
}
</section>
}
@if (mapLocations().length) {
<app-location-map
class="event-detail__map"
[locations]="mapLocations()"
ariaLabel="Ubicación del evento"
mapStyle="minimal-light"
/>
}
</aside> </aside>
<main class="event-detail__main col-12 col-sm-8"> <main class="event-detail__main">
<header class="event-detail__header"> <header class="event-detail__header">
<h1>{{ item.title }}</h1> <h1>{{ item.title }}</h1>
@if (item.subtitle) { <p class="event-detail__subtitle">{{ item.subtitle }}</p> } @if (item.subtitle) { <p class="event-detail__subtitle">{{ item.subtitle }}</p> }
@@ -95,7 +67,7 @@
</div> </div>
<div class="event-detail__total"><span>Total</span><strong>{{ formatPrice(total()) }}</strong></div> <div class="event-detail__total"><span>Total</span><strong>{{ formatPrice(total()) }}</strong></div>
<div class="event-detail__buttons"> <div class="event-detail__buttons">
<app-button variant="cancel" (click)="cancel()">Cancelar</app-button> <app-button variant="cancel" [disabled]="!hasSelectedItems()" (click)="cancel()">Cancelar</app-button>
<app-button variant="primary" [disabled]="total() === 0 || submitting()" (click)="buyNow()"> <app-button variant="primary" [disabled]="total() === 0 || submitting()" (click)="buyNow()">
{{ submitting() ? 'Procesando…' : 'Comprar' }} {{ submitting() ? 'Procesando…' : 'Comprar' }}
</app-button> </app-button>
@@ -105,6 +77,36 @@
} }
</section> </section>
</main> </main>
<aside class="event-detail__details">
@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>
</div> </div>
} @else if (!loading()) { } @else if (!loading()) {
<p role="alert">No encontramos este evento.</p> <p role="alert">No encontramos este evento.</p>

View File

@@ -1,6 +1,9 @@
:host { display: block; color: #666666; } :host { display: block; color: #666666; }
.event-detail { margin-bottom: 3rem; } .event-detail { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); column-gap: 1.5rem; margin-bottom: 3rem; }
.event-detail__sidebar, .event-detail__main { min-width: 0; } .event-detail__sidebar, .event-detail__main { min-width: 0; }
.event-detail__sidebar { grid-column: 1; grid-row: 1; }
.event-detail__main { grid-column: 2; grid-row: 1 / span 2; }
.event-detail__details { min-width: 0; grid-column: 1; grid-row: 2; }
.event-detail__image { background: #e7e8e9; } .event-detail__image { background: #e7e8e9; }
.event-detail__image--placeholder { aspect-ratio: 1.43; display: grid; place-items: center; } .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 img { width: 100%; height: auto; display: block; }
@@ -10,8 +13,8 @@
.event-detail__description p { margin: 0; white-space: pre-line; line-height: 1.36; font-size: 14px; } .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 { 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 { color: #666666; font-family: 'Gotham Book', sans-serif; font-size: 14px; font-weight: 400; display: flex; align-items: center; gap: .5rem; text-decoration: none; font-style: italic; }
.event-detail__social a:hover { color: var(--tenant-primary); text-decoration: underline; } .event-detail__social a:hover { color: var(--tenant-primary); text-decoration: none; }
.event-detail__social i { width: 14px; font-size: 14px; text-align: center; } .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__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 { 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__header h1 { margin: 0; color: #666666; font-size: 24px; line-height: 1.25; font-weight: 700; text-transform: uppercase; letter-spacing: .03em; }
@@ -34,10 +37,40 @@
.event-detail__buttons { display: flex; justify-content: flex-end; gap: .5rem; margin-top: 1.6rem; } .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__buttons app-button { min-width: 0; flex: 0 0 36%; }
.event-detail__empty { color: #7c8286; font-size: .85rem; } .event-detail__empty { color: #7c8286; font-size: .85rem; }
@media (min-width: 992px) {
.event-detail { column-gap: 3rem; }
}
@media (max-width: 575.98px) { @media (max-width: 575.98px) {
.event-detail { grid-template-columns: minmax(0, 1fr); }
.event-detail__sidebar, .event-detail__main, .event-detail__details { grid-column: 1; grid-row: auto; }
.event-detail__main { margin-top: 2rem; } .event-detail__main { margin-top: 2rem; }
.event-detail__date-select { width: 100%; }
.event-detail__description, .event-detail__social { margin-top: 1.5rem; } .event-detail__description, .event-detail__social { margin-top: 1.5rem; }
.event-detail__header { min-height: 0; margin-bottom: 2rem; } .event-detail__header { min-height: 0; margin-bottom: 2rem; }
.event-detail__buttons { flex-wrap: wrap; } .event-detail__header h1 { font-size: 20px; }
.event-detail__buttons app-button { flex: 1 1 100%; } .event-detail__product-list { gap: 42px; }
.event-detail__product {
display: grid;
grid-template-columns: minmax(0, 1fr) 96px 72px;
column-gap: 26px;
margin-inline: 0;
}
.event-detail__product > * { width: auto; margin-top: 0; padding-inline: 0; }
.event-detail__product h3 { align-self: center; font-size: 13px; }
.event-detail__product-action { margin-left: 0 !important; }
.event-detail__product-action app-quantity-selector {
--quantity-selector-width: 96px;
--quantity-selector-height: 32px;
--quantity-selector-button-width: 28px;
--quantity-selector-button-font-size: 14px;
--quantity-selector-value-font-size: 14px;
}
.event-detail__price { align-self: center; font-size: 14px; }
.event-detail__total { margin-top: 44px; padding-top: 25px; font-size: 13px; }
.event-detail__total strong { font-size: 20px; }
.event-detail__buttons { gap: 10px; margin-top: 44px; }
.event-detail__buttons app-button {
--app-button-min-height: 50px;
flex: 1 1 0;
}
} }

View File

@@ -56,12 +56,15 @@ export class EventDetailPageComponent {
const choice = this.resolveChoice(product); const choice = this.resolveChoice(product);
return sum + (choice ? choice.price * this.quantityFor(product.id) : 0); return sum + (choice ? choice.price * this.quantityFor(product.id) : 0);
}, 0)); }, 0));
protected readonly hasSelectedItems = computed(() =>
Object.values(this.quantities()).some((quantity) => quantity > 0),
);
constructor() { constructor() {
this.route.paramMap.pipe( this.route.paramMap.pipe(
map((params) => Number(params.get('id'))), map((params) => params.get('slug')?.trim() ?? ''),
switchMap((id) => Number.isInteger(id) && id > 0 switchMap((slug) => slug
? this.events.get(id).pipe(map((response) => response.data), catchError(() => of(null))) ? this.events.get(slug).pipe(map((response) => response.data), catchError(() => of(null)))
: of(null)), : of(null)),
takeUntilDestroyed(this.destroyRef), takeUntilDestroyed(this.destroyRef),
).subscribe((event) => { ).subscribe((event) => {
@@ -131,7 +134,7 @@ export class EventDetailPageComponent {
} }
protected cancel(): void { protected cancel(): void {
void this.router.navigate(['/']); this.quantities.set({});
} }
protected async buyNow(): Promise<void> { protected async buyNow(): Promise<void> {

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

@@ -30,7 +30,9 @@ export const routes: Routes = [
canMatch: [isMultiEventStorefront], canMatch: [isMultiEventStorefront],
canActivate: [hasMenuGuard('event.index')], canActivate: [hasMenuGuard('event.index')],
loadComponent: () => loadComponent: () =>
import('./pages/event-home-page/event-home-page.component').then((m) => m.EventHomePageComponent), import('./pages/event-home-page/event-home-page.component').then(
(m) => m.EventHomePageComponent,
),
}, },
{ {
path: '', path: '',
@@ -86,7 +88,9 @@ export const routes: Routes = [
path: 'buscar', path: 'buscar',
canMatch: [isMultiEventStorefront], canMatch: [isMultiEventStorefront],
loadComponent: () => loadComponent: () =>
import('./pages/event-search-page/event-search-page.component').then((m) => m.EventSearchPageComponent), import('./pages/event-search-page/event-search-page.component').then(
(m) => m.EventSearchPageComponent,
),
}, },
{ {
path: 'buscar', path: 'buscar',
@@ -94,15 +98,18 @@ export const routes: Routes = [
import('./pages/search-page/search-page.component').then((m) => m.SearchPageComponent), import('./pages/search-page/search-page.component').then((m) => m.SearchPageComponent),
}, },
{ {
path: 'evento/:id', path: 'evento/:slug',
canMatch: [isMultiEventStorefront], canMatch: [isMultiEventStorefront],
canActivate: [hasMenuGuard('event.detail')], canActivate: [hasMenuGuard('event.detail')],
loadComponent: () => loadComponent: () =>
import('./pages/event-detail-page/event-detail-page.component').then((m) => m.EventDetailPageComponent), import('./pages/event-detail-page/event-detail-page.component').then(
(m) => m.EventDetailPageComponent,
),
}, },
{ {
path: 'categoria/:id', path: 'eventos/categoria/:id',
canMatch: [isMultiEventStorefront], canMatch: [isMultiEventStorefront],
canActivate: [hasMenuGuard('event.category')],
loadComponent: () => loadComponent: () =>
import('./pages/event-category-page/event-category-page.component').then( import('./pages/event-category-page/event-category-page.component').then(
(m) => m.EventCategoryPageComponent, (m) => m.EventCategoryPageComponent,
@@ -110,6 +117,7 @@ export const routes: Routes = [
}, },
{ {
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,
@@ -223,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

@@ -4,12 +4,18 @@
} }
.btn { .btn {
min-height: 40px; min-height: var(--app-button-min-height, 40px);
font-weight: 700; font-weight: 700;
font-size: 15px; font-size: 15px;
width: 100%; width: 100%;
} }
@media (max-width: 767.98px) {
.btn {
font-size: 11px;
}
}
.btn-primary, .btn-primary,
.btn-secondary, .btn-secondary,
.btn-danger { .btn-danger {

View File

@@ -9,6 +9,8 @@
[src]="image" [src]="image"
alt="" alt=""
[loading]="set === 0 && $index < 5 ? 'eager' : 'lazy'" [loading]="set === 0 && $index < 5 ? 'eager' : 'lazy'"
fetchpriority="low"
decoding="async"
/> />
} }
</div> </div>

View File

@@ -14,20 +14,21 @@
.continuous-carousel__track { .continuous-carousel__track {
display: flex; display: flex;
width: max-content; width: max-content;
margin-left: var(--carousel-offset, 0px);
animation: continuous-carousel-scroll var(--duration) linear infinite; animation: continuous-carousel-scroll var(--duration) linear infinite;
} }
.continuous-carousel__set { .continuous-carousel__set {
display: flex; display: flex;
flex: none; flex: none;
gap: var(--gap); gap: var(--carousel-gap, var(--gap));
padding-right: var(--gap); padding-right: var(--carousel-gap, var(--gap));
} }
.continuous-carousel__image { .continuous-carousel__image {
display: block; display: block;
flex: none; flex: none;
width: var(--card-width); width: var(--carousel-card-width, var(--card-width));
aspect-ratio: 1.45; aspect-ratio: 1.45;
object-fit: cover; object-fit: cover;
border-radius: 2px; border-radius: 2px;
@@ -64,6 +65,7 @@
} }
.continuous-carousel__track { .continuous-carousel__track {
animation: none; animation: none;
margin-left: 0;
} }
.continuous-carousel__set[aria-hidden='true'] { .continuous-carousel__set[aria-hidden='true'] {
display: none; display: none;

View File

@@ -1,5 +1,5 @@
:host { display: block; margin-top: 9px; } :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 { min-height: 30px; padding-block: 12px; display: grid; grid-template-columns: repeat(4, 1fr); align-items: center; text-align: center; border: 1px solid #a0a0a0; border-radius: 3px; }
.counter-to > div + div { border-left: 1px solid #e6e7e8; } .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 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 small { display: block; margin-top: 1px; color: #666666; font-size: 11px; font-weight: 400; letter-spacing: .02em; }

View File

@@ -0,0 +1,63 @@
<div
class="carousel"
role="group"
[attr.aria-label]="ariaLabel()"
[class.carousel--looping]="looping()"
>
@if (showArrows()) {
<button
type="button"
class="carousel__arrow"
aria-label="Ver elementos anteriores"
[disabled]="!overflowing()"
(click)="move(-1)"
>
<i class="fa-solid fa-chevron-left" aria-hidden="true"></i>
</button>
}
<div #viewport class="carousel__viewport" (scroll)="onScroll()" (scrollend)="normalize()">
<div class="carousel__track">
@for (block of blocks(); track block) {
@if (block === 0) {
<div #canonical class="carousel__set">
@for (item of items(); track item.value) {
<button
type="button"
class="carousel__item"
(click)="itemSelect.emit(item)"
(focus)="reveal($event)"
>
{{ item.label }}
</button>
}
</div>
} @else {
<div class="carousel__set" aria-hidden="true">
@for (item of items(); track item.value) {
<button
type="button"
class="carousel__item"
tabindex="-1"
(mousedown)="$event.preventDefault()"
(click)="selectCopy(item)"
>
{{ item.label }}
</button>
}
</div>
}
}
</div>
</div>
@if (showArrows()) {
<button
type="button"
class="carousel__arrow"
aria-label="Ver más elementos"
[disabled]="!overflowing()"
(click)="move(1)"
>
<i class="fa-solid fa-chevron-right" aria-hidden="true"></i>
</button>
}
</div>

View File

@@ -0,0 +1,71 @@
:host {
display: block;
min-width: 0;
}
.carousel {
display: flex;
align-items: center;
min-width: 0;
}
.carousel__viewport {
container-type: inline-size;
flex: 1;
min-width: 0;
overflow-x: auto;
scrollbar-width: none;
overflow-anchor: none;
scroll-snap-type: var(--horizontal-carousel-snap, none);
}
.carousel__viewport::-webkit-scrollbar {
display: none;
}
.carousel__track {
display: flex;
width: max-content;
min-width: 100%;
justify-content: center;
}
.carousel__set {
position: relative;
display: flex;
flex: none;
align-items: center;
gap: var(--horizontal-carousel-gap, 1.5rem);
}
.carousel--looping .carousel__set {
padding-right: var(--horizontal-carousel-gap, 1.5rem);
}
.carousel__item,
.carousel__arrow {
flex: none;
min-height: 44px;
border: 0;
padding: 0;
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
}
.carousel__item {
width: var(--horizontal-carousel-item-width, auto);
white-space: var(--horizontal-carousel-white-space, nowrap);
overflow-wrap: anywhere;
scroll-snap-align: start;
text-transform: var(--horizontal-carousel-text-transform, none);
}
.carousel__arrow {
width: 32px;
font-size: 10px;
}
.carousel__arrow:disabled {
opacity: 0.35;
cursor: default;
}
.carousel__item:hover {
text-decoration: underline;
}
.carousel__item:focus-visible,
.carousel__arrow:focus-visible {
outline: 2px solid currentColor;
outline-offset: -2px;
}

View File

@@ -0,0 +1,180 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { HorizontalCarouselComponent } from './horizontal-carousel.component';
describe('HorizontalCarouselComponent', () => {
let fixture: ComponentFixture<HorizontalCarouselComponent>;
let host: HTMLElement;
let available: number;
let resize: () => void;
const disconnect = vi.fn();
const items = Array.from({ length: 5 }, (_, index) => ({ value: index, label: `Item ${index}` }));
const viewport = () => host.querySelector<HTMLElement>('.carousel__viewport')!;
const period = () =>
host.querySelector<HTMLElement>('.carousel__set')!.getBoundingClientRect().width;
beforeEach(async () => {
available = 320;
vi.stubGlobal(
'ResizeObserver',
class {
constructor(callback: () => void) {
resize = callback;
}
observe() {}
disconnect = disconnect;
},
);
vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(function (
this: HTMLElement,
) {
return this.classList.contains('carousel__viewport') ? available - 64 : available;
});
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (
this: HTMLElement,
) {
const index = this.parentElement ? Array.from(this.parentElement.children).indexOf(this) : 0;
const left = this.classList.contains('carousel__item') ? index * 124.25 : 0;
const width = this.classList.contains('carousel__set')
? Math.max(
0,
this.children.length * 124.25 - (this.closest('.carousel--looping') ? 0 : 24.25),
)
: 100;
return {
left,
right: left + width,
width,
top: 0,
bottom: 44,
height: 44,
x: left,
y: 0,
toJSON() {},
};
});
vi.stubGlobal(
'matchMedia',
vi.fn(() => ({ matches: true })),
);
// jsdom has no layout or scrolling implementation.
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
configurable: true,
value: vi.fn(function (this: HTMLElement, options: ScrollToOptions) {
this.scrollLeft = options.left ?? 0;
}),
});
Object.defineProperty(HTMLElement.prototype, 'scrollBy', {
configurable: true,
value: vi.fn(function (this: HTMLElement, options: ScrollToOptions) {
this.scrollLeft += options.left ?? 0;
}),
});
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
configurable: true,
value: vi.fn(),
});
await TestBed.configureTestingModule({
imports: [HorizontalCarouselComponent],
}).compileComponents();
fixture = TestBed.createComponent(HorizontalCarouselComponent);
fixture.componentRef.setInput('items', items);
fixture.componentRef.setInput('ariaLabel', 'Opciones');
host = fixture.nativeElement;
fixture.detectChanges();
await fixture.whenStable();
});
afterEach(() => {
fixture.destroy();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it('starts in the middle with only one accessible set', () => {
expect(host.querySelectorAll('.carousel__set')).toHaveLength(3);
expect(host.querySelectorAll('[aria-hidden="true"] .carousel__item')).toHaveLength(10);
expect(host.querySelectorAll('.carousel__item:not([tabindex="-1"])')).toHaveLength(5);
expect(viewport().scrollLeft).toBe(period());
});
it('wraps both directions preserving the exact fractional offset', () => {
const width = period();
viewport().scrollLeft = width - 30.5;
viewport().dispatchEvent(new Event('scrollend'));
expect(viewport().scrollLeft).toBe(2 * width - 30.5);
viewport().scrollLeft = 2 * width + 42.5;
viewport().dispatchEvent(new Event('scrollend'));
expect(viewport().scrollLeft).toBe(width + 42.5);
});
it('uses the idle fallback after a gesture', () => {
vi.useFakeTimers();
viewport().scrollLeft = 12;
viewport().dispatchEvent(new Event('scroll'));
vi.advanceTimersByTime(180);
expect(viewport().scrollLeft).toBe(period() + 12);
vi.useRealTimers();
});
it('arrows respect reduced motion', () => {
host.querySelectorAll<HTMLButtonElement>('.carousel__arrow')[1].click();
expect(viewport().scrollBy).toHaveBeenCalledWith({
left: (available - 64) * 0.8,
behavior: 'instant',
});
});
it('selects the original value when clicking a copy', () => {
const selected = vi.fn();
fixture.componentInstance.itemSelect.subscribe(selected);
host.querySelector<HTMLButtonElement>('[aria-hidden="true"] .carousel__item')!.click();
expect(selected).toHaveBeenCalledWith(items[0]);
});
it('removes copies and arrows when resized to fit, then restores them', async () => {
available = 1000;
resize();
fixture.detectChanges();
await fixture.whenStable();
expect(host.querySelectorAll('.carousel__set')).toHaveLength(1);
expect(host.querySelectorAll('.carousel__arrow')).toHaveLength(0);
available = 320;
resize();
fixture.detectChanges();
await fixture.whenStable();
expect(host.querySelectorAll('.carousel__set')).toHaveLength(3);
expect(viewport().scrollLeft).toBe(period());
});
it('keeps arrows with three visible slots and advances one item', async () => {
host.style.setProperty('--horizontal-carousel-visible-items', '3');
resize();
fixture.detectChanges();
await fixture.whenStable();
host.querySelectorAll<HTMLElement>('.carousel__set').forEach((set) => {
set.style.columnGap = '8px';
});
host.querySelectorAll<HTMLButtonElement>('.carousel__arrow')[1].click();
expect(viewport().scrollBy).toHaveBeenCalledWith({ left: 108, behavior: 'instant' });
fixture.componentRef.setInput('items', items.slice(0, 3));
fixture.detectChanges();
await fixture.whenStable();
expect(host.querySelectorAll('.carousel__set')).toHaveLength(1);
expect(host.querySelectorAll('.carousel__arrow:disabled')).toHaveLength(2);
});
it('supports noncircular overflow and empty lists', async () => {
fixture.componentRef.setInput('circular', false);
fixture.detectChanges();
await fixture.whenStable();
expect(host.querySelectorAll('.carousel__set')).toHaveLength(1);
expect(host.querySelectorAll('.carousel__arrow')).toHaveLength(2);
fixture.componentRef.setInput('items', []);
fixture.detectChanges();
await fixture.whenStable();
expect(host.querySelectorAll('.carousel__arrow')).toHaveLength(0);
expect(host.querySelectorAll('.carousel__item')).toHaveLength(0);
});
});

View File

@@ -0,0 +1,154 @@
import {
afterNextRender,
afterRenderEffect,
ChangeDetectionStrategy,
Component,
computed,
DestroyRef,
ElementRef,
inject,
input,
output,
signal,
viewChild,
} from '@angular/core';
export interface HorizontalCarouselItem {
/** Unique within the carousel. */
value: string | number;
label: string;
}
@Component({
selector: 'app-horizontal-carousel',
templateUrl: './horizontal-carousel.component.html',
styleUrl: './horizontal-carousel.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class HorizontalCarouselComponent {
readonly items = input.required<readonly HorizontalCarouselItem[]>();
readonly circular = input(true);
readonly ariaLabel = input.required<string>();
readonly itemSelect = output<HorizontalCarouselItem>();
protected readonly overflowing = signal(false);
protected readonly visibleItems = signal<number | null>(null);
protected readonly showArrows = computed(
() => this.overflowing() || (this.visibleItems() !== null && this.items().length > 0),
);
protected readonly looping = computed(() => this.circular() && this.overflowing());
protected readonly blocks = computed(() => (this.looping() ? [-1, 0, 1] : [0]));
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly viewport = viewChild.required<ElementRef<HTMLElement>>('viewport');
private readonly canonical = viewChild.required<ElementRef<HTMLElement>>('canonical');
private readonly destroyRef = inject(DestroyRef);
private settleTimer?: ReturnType<typeof setTimeout>;
private observer?: ResizeObserver;
private initializedLoop = false;
private transferringFocus = false;
private lastItems?: readonly HorizontalCarouselItem[];
constructor() {
afterRenderEffect(() => {
const items = this.items();
const looping = this.looping();
const set = this.canonical().nativeElement;
this.measure();
if (this.lastItems !== items || this.initializedLoop !== looping) {
this.viewport().nativeElement.scrollLeft = looping ? set.getBoundingClientRect().width : 0;
this.lastItems = items;
this.initializedLoop = looping;
}
});
afterNextRender(() => {
this.observer = new ResizeObserver(() => {
this.measure();
this.normalize();
});
this.observer.observe(this.host.nativeElement);
this.observer.observe(this.canonical().nativeElement);
});
this.destroyRef.onDestroy(() => {
this.observer?.disconnect();
clearTimeout(this.settleTimer);
});
}
private measure(): void {
const slots = Number(
this.host.nativeElement.ownerDocument.defaultView
?.getComputedStyle(this.host.nativeElement)
.getPropertyValue('--horizontal-carousel-visible-items'),
);
this.visibleItems.set(slots > 0 ? slots : null);
if (slots > 0) {
this.overflowing.set(this.items().length > slots);
return;
}
const set = this.canonical().nativeElement;
const children = set.children;
const first = children.item(0) as HTMLElement | null;
const last = children.item(children.length - 1) as HTMLElement | null;
const width =
first && last ? last.getBoundingClientRect().right - first.getBoundingClientRect().left : 0;
const available = this.host.nativeElement.clientWidth;
this.overflowing.set(available > 0 && width > available + 1);
}
protected move(direction: number): void {
const viewport = this.viewport().nativeElement;
const set = this.canonical().nativeElement;
const first = set.children.item(0) as HTMLElement | null;
const gap =
parseFloat(viewport.ownerDocument.defaultView?.getComputedStyle(set).columnGap ?? '0') || 0;
const distance =
this.visibleItems() && first
? first.getBoundingClientRect().width + gap
: viewport.clientWidth * 0.8;
const reduceMotion = viewport.ownerDocument.defaultView?.matchMedia(
'(prefers-reduced-motion: reduce)',
).matches;
viewport.scrollBy({
left: direction * distance,
behavior: reduceMotion ? 'instant' : 'smooth',
});
}
protected onScroll(): void {
clearTimeout(this.settleTimer);
// Fallback for browsers without scrollend; momentum keeps resetting the timer.
this.settleTimer = setTimeout(() => this.normalize(), 180);
}
protected normalize(): void {
clearTimeout(this.settleTimer);
if (!this.looping()) return;
const viewport = this.viewport().nativeElement;
const width = this.canonical().nativeElement.getBoundingClientRect().width;
if (!width) return;
const left = viewport.scrollLeft;
if (left < width || left >= 2 * width) {
viewport.scrollTo({ left: width + (((left % width) + width) % width), behavior: 'instant' });
}
}
protected reveal(event: FocusEvent): void {
// Only the canonical buttons participate in keyboard / screen-reader navigation.
if (this.transferringFocus) return;
(event.target as HTMLElement).scrollIntoView({
block: 'nearest',
inline: 'nearest',
behavior: 'instant',
});
}
protected selectCopy(item: HorizontalCarouselItem): void {
const index = this.items().findIndex((candidate) => candidate.value === item.value);
this.transferringFocus = true;
(this.canonical().nativeElement.children.item(index) as HTMLElement | null)?.focus({
preventScroll: true,
});
this.transferringFocus = false;
this.itemSelect.emit(item);
}
}

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

View File

@@ -4,16 +4,16 @@
.quantity-selector { .quantity-selector {
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
width: 78px; width: var(--quantity-selector-width, 78px);
height: 40px; height: var(--quantity-selector-height, 40px);
min-height: 40px; min-height: var(--quantity-selector-height, 40px);
background-color: #ffffff; background-color: #ffffff;
box-sizing: border-box; box-sizing: border-box;
&__button { &__button {
width: 24px; width: var(--quantity-selector-button-width, 24px);
height: 100%; height: 100%;
font-size: 20px; font-size: var(--quantity-selector-button-font-size, 20px);
color: #6f6f6f; color: #6f6f6f;
background-color: #ffffff; background-color: #ffffff;
transition: transition:
@@ -34,7 +34,7 @@
} }
&__value { &__value {
font-size: 16px; font-size: var(--quantity-selector-value-font-size, 16px);
color: #6f6f6f; color: #6f6f6f;
} }

View File

@@ -11,6 +11,11 @@
line-height: 1.5; line-height: 1.5;
} }
h2.simple-modal__content {
font-size: 15px;
font-weight: bold;
}
.simple-modal__actions { .simple-modal__actions {
display: flex; display: flex;
justify-content: center; justify-content: center;

View File

@@ -2,7 +2,7 @@
z-index: 1500; z-index: 1500;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.25rem;
pointer-events: none; pointer-events: none;
width: 90vw; width: 90vw;
max-width: 800px; max-width: 800px;