feat(tickets): implement tickets page with ticket component and service
This commit is contained in:
@@ -1,8 +1,11 @@
|
||||
<div class="sidebar-container">
|
||||
<h2 class="sidebar-title">MI CUENTA</h2>
|
||||
<div class="divider"></div>
|
||||
<nav class="sidebar-nav">
|
||||
<a routerLink="/mi-cuenta/datos-personales" routerLinkActive="active" class="nav-link">Datos Personales</a>
|
||||
<a routerLink="/mi-cuenta/compras" routerLinkActive="active" class="nav-link">Mis compras</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="sidebar-container">
|
||||
<h2 class="sidebar-title">MI CUENTA</h2>
|
||||
<div class="divider"></div>
|
||||
<nav class="sidebar-nav">
|
||||
<a routerLink="/mi-cuenta/datos-personales" routerLinkActive="active" class="nav-link"
|
||||
>Datos Personales</a
|
||||
>
|
||||
<a routerLink="/mi-cuenta/compras" routerLinkActive="active" class="nav-link">Mis compras</a>
|
||||
<a routerLink="/mi-cuenta/tickets" routerLinkActive="active" class="nav-link">Mis tickets</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<article class="ticket">
|
||||
<label class="ticket__selection">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="selected()"
|
||||
(change)="selected.set($any($event.target).checked)"
|
||||
/>
|
||||
<span class="visually-hidden">Seleccionar ticket {{ title() }}</span>
|
||||
</label>
|
||||
|
||||
<div class="ticket__details">
|
||||
<h3 class="ticket__title">{{ title() }}</h3>
|
||||
<p class="ticket__id">ID: {{ ticketId() }}</p>
|
||||
</div>
|
||||
|
||||
@if (date(); as ticketDate) {
|
||||
<time class="ticket__date">{{ ticketDate }}</time>
|
||||
}
|
||||
|
||||
<div class="ticket__actions">
|
||||
<app-button variant="primary" buttonClass="ticket__qr-button" (click)="viewQr.emit()">
|
||||
<i class="fa-solid fa-qrcode" aria-hidden="true"></i>
|
||||
<span>Ver QR</span>
|
||||
</app-button>
|
||||
|
||||
<app-icon-button variant="download" ariaLabel="Descargar ticket" (clicked)="download.emit()" />
|
||||
<app-icon-button variant="share" ariaLabel="Compartir ticket" (clicked)="share.emit()" />
|
||||
</div>
|
||||
</article>
|
||||
@@ -0,0 +1,95 @@
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ticket {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) 78px auto;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 76px;
|
||||
gap: 16px;
|
||||
padding: 16px 0;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.ticket__selection {
|
||||
display: flex;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ticket__selection input {
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
margin: 0;
|
||||
accent-color: var(--tenant-primary, #009933);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ticket__details {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ticket__title,
|
||||
.ticket__id {
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ticket__title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.ticket__id {
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.ticket__date {
|
||||
font-size: 10px;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ticket__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
:host ::ng-deep .ticket__qr-button {
|
||||
min-width: 115px;
|
||||
min-height: 38px;
|
||||
padding-inline: 14px;
|
||||
}
|
||||
|
||||
.ticket__selection input:focus-visible {
|
||||
outline: 2px solid var(--tenant-primary, #009933);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.ticket {
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.ticket__date {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.ticket__actions {
|
||||
grid-column: 3;
|
||||
grid-row: 1 / span 2;
|
||||
}
|
||||
|
||||
:host ::ng-deep .ticket__qr-button {
|
||||
min-width: auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { TicketComponent } from './ticket.component';
|
||||
|
||||
describe('TicketComponent', () => {
|
||||
it('renders its data and emits its actions', async () => {
|
||||
await TestBed.configureTestingModule({ imports: [TicketComponent] }).compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(TicketComponent);
|
||||
fixture.componentRef.setInput('title', 'ENTRADA GENERAL');
|
||||
fixture.componentRef.setInput('ticketId', '0000000000');
|
||||
fixture.componentRef.setInput('date', '09 de octubre');
|
||||
|
||||
const viewQr = vi.fn();
|
||||
const download = vi.fn();
|
||||
const share = vi.fn();
|
||||
fixture.componentInstance.viewQr.subscribe(viewQr);
|
||||
fixture.componentInstance.download.subscribe(download);
|
||||
fixture.componentInstance.share.subscribe(share);
|
||||
fixture.detectChanges();
|
||||
|
||||
const element = fixture.nativeElement as HTMLElement;
|
||||
expect(element.textContent).toContain('ENTRADA GENERAL');
|
||||
expect(element.textContent).toContain('ID: 0000000000');
|
||||
|
||||
element.querySelector<HTMLButtonElement>('app-button button')?.click();
|
||||
element.querySelector<HTMLButtonElement>('[aria-label="Descargar ticket"]')?.click();
|
||||
element.querySelector<HTMLButtonElement>('[aria-label="Compartir ticket"]')?.click();
|
||||
|
||||
expect(viewQr).toHaveBeenCalledOnce();
|
||||
expect(download).toHaveBeenCalledOnce();
|
||||
expect(share).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ChangeDetectionStrategy, Component, input, model, output } from '@angular/core';
|
||||
|
||||
import { ButtonComponent } from '../../../../../../../../shared/components/button/button.component';
|
||||
import { IconButtonComponent } from '../../../../../../../../shared/components/icon-button/icon-button.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-ticket',
|
||||
imports: [ButtonComponent, IconButtonComponent],
|
||||
templateUrl: './ticket.component.html',
|
||||
styleUrl: './ticket.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class TicketComponent {
|
||||
readonly title = input('ENTRADA GENERAL');
|
||||
readonly ticketId = input.required<string>();
|
||||
readonly date = input<string | null>(null);
|
||||
readonly selected = model(false);
|
||||
|
||||
readonly viewQr = output<void>();
|
||||
readonly download = output<void>();
|
||||
readonly share = output<void>();
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import { TenantService } from '../../../../../../core/services/tenant.service';
|
||||
|
||||
export interface TicketResponse {
|
||||
id: number;
|
||||
tenant_code: string;
|
||||
ticket: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
source_catalog_item_id: number | null;
|
||||
source_variant_id: number | null;
|
||||
starts_at: string | null;
|
||||
expires_at: string | null;
|
||||
used_at: string | null;
|
||||
is_valid: boolean;
|
||||
is_expired: boolean;
|
||||
is_used: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TicketService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly tenantService = inject(TenantService);
|
||||
|
||||
getTickets(): Promise<TicketResponse[]> {
|
||||
return firstValueFrom(
|
||||
this.http.get<{ data: TicketResponse[] }>(`${this.tenantService.getTenantApiUrl()}/tickets`),
|
||||
).then((response) => response.data ?? []);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<section class="tickets-page">
|
||||
<h1 class="tickets-page__title">MIS TICKETS</h1>
|
||||
|
||||
<div class="tickets-list" aria-busy="{{ isLoading() }}">
|
||||
<header class="tickets-list__header">
|
||||
<label class="tickets-list__select-all">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="allSelected()"
|
||||
[disabled]="tickets().length === 0"
|
||||
(change)="toggleAll($any($event.target).checked)"
|
||||
/>
|
||||
<span>TICKETS ACTIVOS</span>
|
||||
</label>
|
||||
|
||||
<div class="tickets-list__bulk-actions">
|
||||
<span class="tickets-list__bulk-action">
|
||||
<app-icon-button variant="download" ariaLabel="Descargar tickets seleccionados" />
|
||||
<small>Descargar</small>
|
||||
</span>
|
||||
<span class="tickets-list__bulk-action">
|
||||
<app-icon-button variant="share" ariaLabel="Compartir tickets seleccionados" />
|
||||
<small>Compartir</small>
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (isLoading()) {
|
||||
@for (item of [1, 2, 3, 4]; track item) {
|
||||
<div class="tickets-list__skeleton" aria-hidden="true"></div>
|
||||
}
|
||||
} @else if (tickets().length) {
|
||||
@for (ticket of tickets(); track ticket.id) {
|
||||
<app-ticket
|
||||
[title]="ticket.name"
|
||||
[ticketId]="ticket.ticket"
|
||||
[date]="formatDate(ticket)"
|
||||
[selected]="isSelected(ticket.id)"
|
||||
(selectedChange)="toggleTicket(ticket.id, $event)"
|
||||
/>
|
||||
}
|
||||
} @else {
|
||||
<p class="tickets-list__empty">Aún no tenés tickets disponibles.</p>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,81 @@
|
||||
.tickets-page {
|
||||
width: 100%;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.tickets-page__title {
|
||||
margin: 0 0 20px;
|
||||
color: #a0a0a0;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tickets-list__header {
|
||||
display: flex;
|
||||
min-height: 58px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #dddddd;
|
||||
}
|
||||
|
||||
.tickets-list__select-all {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin: 0;
|
||||
color: #a0a0a0;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tickets-list__select-all input {
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
margin: 0;
|
||||
accent-color: var(--tenant-primary, #009933);
|
||||
}
|
||||
|
||||
.tickets-list__bulk-actions {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.tickets-list__bulk-action {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
color: #a0a0a0;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.tickets-list app-ticket {
|
||||
border-bottom: 1px solid #dddddd;
|
||||
}
|
||||
|
||||
.tickets-list__empty {
|
||||
padding: 32px 0;
|
||||
color: #666666;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tickets-list__skeleton {
|
||||
height: 77px;
|
||||
border-bottom: 1px solid #dddddd;
|
||||
background: linear-gradient(90deg, transparent, #f3f3f3, transparent);
|
||||
background-size: 200% 100%;
|
||||
animation: loading 1.4s infinite;
|
||||
}
|
||||
|
||||
@keyframes loading {
|
||||
to {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.tickets-page__title {
|
||||
margin-top: 24px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Component, computed, inject, OnInit, signal } from '@angular/core';
|
||||
|
||||
import { ToastService } from '../../../../../../core/services/toast.service';
|
||||
import { IconButtonComponent } from '../../../../../../shared/components/icon-button/icon-button.component';
|
||||
import { TicketComponent } from './components/ticket/ticket.component';
|
||||
import { TicketResponse, TicketService } from './ticket.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-tickets-page',
|
||||
imports: [IconButtonComponent, TicketComponent],
|
||||
providers: [TicketService],
|
||||
templateUrl: './tickets-page.html',
|
||||
styleUrl: './tickets-page.scss',
|
||||
})
|
||||
export class TicketsPage implements OnInit {
|
||||
private readonly ticketService = inject(TicketService);
|
||||
private readonly toastService = inject(ToastService);
|
||||
|
||||
protected readonly tickets = signal<TicketResponse[]>([]);
|
||||
protected readonly selectedIds = signal<Set<number>>(new Set());
|
||||
protected readonly isLoading = signal(true);
|
||||
protected readonly allSelected = computed(
|
||||
() => this.tickets().length > 0 && this.selectedIds().size === this.tickets().length,
|
||||
);
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
try {
|
||||
this.tickets.set(await this.ticketService.getTickets());
|
||||
} catch {
|
||||
this.toastService.danger('Hubo un error al cargar los tickets');
|
||||
} finally {
|
||||
this.isLoading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
protected toggleAll(selected: boolean): void {
|
||||
this.selectedIds.set(selected ? new Set(this.tickets().map((ticket) => ticket.id)) : new Set());
|
||||
}
|
||||
|
||||
protected toggleTicket(ticketId: number, selected: boolean): void {
|
||||
this.selectedIds.update((current) => {
|
||||
const next = new Set(current);
|
||||
selected ? next.add(ticketId) : next.delete(ticketId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
protected isSelected(ticketId: number): boolean {
|
||||
return this.selectedIds().has(ticketId);
|
||||
}
|
||||
|
||||
protected formatDate(ticket: TicketResponse): string | null {
|
||||
const value = ticket.starts_at ?? ticket.expires_at;
|
||||
if (!value) return null;
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
|
||||
return new Intl.DateTimeFormat('es-AR', {
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
timeZone: 'UTC',
|
||||
}).format(date);
|
||||
}
|
||||
}
|
||||
@@ -106,6 +106,14 @@ export const routes: Routes = [
|
||||
(m) => m.PurchaseDetailPage,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'tickets',
|
||||
canActivate: [hasMenuGuard('tickets')],
|
||||
loadComponent: () =>
|
||||
import('./pages/account-page/pages/tickets-page/tickets-page').then(
|
||||
(m) => m.TicketsPage,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '',
|
||||
redirectTo: 'datos-personales',
|
||||
|
||||
Reference in New Issue
Block a user