feat(tickets): add multi-event ticket selector

This commit is contained in:
2026-09-22 12:26:33 -03:00
parent 3465352218
commit b2ffa7bb6b
8 changed files with 353 additions and 0 deletions

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

@@ -231,6 +231,24 @@ export const routes: Routes = [
(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',
canActivate: [hasMenuGuard('account.tickets')],