feat(tickets): add multi-event ticket selector
This commit is contained in:
@@ -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>
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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')],
|
||||
|
||||
Reference in New Issue
Block a user