feat(tickets): implement tickets page with ticket component and service
This commit is contained in:
@@ -574,24 +574,6 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="component-demo card shadow-sm mt-4">
|
||||
<div class="card-body">
|
||||
<span class="eyebrow">Reusable Component</span>
|
||||
<h2 class="mb-4">Ticket</h2>
|
||||
|
||||
<p class="text-muted mb-4">Ticket responsive que ocupa el 100% del ancho disponible.</p>
|
||||
|
||||
<app-ticket
|
||||
title="ENTRADA GENERAL"
|
||||
ticketId="0000000000"
|
||||
date="09 de Octubre"
|
||||
(viewQr)="triggerToast('info')"
|
||||
(download)="triggerToast('success')"
|
||||
(share)="triggerToast('info')"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="component-demo card shadow-sm mt-4">
|
||||
<div class="card-body">
|
||||
<span class="eyebrow">Reusable Component</span>
|
||||
|
||||
@@ -14,7 +14,6 @@ import { TenantService } from '../../../../core/services/tenant.service';
|
||||
import { ToastService } from '../../../../core/services/toast.service';
|
||||
import { StepperComponent } from '../../../../shared/components/stepper/stepper.component';
|
||||
import { StepComponent } from '../../../../shared/components/stepper/step.component';
|
||||
import { TicketComponent } from '../../../../shared/components/ticket/ticket.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-reutilizables-test-page',
|
||||
@@ -31,7 +30,6 @@ import { TicketComponent } from '../../../../shared/components/ticket/ticket.com
|
||||
CartIconComponent,
|
||||
StepperComponent,
|
||||
StepComponent,
|
||||
TicketComponent,
|
||||
],
|
||||
templateUrl: './reutilizables-test-page.component.html',
|
||||
styleUrl: './reutilizables-test-page.component.scss',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
</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>
|
||||
@@ -5,13 +5,12 @@
|
||||
|
||||
.ticket {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto auto;
|
||||
grid-template-columns: auto minmax(0, 1fr) 78px auto;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 56px;
|
||||
min-height: 76px;
|
||||
gap: 16px;
|
||||
padding: 16px 0;
|
||||
background: transparent;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
@@ -22,8 +21,8 @@
|
||||
}
|
||||
|
||||
.ticket__selection input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
margin: 0;
|
||||
accent-color: var(--tenant-primary, #009933);
|
||||
cursor: pointer;
|
||||
@@ -45,34 +44,28 @@
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.ticket__id {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 1.4;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.ticket__date {
|
||||
font-size: 10px;
|
||||
font-weight: 400;
|
||||
line-height: 1.4;
|
||||
color: #666666;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ticket__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
:host ::ng-deep .ticket__qr-button {
|
||||
min-width: auto;
|
||||
min-height: 36px;
|
||||
min-width: 115px;
|
||||
min-height: 38px;
|
||||
padding-inline: 14px;
|
||||
}
|
||||
|
||||
@@ -81,20 +74,22 @@
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 575.98px) {
|
||||
@media (max-width: 767.98px) {
|
||||
.ticket {
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.ticket__date {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.ticket__actions {
|
||||
grid-column: 3;
|
||||
grid-row: 1 / span 2;
|
||||
}
|
||||
|
||||
:host ::ng-deep .ticket__qr-button {
|
||||
min-width: auto;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,16 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { TicketComponent } from './ticket.component';
|
||||
|
||||
describe('TicketComponent', () => {
|
||||
it('renders the ticket data and emits every action', async () => {
|
||||
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');
|
||||
fixture.componentRef.setInput('date', '09 de octubre');
|
||||
|
||||
const viewQr = vi.fn();
|
||||
const download = vi.fn();
|
||||
@@ -22,7 +23,6 @@ describe('TicketComponent', () => {
|
||||
const element = fixture.nativeElement as HTMLElement;
|
||||
expect(element.textContent).toContain('ENTRADA GENERAL');
|
||||
expect(element.textContent).toContain('ID: 0000000000');
|
||||
expect(element.textContent).toContain('09 de Octubre');
|
||||
|
||||
element.querySelector<HTMLButtonElement>('app-button button')?.click();
|
||||
element.querySelector<HTMLButtonElement>('[aria-label="Descargar ticket"]')?.click();
|
||||
@@ -32,19 +32,4 @@ describe('TicketComponent', () => {
|
||||
expect(download).toHaveBeenCalledOnce();
|
||||
expect(share).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('supports two-way selection state', async () => {
|
||||
await TestBed.configureTestingModule({ imports: [TicketComponent] }).compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(TicketComponent);
|
||||
fixture.componentRef.setInput('ticketId', '0000000000');
|
||||
fixture.componentRef.setInput('date', '09 de Octubre');
|
||||
fixture.detectChanges();
|
||||
|
||||
const checkbox = fixture.nativeElement.querySelector('input') as HTMLInputElement;
|
||||
checkbox.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.selected()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ChangeDetectionStrategy, Component, input, model, output } from '@angular/core';
|
||||
import { ButtonComponent } from '../button/button.component';
|
||||
import { IconButtonComponent } from '../icon-button/icon-button.component';
|
||||
|
||||
import { ButtonComponent } from '../../../../../../../../shared/components/button/button.component';
|
||||
import { IconButtonComponent } from '../../../../../../../../shared/components/icon-button/icon-button.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-ticket',
|
||||
@@ -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