Compare commits
6 Commits
902f65d8d0
...
0726b2219e
| Author | SHA1 | Date | |
|---|---|---|---|
| 0726b2219e | |||
| 7845c8f20e | |||
| c41c60f349 | |||
| 163444c91f | |||
| 4f1405f6c1 | |||
| f063bac527 |
@@ -36,6 +36,10 @@ export type InventoryPolicy = 'tracked' | 'unlimited';
|
|||||||
export interface CatalogItemVariant {
|
export interface CatalogItemVariant {
|
||||||
id: number;
|
id: number;
|
||||||
stock_tecnico: number | null;
|
stock_tecnico: number | null;
|
||||||
|
minimum_use_date?: string | null;
|
||||||
|
maximum_use_date?: string | null;
|
||||||
|
effective_minimum_use_date?: string | null;
|
||||||
|
effective_maximum_use_date?: string | null;
|
||||||
values: Record<string, string>;
|
values: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,9 +41,15 @@
|
|||||||
<app-icon-button
|
<app-icon-button
|
||||||
variant="download"
|
variant="download"
|
||||||
ariaLabel="Descargar ticket"
|
ariaLabel="Descargar ticket"
|
||||||
|
[disabled]="isGeneratingPdf()"
|
||||||
(clicked)="download.emit()"
|
(clicked)="download.emit()"
|
||||||
/>
|
/>
|
||||||
<app-icon-button variant="share" ariaLabel="Compartir ticket" (clicked)="share.emit()" />
|
<app-icon-button
|
||||||
|
variant="share"
|
||||||
|
ariaLabel="Compartir ticket"
|
||||||
|
[disabled]="isGeneratingPdf()"
|
||||||
|
(clicked)="share.emit()"
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export class TicketComponent {
|
|||||||
readonly date = input<string | null>(null);
|
readonly date = input<string | null>(null);
|
||||||
readonly disabled = input(false);
|
readonly disabled = input(false);
|
||||||
readonly expired = input(false);
|
readonly expired = input(false);
|
||||||
|
readonly isGeneratingPdf = input(false);
|
||||||
readonly selected = model(false);
|
readonly selected = model(false);
|
||||||
|
|
||||||
readonly viewQr = output<void>();
|
readonly viewQr = output<void>();
|
||||||
|
|||||||
@@ -30,4 +30,14 @@ export class TicketService {
|
|||||||
this.http.get<{ data: TicketResponse[] }>(`${this.tenantService.getTenantApiUrl()}/tickets`),
|
this.http.get<{ data: TicketResponse[] }>(`${this.tenantService.getTenantApiUrl()}/tickets`),
|
||||||
).then((response) => response.data ?? []);
|
).then((response) => response.data ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
downloadPdf(ticketIds: number[]): Promise<Blob> {
|
||||||
|
return firstValueFrom(
|
||||||
|
this.http.post(
|
||||||
|
`${this.tenantService.getTenantApiUrl()}/tickets/pdf`,
|
||||||
|
{ ticket_ids: ticketIds },
|
||||||
|
{ responseType: 'blob' },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
<section class="tickets-page">
|
<section class="tickets-page">
|
||||||
<h1 class="tickets-page__title">MIS TICKETS</h1>
|
<h1 class="tickets-page__title">MIS TICKETS</h1>
|
||||||
|
|
||||||
<div class="tickets-list" aria-busy="{{ isLoading() }}">
|
<div class="tickets-list" [attr.aria-busy]="isLoading() || isGeneratingPdf()">
|
||||||
|
@if (isGeneratingPdf()) {
|
||||||
|
<div class="tickets-list__generating" role="status" aria-live="polite">
|
||||||
|
<span class="tickets-list__generating-spinner" aria-hidden="true"></span>
|
||||||
|
<span>Generando PDF...</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
@if (activeTickets().length) {
|
@if (activeTickets().length) {
|
||||||
<header class="tickets-list__header">
|
<header class="tickets-list__header">
|
||||||
<label class="tickets-list__select-all">
|
<label class="tickets-list__select-all">
|
||||||
@@ -15,11 +22,21 @@
|
|||||||
|
|
||||||
<div class="tickets-list__bulk-actions">
|
<div class="tickets-list__bulk-actions">
|
||||||
<span class="tickets-list__bulk-action">
|
<span class="tickets-list__bulk-action">
|
||||||
<app-icon-button variant="download" ariaLabel="Descargar tickets seleccionados" />
|
<app-icon-button
|
||||||
|
variant="download"
|
||||||
|
ariaLabel="Descargar tickets seleccionados"
|
||||||
|
[disabled]="isGeneratingPdf()"
|
||||||
|
(clicked)="downloadSelected()"
|
||||||
|
/>
|
||||||
<small>Descargar</small>
|
<small>Descargar</small>
|
||||||
</span>
|
</span>
|
||||||
<span class="tickets-list__bulk-action">
|
<span class="tickets-list__bulk-action">
|
||||||
<app-icon-button variant="share" ariaLabel="Compartir tickets seleccionados" />
|
<app-icon-button
|
||||||
|
variant="share"
|
||||||
|
ariaLabel="Compartir tickets seleccionados"
|
||||||
|
[disabled]="isGeneratingPdf()"
|
||||||
|
(clicked)="shareSelected()"
|
||||||
|
/>
|
||||||
<small>Compartir</small>
|
<small>Compartir</small>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -27,8 +44,31 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@if (isLoading()) {
|
@if (isLoading()) {
|
||||||
|
<header class="tickets-list__skeleton-header" aria-hidden="true">
|
||||||
|
<div class="tickets-list__skeleton-selection">
|
||||||
|
<span class="tickets-list__skeleton-check"></span>
|
||||||
|
<span class="tickets-list__skeleton-line tickets-list__skeleton-line--section"></span>
|
||||||
|
</div>
|
||||||
|
<div class="tickets-list__skeleton-bulk-actions">
|
||||||
|
<span class="tickets-list__skeleton-icon"></span>
|
||||||
|
<span class="tickets-list__skeleton-icon"></span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
@for (item of [1, 2, 3, 4]; track item) {
|
@for (item of [1, 2, 3, 4]; track item) {
|
||||||
<div class="tickets-list__skeleton" aria-hidden="true"></div>
|
<div class="tickets-list__skeleton" aria-hidden="true">
|
||||||
|
<span class="tickets-list__skeleton-check"></span>
|
||||||
|
<div class="tickets-list__skeleton-details">
|
||||||
|
<span class="tickets-list__skeleton-line tickets-list__skeleton-line--title"></span>
|
||||||
|
<span class="tickets-list__skeleton-line tickets-list__skeleton-line--id"></span>
|
||||||
|
</div>
|
||||||
|
<span class="tickets-list__skeleton-line tickets-list__skeleton-line--date"></span>
|
||||||
|
<div class="tickets-list__skeleton-actions">
|
||||||
|
<span class="tickets-list__skeleton-button"></span>
|
||||||
|
<span class="tickets-list__skeleton-icon"></span>
|
||||||
|
<span class="tickets-list__skeleton-icon"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
} @else if (tickets().length) {
|
} @else if (tickets().length) {
|
||||||
@for (ticket of activeTickets(); track ticket.id) {
|
@for (ticket of activeTickets(); track ticket.id) {
|
||||||
@@ -37,8 +77,11 @@
|
|||||||
[ticketId]="ticket.id"
|
[ticketId]="ticket.id"
|
||||||
[date]="formatDate(ticket)"
|
[date]="formatDate(ticket)"
|
||||||
[selected]="isSelected(ticket.id)"
|
[selected]="isSelected(ticket.id)"
|
||||||
|
[isGeneratingPdf]="isGeneratingPdf()"
|
||||||
(selectedChange)="toggleTicket(ticket.id, $event)"
|
(selectedChange)="toggleTicket(ticket.id, $event)"
|
||||||
(viewQr)="viewQr(ticket)"
|
(viewQr)="viewQr(ticket)"
|
||||||
|
(download)="downloadTicket(ticket)"
|
||||||
|
(share)="shareTicket(ticket)"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,35 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tickets-list {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tickets-list__generating {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 10;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
min-height: 180px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #26322a;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tickets-list__generating-spinner {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: 3px solid #dce8e0;
|
||||||
|
border-top-color: var(--tenant-primary, #009933);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: tickets-spinner 0.75s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
.tickets-list__header {
|
.tickets-list__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 58px;
|
min-height: 58px;
|
||||||
@@ -75,22 +104,128 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tickets-list__skeleton {
|
.tickets-list__skeleton-header {
|
||||||
height: 77px;
|
display: flex;
|
||||||
|
min-height: 58px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-bottom: 12px;
|
||||||
border-bottom: 1px solid #dddddd;
|
border-bottom: 1px solid #dddddd;
|
||||||
background: linear-gradient(90deg, transparent, #f3f3f3, transparent);
|
|
||||||
background-size: 200% 100%;
|
|
||||||
animation: loading 1.4s infinite;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes loading {
|
.tickets-list__skeleton-selection,
|
||||||
|
.tickets-list__skeleton-bulk-actions,
|
||||||
|
.tickets-list__skeleton-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tickets-list__skeleton-selection {
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tickets-list__skeleton-bulk-actions,
|
||||||
|
.tickets-list__skeleton-actions {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tickets-list__skeleton {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) 78px auto;
|
||||||
|
min-height: 76px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px 0;
|
||||||
|
border-bottom: 1px solid #dddddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tickets-list__skeleton-check,
|
||||||
|
.tickets-list__skeleton-line,
|
||||||
|
.tickets-list__skeleton-icon,
|
||||||
|
.tickets-list__skeleton-button {
|
||||||
|
display: block;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: linear-gradient(90deg, #eeeeee 20%, #f7f7f7 50%, #eeeeee 80%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: tickets-loading 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tickets-list__skeleton-check {
|
||||||
|
width: 21px;
|
||||||
|
height: 21px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tickets-list__skeleton-details {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tickets-list__skeleton-line--section {
|
||||||
|
width: 120px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tickets-list__skeleton-line--title {
|
||||||
|
width: min(240px, 75%);
|
||||||
|
height: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tickets-list__skeleton-line--id {
|
||||||
|
width: 86px;
|
||||||
|
height: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tickets-list__skeleton-line--date {
|
||||||
|
width: 64px;
|
||||||
|
height: 11px;
|
||||||
|
justify-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tickets-list__skeleton-button {
|
||||||
|
width: 115px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tickets-list__skeleton-icon {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tickets-loading {
|
||||||
to {
|
to {
|
||||||
background-position: -200% 0;
|
background-position: -200% 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes tickets-spinner {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 767.98px) {
|
@media (max-width: 767.98px) {
|
||||||
.tickets-page__title {
|
.tickets-page__title {
|
||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tickets-list__skeleton {
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tickets-list__skeleton-line--date {
|
||||||
|
grid-column: 2;
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tickets-list__skeleton-actions {
|
||||||
|
grid-column: 3;
|
||||||
|
grid-row: 1 / span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tickets-list__skeleton-actions .tickets-list__skeleton-icon {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,122 @@ const ticket = (overrides: Partial<TicketResponse>): TicketResponse => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('TicketsPage', () => {
|
describe('TicketsPage', () => {
|
||||||
|
it('shares the generated PDF as a file without text that Windows could prioritize', async () => {
|
||||||
|
const share = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const canShare = vi.fn().mockReturnValue(true);
|
||||||
|
vi.stubGlobal('navigator', { canShare, share });
|
||||||
|
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [TicketsPage],
|
||||||
|
providers: [{ provide: ToastService, useValue: { danger: vi.fn() } }],
|
||||||
|
})
|
||||||
|
.overrideComponent(TicketsPage, {
|
||||||
|
set: {
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: TicketService,
|
||||||
|
useValue: {
|
||||||
|
getTickets: () => Promise.resolve([ticket({})]),
|
||||||
|
downloadPdf: () => Promise.resolve(new Blob(['pdf-content'])),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(TicketsPage);
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const page = fixture.nativeElement as HTMLElement;
|
||||||
|
page.querySelector<HTMLButtonElement>('[aria-label="Compartir ticket"]')?.click();
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(share).toHaveBeenCalledOnce());
|
||||||
|
|
||||||
|
const shareData = share.mock.calls[0][0] as ShareData;
|
||||||
|
const [sharedFile] = shareData.files ?? [];
|
||||||
|
|
||||||
|
expect(Object.keys(shareData)).toEqual(['files']);
|
||||||
|
expect(canShare).toHaveBeenCalledWith({ files: [sharedFile] });
|
||||||
|
expect(sharedFile).toBeInstanceOf(File);
|
||||||
|
expect(sharedFile.name).toBe('tickets_1.pdf');
|
||||||
|
expect(sharedFile.type).toBe('application/pdf');
|
||||||
|
expect(sharedFile.size).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a loading overlay and disables PDF actions while the file is generated', async () => {
|
||||||
|
let resolvePdf!: (pdf: Blob) => void;
|
||||||
|
const downloadPdf = vi.fn(
|
||||||
|
() =>
|
||||||
|
new Promise<Blob>((resolve) => {
|
||||||
|
resolvePdf = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const createObjectUrl = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:ticket');
|
||||||
|
const revokeObjectUrl = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined);
|
||||||
|
let downloadedFilename = '';
|
||||||
|
const linkClick = vi
|
||||||
|
.spyOn(HTMLAnchorElement.prototype, 'click')
|
||||||
|
.mockImplementation(function (this: HTMLAnchorElement) {
|
||||||
|
downloadedFilename = this.download;
|
||||||
|
});
|
||||||
|
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [TicketsPage],
|
||||||
|
providers: [{ provide: ToastService, useValue: { danger: vi.fn() } }],
|
||||||
|
})
|
||||||
|
.overrideComponent(TicketsPage, {
|
||||||
|
set: {
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: TicketService,
|
||||||
|
useValue: {
|
||||||
|
getTickets: () => Promise.resolve([ticket({})]),
|
||||||
|
downloadPdf,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(TicketsPage);
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const page = fixture.nativeElement as HTMLElement;
|
||||||
|
page.querySelector<HTMLButtonElement>('[aria-label="Descargar ticket"]')?.click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(page.querySelector('.tickets-list__generating')?.textContent).toContain(
|
||||||
|
'Generando PDF...',
|
||||||
|
);
|
||||||
|
expect(page.querySelector('.tickets-list')?.getAttribute('aria-busy')).toBe('true');
|
||||||
|
expect(page.querySelector<HTMLButtonElement>('[aria-label="Descargar ticket"]')?.disabled).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(page.querySelector<HTMLButtonElement>('[aria-label="Compartir ticket"]')?.disabled).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
resolvePdf(new Blob(['pdf'], { type: 'application/pdf' }));
|
||||||
|
await fixture.whenStable();
|
||||||
|
await vi.waitFor(() => expect(downloadedFilename).toBe('tickets_1.pdf'));
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(page.querySelector('.tickets-list__generating')).toBeNull();
|
||||||
|
expect(page.querySelector('.tickets-list')?.getAttribute('aria-busy')).toBe('false');
|
||||||
|
|
||||||
|
createObjectUrl.mockRestore();
|
||||||
|
revokeObjectUrl.mockRestore();
|
||||||
|
linkClick.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
it('hides the active tickets header when there are no available tickets', async () => {
|
it('hides the active tickets header when there are no available tickets', async () => {
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [TicketsPage],
|
imports: [TicketsPage],
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export class TicketsPage implements OnInit {
|
|||||||
);
|
);
|
||||||
protected readonly selectedIds = signal<Set<number>>(new Set());
|
protected readonly selectedIds = signal<Set<number>>(new Set());
|
||||||
protected readonly isLoading = signal(true);
|
protected readonly isLoading = signal(true);
|
||||||
|
protected readonly isGeneratingPdf = signal(false);
|
||||||
protected readonly allSelected = computed(
|
protected readonly allSelected = computed(
|
||||||
() =>
|
() =>
|
||||||
this.activeTickets().length > 0 && this.selectedIds().size === this.activeTickets().length,
|
this.activeTickets().length > 0 && this.selectedIds().size === this.activeTickets().length,
|
||||||
@@ -86,4 +87,91 @@ export class TicketsPage implements OnInit {
|
|||||||
ticket: ticket.ticket,
|
ticket: ticket.ticket,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected async downloadTicket(ticket: TicketResponse): Promise<void> {
|
||||||
|
await this.downloadTickets([ticket]);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async downloadSelected(): Promise<void> {
|
||||||
|
const tickets = this.activeTickets().filter((ticket) => this.selectedIds().has(ticket.id));
|
||||||
|
|
||||||
|
if (!tickets.length) {
|
||||||
|
this.toastService.danger('Seleccioná al menos un ticket para descargar.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.downloadTickets(tickets);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async shareTicket(ticket: TicketResponse): Promise<void> {
|
||||||
|
await this.shareTickets([ticket]);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async shareSelected(): Promise<void> {
|
||||||
|
const tickets = this.activeTickets().filter((ticket) => this.selectedIds().has(ticket.id));
|
||||||
|
|
||||||
|
if (!tickets.length) {
|
||||||
|
this.toastService.danger('Seleccioná al menos un ticket para compartir.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.shareTickets(tickets);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async downloadTickets(tickets: TicketResponse[]): Promise<void> {
|
||||||
|
const pdf = await this.generateTicketsPdf(tickets);
|
||||||
|
if (pdf) this.downloadPdf(pdf, this.pdfFilename(tickets));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async shareTickets(tickets: TicketResponse[]): Promise<void> {
|
||||||
|
const pdf = await this.generateTicketsPdf(tickets);
|
||||||
|
if (!pdf) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const filename = this.pdfFilename(tickets);
|
||||||
|
const file = new File([pdf], filename, { type: 'application/pdf' });
|
||||||
|
|
||||||
|
if (navigator.canShare?.({ files: [file] })) {
|
||||||
|
await navigator.share({ files: [file] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.downloadPdf(pdf, filename);
|
||||||
|
this.toastService.success('Tu navegador no permite compartir archivos; descargamos el PDF.');
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DOMException && error.name === 'AbortError') return;
|
||||||
|
|
||||||
|
this.toastService.danger('No se pudo compartir el PDF de los tickets.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async generateTicketsPdf(tickets: TicketResponse[]): Promise<Blob | null> {
|
||||||
|
if (this.isGeneratingPdf()) return null;
|
||||||
|
|
||||||
|
this.isGeneratingPdf.set(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this.ticketService.downloadPdf(tickets.map((ticket) => ticket.id));
|
||||||
|
} catch {
|
||||||
|
this.toastService.danger('No se pudo generar el PDF de los tickets.');
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
this.isGeneratingPdf.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private pdfFilename(tickets: TicketResponse[]): string {
|
||||||
|
return `tickets_${tickets.map((ticket) => ticket.id).join('_')}.pdf`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private downloadPdf(pdf: Blob, filename: string): void {
|
||||||
|
const url = URL.createObjectURL(pdf);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
<div class="checkout-page">
|
@if (isLoadingPurchase()) {
|
||||||
|
<div class="checkout-page__loading" role="status" aria-live="polite">
|
||||||
|
<span class="checkout-page__spinner" aria-hidden="true"></span>
|
||||||
|
<span>Cargando compra...</span>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="checkout-page">
|
||||||
<div
|
<div
|
||||||
class="checkout-page__stepper-col"
|
class="checkout-page__stepper-col"
|
||||||
[class.checkout-page__stepper-col--editing]="isEditingItems()"
|
[class.checkout-page__stepper-col--editing]="isEditingItems()"
|
||||||
[attr.aria-hidden]="isEditingItems()"
|
[attr.aria-hidden]="isEditingItems()"
|
||||||
[attr.inert]="isEditingItems() ? '' : null"
|
[attr.inert]="isEditingItems() ? '' : null"
|
||||||
>
|
>
|
||||||
<app-stepper #stepper>
|
<app-stepper #stepper [initialStepIndex]="checkoutStepIndex()">
|
||||||
<app-step label="Datos" [isValid]="isStep1Valid()">
|
<app-step label="Datos" [isValid]="isStep1Valid()">
|
||||||
<app-checkout-data-step
|
<app-checkout-data-step
|
||||||
[form]="form"
|
[form]="form"
|
||||||
@@ -62,4 +68,5 @@
|
|||||||
(itemQuantityChange)="onPurchaseItemQuantityChange($event)"
|
(itemQuantityChange)="onPurchaseItemQuantityChange($event)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,10 +36,33 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&__cart-col {
|
&__cart-col {
|
||||||
|
|
||||||
max-height: 80vh;
|
max-height: 80vh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.checkout-page__loading {
|
||||||
|
display: grid;
|
||||||
|
min-height: 420px;
|
||||||
|
place-items: center;
|
||||||
|
align-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
color: #666666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkout-page__spinner {
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
border: 0.25rem solid rgba(32, 32, 32, 0.08);
|
||||||
|
border-top-color: var(--tenant-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: checkout-page-spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes checkout-page-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -234,6 +234,9 @@ describe('CheckoutPageComponent payment validation', () => {
|
|||||||
const purchase = {
|
const purchase = {
|
||||||
id: 25,
|
id: 25,
|
||||||
cart_id: null,
|
cart_id: null,
|
||||||
|
status: 'created',
|
||||||
|
payment_method: null,
|
||||||
|
transfer_payer_dni: null,
|
||||||
nombre_apellido: 'Datos de la compra',
|
nombre_apellido: 'Datos de la compra',
|
||||||
email: 'compra@example.com',
|
email: 'compra@example.com',
|
||||||
dni: '11111111',
|
dni: '11111111',
|
||||||
@@ -293,6 +296,99 @@ describe('CheckoutPageComponent payment validation', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps the checkout hidden while the purchase is loading', async () => {
|
||||||
|
let resolvePurchase!: (purchase: any) => void;
|
||||||
|
routeQueryParamMap = convertToParamMap({ purchase: 25 });
|
||||||
|
checkoutServiceStub.getPurchase.mockReturnValue(
|
||||||
|
new Promise((resolve) => {
|
||||||
|
resolvePurchase = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const { component } = createComponent();
|
||||||
|
|
||||||
|
expect(component.isLoadingPurchase()).toBe(true);
|
||||||
|
|
||||||
|
resolvePurchase({
|
||||||
|
id: 25,
|
||||||
|
status: 'created',
|
||||||
|
payment_method: null,
|
||||||
|
transfer_payer_dni: null,
|
||||||
|
items: [],
|
||||||
|
subtotal: '0.00',
|
||||||
|
total: '0.00',
|
||||||
|
});
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(component.isLoadingPurchase()).toBe(false);
|
||||||
|
expect(component.checkoutStepIndex()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens a pending purchase on the payment step and restores its payment method', async () => {
|
||||||
|
routeQueryParamMap = convertToParamMap({ purchase: 25 });
|
||||||
|
checkoutServiceStub.getPurchase.mockResolvedValue({
|
||||||
|
id: 25,
|
||||||
|
status: 'pending_payment',
|
||||||
|
payment_method: 'transfer',
|
||||||
|
transfer_payer_dni: '12345678',
|
||||||
|
items: [],
|
||||||
|
subtotal: '100.00',
|
||||||
|
total: '100.00',
|
||||||
|
});
|
||||||
|
const { component } = createComponent();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(component.checkoutStepIndex()).toBe(1);
|
||||||
|
expect(component.selectedPaymentMethod()).toBe('transfer');
|
||||||
|
expect(component.transferDni()).toBe('12345678');
|
||||||
|
expect(component.isLoadingPurchase()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates a new QR when reopening a pending QR purchase', async () => {
|
||||||
|
routeQueryParamMap = convertToParamMap({ purchase: 25 });
|
||||||
|
checkoutServiceStub.getPurchase.mockResolvedValue({
|
||||||
|
id: 25,
|
||||||
|
status: 'pending_payment',
|
||||||
|
payment_method: 'qr',
|
||||||
|
transfer_payer_dni: null,
|
||||||
|
items: [],
|
||||||
|
subtotal: '100.00',
|
||||||
|
total: '100.00',
|
||||||
|
});
|
||||||
|
const { component } = createComponent();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(component.checkoutStepIndex()).toBe(1);
|
||||||
|
expect(component.selectedPaymentMethod()).toBe('qr');
|
||||||
|
expect(checkoutServiceStub.generatePaymentIntent).toHaveBeenCalledWith(
|
||||||
|
'tenant-test',
|
||||||
|
25,
|
||||||
|
'qr',
|
||||||
|
);
|
||||||
|
expect(component.qrData()).toBe('qr-value');
|
||||||
|
expect(component.qrPaymentStatus()).toBe('waiting');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['paid', 'cancelled', 'rejected', 'expired'])(
|
||||||
|
'redirects a %s purchase to its status page',
|
||||||
|
async (status) => {
|
||||||
|
routeQueryParamMap = convertToParamMap({ purchase: 25 });
|
||||||
|
checkoutServiceStub.getPurchase.mockResolvedValue({
|
||||||
|
id: 25,
|
||||||
|
status,
|
||||||
|
payment_method: null,
|
||||||
|
transfer_payer_dni: null,
|
||||||
|
items: [],
|
||||||
|
subtotal: '100.00',
|
||||||
|
total: '100.00',
|
||||||
|
});
|
||||||
|
createComponent();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it('updates a purchase item while editing and refreshes checkout totals', async () => {
|
it('updates a purchase item while editing and refreshes checkout totals', async () => {
|
||||||
const updatedPurchase = {
|
const updatedPurchase = {
|
||||||
id: 25,
|
id: 25,
|
||||||
@@ -318,12 +414,7 @@ describe('CheckoutPageComponent payment validation', () => {
|
|||||||
quantity: 3,
|
quantity: 3,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(checkoutServiceStub.updateItemQuantity).toHaveBeenCalledWith(
|
expect(checkoutServiceStub.updateItemQuantity).toHaveBeenCalledWith('tenant-test', 25, 91, 3);
|
||||||
'tenant-test',
|
|
||||||
25,
|
|
||||||
91,
|
|
||||||
3,
|
|
||||||
);
|
|
||||||
expect(component.createdPurchase()).toBe(updatedPurchase);
|
expect(component.createdPurchase()).toBe(updatedPurchase);
|
||||||
expect(component.isUpdatingItem()).toBe(false);
|
expect(component.isUpdatingItem()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
|
|
||||||
protected readonly createdPurchase = signal<PurchaseDetailResponse | null>(null);
|
protected readonly createdPurchase = signal<PurchaseDetailResponse | null>(null);
|
||||||
|
protected readonly isLoadingPurchase = signal(true);
|
||||||
|
protected readonly checkoutStepIndex = signal(0);
|
||||||
|
|
||||||
protected readonly cartSubtotal = computed(() => {
|
protected readonly cartSubtotal = computed(() => {
|
||||||
const purchase = this.createdPurchase();
|
const purchase = this.createdPurchase();
|
||||||
@@ -198,10 +200,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
this.isPreparingItemEdit.set(true);
|
this.isPreparingItemEdit.set(true);
|
||||||
try {
|
try {
|
||||||
const purchase = await this.checkoutService.prepareItemEditing(
|
const purchase = await this.checkoutService.prepareItemEditing(tenant.codigo, purchaseId);
|
||||||
tenant.codigo,
|
|
||||||
purchaseId,
|
|
||||||
);
|
|
||||||
this.createdPurchase.set(purchase);
|
this.createdPurchase.set(purchase);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to prepare purchase item editing:', error);
|
console.error('Failed to prepare purchase item editing:', error);
|
||||||
@@ -574,8 +573,39 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const purchase = await this.checkoutService.getPurchase(tenant.codigo, purchaseId);
|
const purchase = await this.checkoutService.getPurchase(tenant.codigo, purchaseId);
|
||||||
|
|
||||||
|
if (
|
||||||
|
purchase.status === 'paid' ||
|
||||||
|
purchase.status === 'cancelled' ||
|
||||||
|
purchase.status === 'rejected' ||
|
||||||
|
purchase.status === 'expired'
|
||||||
|
) {
|
||||||
|
this.navigateToPurchaseStatus(purchaseId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (purchase.status !== 'created' && purchase.status !== 'pending_payment') {
|
||||||
|
void this.router.navigate(['/']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.createdPurchaseId.set(purchase.id);
|
this.createdPurchaseId.set(purchase.id);
|
||||||
this.createdPurchase.set(purchase);
|
this.createdPurchase.set(purchase);
|
||||||
|
this.checkoutStepIndex.set(purchase.status === 'pending_payment' ? 1 : 0);
|
||||||
|
|
||||||
|
if (
|
||||||
|
purchase.status === 'pending_payment' &&
|
||||||
|
this.paymentMethods.some((method) => method.id === purchase.payment_method)
|
||||||
|
) {
|
||||||
|
this.selectedPaymentMethod.set(purchase.payment_method as PaymentMethod);
|
||||||
|
this.transferDni.set(purchase.transfer_payer_dni ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isLoadingPurchase.set(false);
|
||||||
|
|
||||||
|
if (purchase.status === 'pending_payment' && purchase.payment_method === 'qr') {
|
||||||
|
await this.selectPaymentMethod('qr');
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load purchase:', error);
|
console.error('Failed to load purchase:', error);
|
||||||
void this.router.navigate(['/']);
|
void this.router.navigate(['/']);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
DestroyRef,
|
DestroyRef,
|
||||||
|
Injector,
|
||||||
computed,
|
computed,
|
||||||
inject,
|
inject,
|
||||||
signal,
|
signal,
|
||||||
@@ -12,6 +13,8 @@ import { catchError, distinctUntilChanged, map, of, switchMap, tap } from 'rxjs'
|
|||||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
|
|
||||||
import { CartService } from '../../../../core/services/cart/cart.service';
|
import { CartService } from '../../../../core/services/cart/cart.service';
|
||||||
|
import { AuthService } from '../../../../core/services/auth/auth.service';
|
||||||
|
import { CheckoutService } from '../../../../core/services/checkout.service';
|
||||||
import {
|
import {
|
||||||
CatalogFeaturedItem,
|
CatalogFeaturedItem,
|
||||||
CatalogFeaturedItems,
|
CatalogFeaturedItems,
|
||||||
@@ -24,8 +27,8 @@ import { TenantService } from '../../../../core/services/tenant.service';
|
|||||||
import { ToastService } from '../../../../core/services/toast.service';
|
import { ToastService } from '../../../../core/services/toast.service';
|
||||||
import {
|
import {
|
||||||
ProductListCartEvent,
|
ProductListCartEvent,
|
||||||
|
ProductListBuyEvent,
|
||||||
ProductListComponent,
|
ProductListComponent,
|
||||||
ProductListItem,
|
|
||||||
} from '../../../../shared/components/product-list/product-list.component';
|
} from '../../../../shared/components/product-list/product-list.component';
|
||||||
|
|
||||||
interface SearchRouteState {
|
interface SearchRouteState {
|
||||||
@@ -44,6 +47,7 @@ export class SearchPageComponent {
|
|||||||
private readonly minSearchLength = 3;
|
private readonly minSearchLength = 3;
|
||||||
private readonly cartService = inject(CartService);
|
private readonly cartService = inject(CartService);
|
||||||
private readonly catalogService = inject(CatalogService);
|
private readonly catalogService = inject(CatalogService);
|
||||||
|
private readonly injector = inject(Injector);
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
private readonly route = inject(ActivatedRoute);
|
private readonly route = inject(ActivatedRoute);
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
@@ -54,6 +58,7 @@ export class SearchPageComponent {
|
|||||||
protected readonly results = signal<ApiPaginatedResponse<CatalogFeaturedItem[]> | null>(null);
|
protected readonly results = signal<ApiPaginatedResponse<CatalogFeaturedItem[]> | null>(null);
|
||||||
protected readonly loading = signal(false);
|
protected readonly loading = signal(false);
|
||||||
protected readonly error = signal<string | null>(null);
|
protected readonly error = signal<string | null>(null);
|
||||||
|
protected readonly creatingDirectPurchase = signal(false);
|
||||||
|
|
||||||
protected readonly productLayout = computed<CatalogProductLayout>(
|
protected readonly productLayout = computed<CatalogProductLayout>(
|
||||||
() => this.tenantService.tenant()?.search_product_layout ?? 'column_with_image',
|
() => this.tenantService.tenant()?.search_product_layout ?? 'column_with_image',
|
||||||
@@ -131,8 +136,45 @@ export class SearchPageComponent {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
protected onBuyProduct(product: ProductListItem): void {
|
protected async onBuyProduct(event: ProductListBuyEvent): Promise<void> {
|
||||||
void this.router.navigate(['/producto', product.id]);
|
if (!event.directPurchase) {
|
||||||
|
await this.router.navigate(['/producto', event.product.id]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.creatingDirectPurchase()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.injector.get(AuthService).user()) {
|
||||||
|
await this.router.navigate(['/login'], {
|
||||||
|
queryParams: { returnUrl: `/producto/${event.product.id}` },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenant = this.tenantService.tenant();
|
||||||
|
if (!tenant) {
|
||||||
|
this.toastService.danger('No se pudo identificar la tienda.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.creatingDirectPurchase.set(true);
|
||||||
|
try {
|
||||||
|
const purchase = await this.injector.get(CheckoutService).startCheckout(tenant.codigo, {
|
||||||
|
direct_item: {
|
||||||
|
catalog_item_id: event.product.id,
|
||||||
|
variant_id: event.variant ?? null,
|
||||||
|
cantidad: event.quantity,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await this.router.navigate(['/checkout'], { queryParams: { purchase: purchase.id } });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create direct purchase:', error);
|
||||||
|
this.toastService.danger('No se pudo iniciar la compra directa.');
|
||||||
|
} finally {
|
||||||
|
this.creatingDirectPurchase.set(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected onAddToCart(event: ProductListCartEvent): void {
|
protected onAddToCart(event: ProductListCartEvent): void {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
|
Injector,
|
||||||
OnDestroy,
|
OnDestroy,
|
||||||
OnInit,
|
OnInit,
|
||||||
computed,
|
computed,
|
||||||
@@ -12,14 +13,16 @@ import { ActivatedRoute, Router } from '@angular/router';
|
|||||||
import { Subscription } from 'rxjs';
|
import { Subscription } from 'rxjs';
|
||||||
|
|
||||||
import { CartService } from '../../../../core/services/cart/cart.service';
|
import { CartService } from '../../../../core/services/cart/cart.service';
|
||||||
|
import { AuthService } from '../../../../core/services/auth/auth.service';
|
||||||
import { CatalogFeaturedGroup } from '../../../../core/services/catalog/catalog.interface';
|
import { CatalogFeaturedGroup } from '../../../../core/services/catalog/catalog.interface';
|
||||||
import { CatalogService } from '../../../../core/services/catalog/catalog.service';
|
import { CatalogService } from '../../../../core/services/catalog/catalog.service';
|
||||||
import { TenantService } from '../../../../core/services/tenant.service';
|
import { TenantService } from '../../../../core/services/tenant.service';
|
||||||
import { ToastService } from '../../../../core/services/toast.service';
|
import { ToastService } from '../../../../core/services/toast.service';
|
||||||
|
import { CheckoutService } from '../../../../core/services/checkout.service';
|
||||||
import {
|
import {
|
||||||
ProductListComponent,
|
ProductListComponent,
|
||||||
ProductListCartEvent,
|
ProductListCartEvent,
|
||||||
ProductListItem,
|
ProductListBuyEvent,
|
||||||
} from '../../../../shared/components/product-list/product-list.component';
|
} from '../../../../shared/components/product-list/product-list.component';
|
||||||
import { HeroBannerComponent } from '../../../../shared/components/hero-banner/hero-banner.component';
|
import { HeroBannerComponent } from '../../../../shared/components/hero-banner/hero-banner.component';
|
||||||
import { MainCarouselComponent } from '../../../../shared/components/main-carousel/main-carousel.component';
|
import { MainCarouselComponent } from '../../../../shared/components/main-carousel/main-carousel.component';
|
||||||
@@ -44,6 +47,7 @@ import {
|
|||||||
export class StoreHomePageComponent implements OnInit, OnDestroy {
|
export class StoreHomePageComponent implements OnInit, OnDestroy {
|
||||||
private readonly cartService = inject(CartService);
|
private readonly cartService = inject(CartService);
|
||||||
private readonly catalogService = inject(CatalogService);
|
private readonly catalogService = inject(CatalogService);
|
||||||
|
private readonly injector = inject(Injector);
|
||||||
private readonly route = inject(ActivatedRoute);
|
private readonly route = inject(ActivatedRoute);
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
private readonly tenantService = inject(TenantService);
|
private readonly tenantService = inject(TenantService);
|
||||||
@@ -55,6 +59,7 @@ export class StoreHomePageComponent implements OnInit, OnDestroy {
|
|||||||
protected readonly loadingGroupIds = signal<ReadonlySet<number>>(new Set());
|
protected readonly loadingGroupIds = signal<ReadonlySet<number>>(new Set());
|
||||||
protected readonly error = signal<string | null>(null);
|
protected readonly error = signal<string | null>(null);
|
||||||
protected readonly mainCarouselReady = signal(false);
|
protected readonly mainCarouselReady = signal(false);
|
||||||
|
protected readonly creatingDirectPurchase = signal(false);
|
||||||
protected readonly hasMainCarouselImages = computed(
|
protected readonly hasMainCarouselImages = computed(
|
||||||
() => (this.tenant()?.main_carousel_images?.length ?? 0) > 0,
|
() => (this.tenant()?.main_carousel_images?.length ?? 0) > 0,
|
||||||
);
|
);
|
||||||
@@ -64,7 +69,8 @@ export class StoreHomePageComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
const resolvedData = this.route.snapshot.data['catalogData'] as
|
const resolvedData = this.route.snapshot.data['catalogData'] as
|
||||||
StoreHomeCatalogResolvedData | undefined;
|
| StoreHomeCatalogResolvedData
|
||||||
|
| undefined;
|
||||||
|
|
||||||
if (resolvedData) {
|
if (resolvedData) {
|
||||||
this.applyResolvedData(resolvedData);
|
this.applyResolvedData(resolvedData);
|
||||||
@@ -118,8 +124,45 @@ export class StoreHomePageComponent implements OnInit, OnDestroy {
|
|||||||
this.groupRequestSubscriptions.set(groupId, subscription);
|
this.groupRequestSubscriptions.set(groupId, subscription);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected onBuyProduct(product: ProductListItem): void {
|
protected async onBuyProduct(event: ProductListBuyEvent): Promise<void> {
|
||||||
this.router.navigate(['/producto', product.id]);
|
if (!event.directPurchase) {
|
||||||
|
await this.router.navigate(['/producto', event.product.id]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.creatingDirectPurchase()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.injector.get(AuthService).user()) {
|
||||||
|
await this.router.navigate(['/login'], {
|
||||||
|
queryParams: { returnUrl: `/producto/${event.product.id}` },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenant = this.tenantService.tenant();
|
||||||
|
if (!tenant) {
|
||||||
|
this.toastService.danger('No se pudo identificar la tienda.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.creatingDirectPurchase.set(true);
|
||||||
|
try {
|
||||||
|
const purchase = await this.injector.get(CheckoutService).startCheckout(tenant.codigo, {
|
||||||
|
direct_item: {
|
||||||
|
catalog_item_id: event.product.id,
|
||||||
|
variant_id: event.variant ?? null,
|
||||||
|
cantidad: event.quantity,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await this.router.navigate(['/checkout'], { queryParams: { purchase: purchase.id } });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create direct purchase:', error);
|
||||||
|
this.toastService.danger('No se pudo iniciar la compra directa.');
|
||||||
|
} finally {
|
||||||
|
this.creatingDirectPurchase.set(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected onAddToCart(event: ProductListCartEvent): void {
|
protected onAddToCart(event: ProductListCartEvent): void {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
[description]="item.descripcion ?? ''"
|
[description]="item.descripcion ?? ''"
|
||||||
[price]="price(item)"
|
[price]="price(item)"
|
||||||
[variants]="variantsFor(item)"
|
[variants]="variantsFor(item)"
|
||||||
(buy)="buy.emit(item)"
|
(buy)="emitRowBuy(item, $event)"
|
||||||
(addToCart)="emitRowCart(item, $event)"
|
(addToCart)="emitRowCart(item, $event)"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
[title]="item.nombre"
|
[title]="item.nombre"
|
||||||
[description]="item.descripcion ?? ''"
|
[description]="item.descripcion ?? ''"
|
||||||
[price]="price(item)"
|
[price]="price(item)"
|
||||||
(buy)="buy.emit(item)"
|
(buy)="emitColumnBuy(item, $event)"
|
||||||
(addToCart)="emitColumnCart(item, $event)"
|
(addToCart)="emitColumnCart(item, $event)"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
[title]="item.nombre"
|
[title]="item.nombre"
|
||||||
[originalPrice]="price(item)"
|
[originalPrice]="price(item)"
|
||||||
[imagePriority]="loadImages() && index < 4"
|
[imagePriority]="loadImages() && index < 4"
|
||||||
(buy)="buy.emit(item)"
|
(buy)="emitProductDetailBuy(item)"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,6 +126,40 @@ describe('ProductListComponent', () => {
|
|||||||
).toHaveLength(2);
|
).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('emits a direct-purchase event with the row quantity and selected variant', async () => {
|
||||||
|
const fixture = await render('row');
|
||||||
|
const buySpy = vi.fn();
|
||||||
|
fixture.componentInstance.buy.subscribe(buySpy);
|
||||||
|
const rowCard = fixture.nativeElement.querySelector('app-product-row-card') as HTMLElement;
|
||||||
|
|
||||||
|
(rowCard.querySelector('.btn-primary') as HTMLButtonElement).click();
|
||||||
|
|
||||||
|
expect(buySpy).toHaveBeenCalledWith({
|
||||||
|
product: items[0],
|
||||||
|
quantity: 1,
|
||||||
|
variant: null,
|
||||||
|
directPurchase: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits a direct-purchase event with the column quantity', async () => {
|
||||||
|
const fixture = await render('column_with_cart');
|
||||||
|
const buySpy = vi.fn();
|
||||||
|
fixture.componentInstance.buy.subscribe(buySpy);
|
||||||
|
const card = fixture.nativeElement.querySelector(
|
||||||
|
'app-product-vertical-with-cart-card',
|
||||||
|
) as HTMLElement;
|
||||||
|
|
||||||
|
(card.querySelector('.btn-primary') as HTMLButtonElement).click();
|
||||||
|
|
||||||
|
expect(buySpy).toHaveBeenCalledWith({
|
||||||
|
product: items[0],
|
||||||
|
quantity: 1,
|
||||||
|
variant: null,
|
||||||
|
directPurchase: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('renders pagination and emits the requested page', async () => {
|
it('renders pagination and emits the requested page', async () => {
|
||||||
const fixture = await render('row');
|
const fixture = await render('row');
|
||||||
const pageChangeSpy = vi.fn();
|
const pageChangeSpy = vi.fn();
|
||||||
|
|||||||
@@ -37,6 +37,13 @@ export interface ProductListCartEvent {
|
|||||||
variant?: number | null;
|
variant?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProductListBuyEvent {
|
||||||
|
product: ProductListItem;
|
||||||
|
quantity: number;
|
||||||
|
variant?: number | null;
|
||||||
|
directPurchase: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-product-list',
|
selector: 'app-product-list',
|
||||||
imports: [
|
imports: [
|
||||||
@@ -61,7 +68,7 @@ export class ProductListComponent {
|
|||||||
readonly loading = input(false);
|
readonly loading = input(false);
|
||||||
readonly loadImages = input(true);
|
readonly loadImages = input(true);
|
||||||
|
|
||||||
readonly buy = output<ProductListItem>();
|
readonly buy = output<ProductListBuyEvent>();
|
||||||
readonly addToCart = output<ProductListCartEvent>();
|
readonly addToCart = output<ProductListCartEvent>();
|
||||||
readonly pageChange = output<number>();
|
readonly pageChange = output<number>();
|
||||||
|
|
||||||
@@ -119,4 +126,24 @@ export class ProductListComponent {
|
|||||||
protected emitColumnCart(product: ProductListItem, event: { quantity: number }): void {
|
protected emitColumnCart(product: ProductListItem, event: { quantity: number }): void {
|
||||||
this.addToCart.emit({ product, quantity: event.quantity });
|
this.addToCart.emit({ product, quantity: event.quantity });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected emitRowBuy(
|
||||||
|
product: ProductListItem,
|
||||||
|
event: { quantity: number; variant: unknown },
|
||||||
|
): void {
|
||||||
|
this.buy.emit({
|
||||||
|
product,
|
||||||
|
quantity: event.quantity,
|
||||||
|
variant: typeof event.variant === 'number' ? event.variant : null,
|
||||||
|
directPurchase: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected emitColumnBuy(product: ProductListItem, event: { quantity: number }): void {
|
||||||
|
this.buy.emit({ product, quantity: event.quantity, variant: null, directPurchase: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
protected emitProductDetailBuy(product: ProductListItem): void {
|
||||||
|
this.buy.emit({ product, quantity: 1, variant: null, directPurchase: false });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<div class="product-row-card border rounded bg-white shadow-sm d-flex justify-content-between p-3 gap-3">
|
<div
|
||||||
|
class="product-row-card border rounded bg-white shadow-sm d-flex justify-content-between p-3 gap-3"
|
||||||
|
>
|
||||||
<!-- Left Side: Title and Description -->
|
<!-- Left Side: Title and Description -->
|
||||||
<div class="product-row-card__info d-flex flex-column justify-content-center flex-grow-1">
|
<div class="product-row-card__info d-flex flex-column justify-content-center flex-grow-1">
|
||||||
<h3 class="product-row-card__title text-uppercase mb-1 m-0">
|
<h3 class="product-row-card__title text-uppercase mb-1 m-0">
|
||||||
@@ -19,9 +21,7 @@
|
|||||||
{{ formattedPrice() }}
|
{{ formattedPrice() }}
|
||||||
</span>
|
</span>
|
||||||
<div class="product-row-card__btn-wrapper">
|
<div class="product-row-card__btn-wrapper">
|
||||||
<app-button variant="primary" (click)="buy.emit()">
|
<app-button variant="primary" (click)="onBuy()"> Comprar </app-button>
|
||||||
Comprar
|
|
||||||
</app-button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -35,12 +35,10 @@
|
|||||||
</select>
|
</select>
|
||||||
}
|
}
|
||||||
|
|
||||||
<app-quantity-selector [(quantity)]="quantity" ></app-quantity-selector>
|
<app-quantity-selector [(quantity)]="quantity"></app-quantity-selector>
|
||||||
|
|
||||||
<div class="product-row-card__btn-wrapper">
|
<div class="product-row-card__btn-wrapper">
|
||||||
<app-button variant="secondary" (click)="onAddToCart()">
|
<app-button variant="secondary" (click)="onAddToCart()"> Agregar al carrito </app-button>
|
||||||
Agregar al carrito
|
|
||||||
</app-button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
import { ChangeDetectionStrategy, Component, computed, effect, input, output, model } from '@angular/core';
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
computed,
|
||||||
|
effect,
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
model,
|
||||||
|
} from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { ButtonComponent } from '../button/button.component';
|
import { ButtonComponent } from '../button/button.component';
|
||||||
import { QuantitySelectorComponent } from '../quantity-selector/quantity-selector.component';
|
import { QuantitySelectorComponent } from '../quantity-selector/quantity-selector.component';
|
||||||
@@ -28,7 +36,7 @@ export class ProductRowCardComponent {
|
|||||||
readonly selectedVariant = model<any>(null);
|
readonly selectedVariant = model<any>(null);
|
||||||
|
|
||||||
// Interactive events
|
// Interactive events
|
||||||
readonly buy = output<void>();
|
readonly buy = output<{ quantity: number; variant: any }>();
|
||||||
readonly addToCart = output<{ quantity: number; variant: any }>();
|
readonly addToCart = output<{ quantity: number; variant: any }>();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -53,7 +61,14 @@ export class ProductRowCardComponent {
|
|||||||
protected onAddToCart(): void {
|
protected onAddToCart(): void {
|
||||||
this.addToCart.emit({
|
this.addToCart.emit({
|
||||||
quantity: this.quantity(),
|
quantity: this.quantity(),
|
||||||
variant: this.selectedVariant()
|
variant: this.selectedVariant(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onBuy(): void {
|
||||||
|
this.buy.emit({
|
||||||
|
quantity: this.quantity(),
|
||||||
|
variant: this.selectedVariant(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="product-vertical-with-cart-card__actions">
|
<div class="product-vertical-with-cart-card__actions">
|
||||||
<app-button variant="primary" (click)="buy.emit()">Comprar</app-button>
|
<app-button variant="primary" (click)="onBuy()">Comprar</app-button>
|
||||||
<app-button variant="secondary" (click)="onAddToCart()"> Agregar al carrito </app-button>
|
<app-button variant="secondary" (click)="onAddToCart()"> Agregar al carrito </app-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ describe('ProductVerticalWithCartCardComponent', () => {
|
|||||||
) as HTMLButtonElement;
|
) as HTMLButtonElement;
|
||||||
button.click();
|
button.click();
|
||||||
|
|
||||||
expect(buySpy).toHaveBeenCalledOnce();
|
expect(buySpy).toHaveBeenCalledWith({ quantity: 1 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('emits addToCart with the current quantity', async () => {
|
it('emits addToCart with the current quantity', async () => {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export class ProductVerticalWithCartCardComponent {
|
|||||||
|
|
||||||
readonly quantity = model<number>(1);
|
readonly quantity = model<number>(1);
|
||||||
|
|
||||||
readonly buy = output<void>();
|
readonly buy = output<{ quantity: number }>();
|
||||||
readonly addToCart = output<{ quantity: number }>();
|
readonly addToCart = output<{ quantity: number }>();
|
||||||
|
|
||||||
protected readonly formattedPrice = computed(() => this.formatCurrency(this.price()));
|
protected readonly formattedPrice = computed(() => this.formatCurrency(this.price()));
|
||||||
@@ -26,6 +26,10 @@ export class ProductVerticalWithCartCardComponent {
|
|||||||
this.addToCart.emit({ quantity: this.quantity() });
|
this.addToCart.emit({ quantity: this.quantity() });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected onBuy(): void {
|
||||||
|
this.buy.emit({ quantity: this.quantity() });
|
||||||
|
}
|
||||||
|
|
||||||
private formatCurrency(value: number): string {
|
private formatCurrency(value: number): string {
|
||||||
const rounded = Math.round(value);
|
const rounded = Math.round(value);
|
||||||
const parts = rounded.toString().split('.');
|
const parts = rounded.toString().split('.');
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { StepComponent } from './step.component';
|
|||||||
<div id="content-2">Content 2</div>
|
<div id="content-2">Content 2</div>
|
||||||
</app-step>
|
</app-step>
|
||||||
</app-stepper>
|
</app-stepper>
|
||||||
`
|
`,
|
||||||
})
|
})
|
||||||
class TestHostComponent {
|
class TestHostComponent {
|
||||||
@ViewChild('stepper') stepper!: StepperComponent;
|
@ViewChild('stepper') stepper!: StepperComponent;
|
||||||
@@ -25,7 +25,7 @@ class TestHostComponent {
|
|||||||
describe('StepperComponent & StepComponent', () => {
|
describe('StepperComponent & StepComponent', () => {
|
||||||
async function setup() {
|
async function setup() {
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [TestHostComponent, StepperComponent, StepComponent]
|
imports: [TestHostComponent, StepperComponent, StepComponent],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
|
|
||||||
const fixture = TestBed.createComponent(TestHostComponent);
|
const fixture = TestBed.createComponent(TestHostComponent);
|
||||||
@@ -50,6 +50,18 @@ describe('StepperComponent & StepComponent', () => {
|
|||||||
expect(content2).toBeNull();
|
expect(content2).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('can initialize on a specified step', async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [StepperComponent, StepComponent],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(StepperComponent);
|
||||||
|
fixture.componentRef.setInput('initialStepIndex', 1);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(fixture.componentInstance.currentStepIndex()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('advances to step 2 when next() is called and current step is valid', async () => {
|
it('advances to step 2 when next() is called and current step is valid', async () => {
|
||||||
const { fixture, component } = await setup();
|
const { fixture, component } = await setup();
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { ChangeDetectionStrategy, Component, contentChildren, signal } from '@angular/core';
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
contentChildren,
|
||||||
|
input,
|
||||||
|
linkedSignal,
|
||||||
|
} from '@angular/core';
|
||||||
import { StepComponent } from './step.component';
|
import { StepComponent } from './step.component';
|
||||||
import { NgClass } from '@angular/common';
|
import { NgClass } from '@angular/common';
|
||||||
|
|
||||||
@@ -11,7 +17,8 @@ import { NgClass } from '@angular/common';
|
|||||||
})
|
})
|
||||||
export class StepperComponent {
|
export class StepperComponent {
|
||||||
readonly steps = contentChildren(StepComponent);
|
readonly steps = contentChildren(StepComponent);
|
||||||
readonly currentStepIndex = signal(0);
|
readonly initialStepIndex = input(0);
|
||||||
|
readonly currentStepIndex = linkedSignal(() => this.initialStepIndex());
|
||||||
|
|
||||||
next() {
|
next() {
|
||||||
const currentSteps = this.steps();
|
const currentSteps = this.steps();
|
||||||
|
|||||||
Reference in New Issue
Block a user