feat(events): add multi-event storefront pages

This commit is contained in:
2026-09-18 14:33:49 -03:00
parent 657ef1d89f
commit 6997550767
12 changed files with 244 additions and 1 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,19 @@
<a routerLink="/" class="event-detail__back">← Volver a eventos</a>
@if (event(); as item) {
<article class="event-detail">
<div class="event-detail__image">
@if (item.image) {
<img [src]="item.image" [alt]="item.title" />
}
</div>
<div class="event-detail__content">
<h1>{{ item.title }}</h1>
@if (item.subtitle) { <p class="event-detail__subtitle">{{ item.subtitle }}</p> }
@if (item.date_text) { <p><i class="fa-regular fa-calendar" aria-hidden="true"></i> {{ item.date_text }}</p> }
@if (item.location) { <p><i class="fa-solid fa-location-dot" aria-hidden="true"></i> {{ item.location }}</p> }
@if (item.description) { <p class="event-detail__description">{{ item.description }}</p> }
</div>
</article>
} @else if (!loading()) {
<p role="alert">No encontramos este evento.</p>
}

View File

@@ -0,0 +1,8 @@
.event-detail__back { display: inline-block; margin-bottom: 2rem; color: var(--tenant-primary); }
.event-detail { display: grid; grid-template-columns: 1fr 1fr; gap: clamp(2rem, 5vw, 5rem); }
.event-detail__image { aspect-ratio: 1.43; background: #e5e6e7; }
.event-detail__image img { width: 100%; height: 100%; object-fit: cover; display: block; }
.event-detail__content h1 { font-size: clamp(1.7rem, 3vw, 2.8rem); text-transform: uppercase; }
.event-detail__subtitle { color: #63666a; }
.event-detail__description { margin-top: 2rem; white-space: pre-line; }
@media (max-width: 767.98px) { .event-detail { grid-template-columns: 1fr; } }

View File

@@ -0,0 +1,35 @@
import { ChangeDetectionStrategy, Component, DestroyRef, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { catchError, map, of, switchMap } from 'rxjs';
import { EventService } from '../../../../core/services/event/event.service';
import { StoreEvent } from '../../../../core/services/event/event.interface';
@Component({
selector: 'app-event-detail-page',
imports: [RouterLink],
templateUrl: './event-detail-page.component.html',
styleUrl: './event-detail-page.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EventDetailPageComponent {
private readonly route = inject(ActivatedRoute);
private readonly events = inject(EventService);
private readonly destroyRef = inject(DestroyRef);
protected readonly event = signal<StoreEvent | null>(null);
protected readonly loading = signal(true);
constructor() {
this.route.paramMap.pipe(
map((params) => Number(params.get('id'))),
switchMap((id) => Number.isInteger(id) && id > 0
? this.events.get(id).pipe(map((response) => response.data), catchError(() => of(null)))
: of(null)),
takeUntilDestroyed(this.destroyRef),
).subscribe((event) => {
this.event.set(event);
this.loading.set(false);
});
}
}

View File

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

View File

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

View File

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