Compare commits

..

5 Commits

16 changed files with 279 additions and 82 deletions

View File

@@ -2,6 +2,8 @@ import { Injectable } from '@angular/core';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
import { ApiPaginatedResponse } from './api-paginated-response.interface';
import { ApiPaginationQueryParams } from './api-pagination-query-params.interface';
import { BaseApiService } from './base-api.service'; import { BaseApiService } from './base-api.service';
export interface UpdatePurchaseCustomerPayload { export interface UpdatePurchaseCustomerPayload {
@@ -226,12 +228,20 @@ export class CheckoutService extends BaseApiService {
async getPurchases( async getPurchases(
tenantCode: string, tenantCode: string,
status?: string, status?: string,
): Promise<{ data: PurchaseSummaryResponse[] }> { pagination: ApiPaginationQueryParams = {},
let url = `${environment.url}tenants/${tenantCode}/compras`; ): Promise<ApiPaginatedResponse<PurchaseSummaryResponse[]>> {
if (status) { const params: Record<string, string | number> = {};
url += `?status=${status}`;
} if (status) params['status'] = status;
const response = await firstValueFrom(this.http.get<{ data: PurchaseSummaryResponse[] }>(url)); if (pagination.page) params['page'] = pagination.page;
if (pagination.per_page) params['per_page'] = pagination.per_page;
const response = await firstValueFrom(
this.http.get<ApiPaginatedResponse<PurchaseSummaryResponse[]>>(
`${environment.url}tenants/${tenantCode}/compras`,
{ params },
),
);
if (!response) { if (!response) {
throw new Error('Error al obtener las compras.'); throw new Error('Error al obtener las compras.');
} }

View File

@@ -11,7 +11,7 @@
.menu-content-section__header { .menu-content-section__header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: var(--menu-content-prefix-gap, 0.75rem);
margin-bottom: 1.25rem; margin-bottom: 1.25rem;
} }

View File

@@ -1,11 +1,29 @@
<div class="purchase-item" [routerLink]="[purchase.id]" style="cursor: pointer;"> <div class="purchase-item" [routerLink]="[purchase.id]">
<div class="purchase-info"> <div class="purchase-info">
<span class="purchase-id">Compra {{ purchase.id }}.</span> <span class="purchase-id">Compra {{ purchase.id }}.</span>
<span class="purchase-date">Fecha de compra: {{ purchase.date }}</span> <span class="purchase-date">Fecha de compra: {{ purchase.date }}</span>
</div> </div>
@if (purchase.statusMessage) {
<span class="purchase-status">{{ purchase.statusMessage }}</span>
}
<div class="purchase-action"> <div class="purchase-action">
<svg width="8" height="14" viewBox="0 0 8 14" fill="none" xmlns="http://www.w3.org/2000/svg" class="arrow-icon"> <svg
<path d="M1 1L7 7L1 13" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> width="8"
height="14"
viewBox="0 0 8 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="arrow-icon"
>
<path
d="M1 1L7 7L1 13"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg> </svg>
</div> </div>
</div> </div>

View File

@@ -2,33 +2,57 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 16px;
padding: 16px; padding: 16px;
padding-left:0; padding-left: 0;
transition: box-shadow 0.2s, border-radius 0.2s; transition:
box-shadow 0.2s,
border-radius 0.2s;
cursor: pointer; cursor: pointer;
} }
.purchase-item:hover { .purchase-item:hover {
box-shadow: 0px 0px 30px 0px rgba(0, 0, 0, 0.07); box-shadow: 0px 0px 30px 0px rgba(0, 0, 0, 0.07);
border-radius: 8px; border-radius: 8px;
} }
.purchase-item:hover .arrow-icon { .purchase-item:hover .arrow-icon {
color: #5b75ff; /* Blue on hover */ color: var(--color-primary, var(--tenant-primary, #5b75ff));
} }
.purchase-info { .purchase-info {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 4px;
} }
.purchase-id { .purchase-id {
font-weight: bold; font-weight: bold;
color: #666666; color: #666666;
font-size: 13px; font-size: 13px;
} }
.purchase-date { .purchase-date {
font-weight: 325; font-weight: 325;
color: #666666; color: #666666;
font-size: 10px; font-size: 10px;
} }
.purchase-status {
margin-left: auto;
margin-right: 20px;
color: var(--color-primary, var(--tenant-primary, #5b75ff));
font-size: 16px;
font-weight: 700;
text-align: right;
}
.purchase-action {
display: flex;
flex-shrink: 0;
align-items: center;
}
.arrow-icon { .arrow-icon {
width: 8px; width: 8px;
height: 14px; height: 14px;

View File

@@ -9,6 +9,5 @@ import { RouterLink } from '@angular/router';
styleUrl: './purchase-list-item.scss', styleUrl: './purchase-list-item.scss',
}) })
export class PurchaseListItem { export class PurchaseListItem {
@Input() purchase!: { id: number; date: string }; @Input() purchase!: { id: number; date: string; statusMessage?: string | null };
} }

View File

@@ -1,29 +1,36 @@
<div class="purchase-list-container"> <div class="purchase-list-container">
<ng-container *ngIf="isLoading(); else contentTpl"> @if (isLoading()) {
<!-- Skeleton --> @for (i of [1, 2, 3, 4]; track i) {
<div class="skeleton-item" *ngFor="let i of [1, 2, 3, 4]"> <div class="skeleton-item">
<div class="skeleton-info"> <div class="skeleton-info">
<div class="skeleton-id"></div> <div class="skeleton-id"></div>
<div class="skeleton-date"></div> <div class="skeleton-date"></div>
</div> </div>
<div class="skeleton-action"></div> <div class="skeleton-action"></div>
</div> </div>
</ng-container> }
} @else {
@if (purchases().length > 0) {
@for (purchase of purchases(); track purchase.id; let last = $last) {
<app-purchase-list-item [purchase]="purchase"></app-purchase-list-item>
@if (!last) {
<div class="purchase-divider"></div>
}
}
} @else {
<div class="empty-state">Aún no hay compras realizadas</div>
}
<ng-template #contentTpl> @if (pagination(); as paginationData) {
<ng-container *ngIf="purchases().length > 0; else emptyTpl"> @if (paginationData.last_page > 1) {
<ng-container *ngFor="let purchase of purchases(); let last = last"> <app-paginator
<app-purchase-list-item class="purchase-list__paginator"
[purchase]="purchase"> [page]="paginationData.current_page"
</app-purchase-list-item> [totalPages]="paginationData.last_page"
<div class="purchase-divider" *ngIf="!last"></div> [disabled]="isLoading()"
</ng-container> (pageChange)="onPageChange($event)"
</ng-container> ></app-paginator>
</ng-template> }
}
<ng-template #emptyTpl> }
<div class="empty-state"> </div>
Aún no hay compras realizadas
</div>
</ng-template>
</div>

View File

@@ -4,9 +4,14 @@
width: 100%; width: 100%;
} }
.purchase-list__paginator {
align-self: center;
margin-top: 1.5rem;
}
.purchase-divider { .purchase-divider {
height: 1px; height: 1px;
background-color: #DDDDDD; background-color: #dddddd;
width: 100%; width: 100%;
} }
@@ -39,7 +44,7 @@
.skeleton-id { .skeleton-id {
width: 100px; width: 100px;
height: 1rem; height: 1rem;
background-color: #EEEEEE; background-color: #eeeeee;
border-radius: 4px; border-radius: 4px;
animation: pulse 1.5s infinite ease-in-out; animation: pulse 1.5s infinite ease-in-out;
} }
@@ -47,7 +52,7 @@
.skeleton-date { .skeleton-date {
width: 150px; width: 150px;
height: 0.8rem; height: 0.8rem;
background-color: #EEEEEE; background-color: #eeeeee;
border-radius: 4px; border-radius: 4px;
animation: pulse 1.5s infinite ease-in-out; animation: pulse 1.5s infinite ease-in-out;
} }
@@ -55,19 +60,19 @@
.skeleton-action { .skeleton-action {
width: 24px; width: 24px;
height: 24px; height: 24px;
background-color: #EEEEEE; background-color: #eeeeee;
border-radius: 50%; border-radius: 50%;
animation: pulse 1.5s infinite ease-in-out; animation: pulse 1.5s infinite ease-in-out;
} }
@keyframes pulse { @keyframes pulse {
0% { 0% {
background-color: #EEEEEE; background-color: #eeeeee;
} }
50% { 50% {
background-color: #E0E0E0; background-color: #e0e0e0;
} }
100% { 100% {
background-color: #EEEEEE; background-color: #eeeeee;
} }
} }

View File

@@ -1,19 +1,24 @@
import { Component, OnInit, inject, signal } from '@angular/core'; import { Component, OnInit, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { PurchaseListItem } from '../purchase-list-item/purchase-list-item'; import { PurchaseListItem } from '../purchase-list-item/purchase-list-item';
import { CheckoutService, PurchaseSummaryResponse } from '../../../../../../core/services/checkout.service'; import {
CheckoutService,
PurchaseSummaryResponse,
} from '../../../../../../core/services/checkout.service';
import { ToastService } from '../../../../../../core/services/toast.service'; import { ToastService } from '../../../../../../core/services/toast.service';
import { TenantService } from '../../../../../../core/services/tenant.service'; import { TenantService } from '../../../../../../core/services/tenant.service';
import { ApiPaginationMeta } from '../../../../../../core/services/api-paginated-response.interface';
import { PaginatorComponent } from '../../../../../../shared/components/paginator/paginator.component';
type PurchaseListViewModel = { type PurchaseListViewModel = {
id: number; id: number;
date: string; date: string;
statusMessage: string | null;
}; };
@Component({ @Component({
selector: 'app-purchase-list', selector: 'app-purchase-list',
standalone: true, standalone: true,
imports: [CommonModule, PurchaseListItem], imports: [PurchaseListItem, PaginatorComponent],
templateUrl: './purchase-list.html', templateUrl: './purchase-list.html',
styleUrl: './purchase-list.scss', styleUrl: './purchase-list.scss',
}) })
@@ -22,21 +27,28 @@ export class PurchaseList implements OnInit {
private readonly toastService = inject(ToastService); private readonly toastService = inject(ToastService);
private readonly tenantService = inject(TenantService); private readonly tenantService = inject(TenantService);
purchases = signal<PurchaseListViewModel[]>([]); protected readonly purchases = signal<PurchaseListViewModel[]>([]);
isLoading = signal<boolean>(true); protected readonly pagination = signal<ApiPaginationMeta | null>(null);
protected readonly isLoading = signal(true);
async ngOnInit(): Promise<void> { async ngOnInit(): Promise<void> {
try { await this.loadPurchases();
const tenantCode = this.tenantService.tenant()?.codigo || ''; }
protected async onPageChange(page: number): Promise<void> {
await this.loadPurchases(page);
}
private async loadPurchases(page = 1): Promise<void> {
this.isLoading.set(true);
try {
const response = await this.checkoutService const response = await this.checkoutService
.withCustomLoading() .withCustomLoading()
.getPurchases(tenantCode, 'paid'); .getPurchases(this.tenantCode(), 'paid,in_review', { page });
const mappedPurchases = response.data.map((purchase: PurchaseSummaryResponse) => ({
id: purchase.id, this.purchases.set(this.mapPurchases(response.data));
date: this.formatDate(purchase.created_at), this.pagination.set(response.meta);
}));
this.purchases.set(mappedPurchases);
} catch (error) { } catch (error) {
this.toastService.danger('Hubo un error al cargar las compras'); this.toastService.danger('Hubo un error al cargar las compras');
} finally { } finally {
@@ -44,6 +56,18 @@ export class PurchaseList implements OnInit {
} }
} }
private tenantCode(): string {
return this.tenantService.tenant()?.codigo || '';
}
private mapPurchases(purchases: PurchaseSummaryResponse[]): PurchaseListViewModel[] {
return purchases.map((purchase) => ({
id: purchase.id,
date: this.formatDate(purchase.created_at),
statusMessage: purchase.status === 'in_review' ? 'Esperando confirmación' : null,
}));
}
private formatDate(value: string | null): string { private formatDate(value: string | null): string {
if (!value) { if (!value) {
return '-'; return '-';

View File

@@ -15,6 +15,27 @@
@if (isLoading()) { @if (isLoading()) {
<p class="purchase-loading">Cargando detalle de compra...</p> <p class="purchase-loading">Cargando detalle de compra...</p>
} @else if (purchase(); as purchase) { } @else if (purchase(); as purchase) {
@if (purchase.isInReview) {
<section class="purchase-review" aria-labelledby="purchase-review-title">
<h3 id="purchase-review-title" class="purchase-review__title">ESPERANDO CONFIRMACIÓN</h3>
<p class="purchase-review__message">
Tu compra aún no ha sido confirmada.<br />
Si no te contactaste con nosotros, podés hacerlo a través del siguiente WhatsApp
</p>
@if (whatsappUrl()) {
<app-button
type="button"
variant="primary"
hostClass="purchase-review__button"
(click)="openWhatsApp()"
>
WhatsApp
</app-button>
}
</section>
}
<div <div
class="d-flex justify-content-between align-items-center mb-4 pb-3" class="d-flex justify-content-between align-items-center mb-4 pb-3"
style="border-bottom: 1px solid #dddddd" style="border-bottom: 1px solid #dddddd"

View File

@@ -4,6 +4,8 @@
} }
.account-page { .account-page {
--menu-content-prefix-gap: 0;
display: block; display: block;
width: 100%; width: 100%;
max-width: 600px; max-width: 600px;
@@ -50,6 +52,32 @@
font-size: 17px; font-size: 17px;
} }
.purchase-review {
padding-bottom: 2rem;
margin-bottom: 2rem;
border-bottom: 1px solid #dddddd;
}
.purchase-review__title {
margin: 0 0 0.5rem;
color: var(--color-primary, var(--tenant-primary, #5b75ff));
font-size: 14px;
font-weight: 700;
line-height: 1.25;
}
.purchase-review__message {
margin: 0 0 1.5rem;
color: #777777;
font-size: 12px;
font-weight: 400;
line-height: 1.4;
}
.purchase-review__button {
width: 185px;
}
.purchase-loading, .purchase-loading,
.purchase-empty { .purchase-empty {
color: #666666; color: #666666;

View File

@@ -1,5 +1,4 @@
import { CommonModule } from '@angular/common'; import { Component, OnInit, computed, inject, signal } from '@angular/core';
import { Component, OnInit, inject, signal } from '@angular/core';
import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { import {
@@ -8,6 +7,7 @@ import {
} from '../../../../../../core/services/checkout.service'; } from '../../../../../../core/services/checkout.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 { ButtonComponent } from '../../../../../../shared/components/button/button.component';
import { MenuContentSectionComponent } from '../../../../components/menu-content-section/menu-content-section.component'; import { MenuContentSectionComponent } from '../../../../components/menu-content-section/menu-content-section.component';
import { PurchaseItem, PurchaseItemViewModel } from '../../components/purchase-item/purchase-item'; import { PurchaseItem, PurchaseItemViewModel } from '../../components/purchase-item/purchase-item';
@@ -16,12 +16,13 @@ type PurchaseDetailViewModel = {
date: string; date: string;
total: string; total: string;
items: PurchaseItemViewModel[]; items: PurchaseItemViewModel[];
isInReview: boolean;
}; };
@Component({ @Component({
selector: 'app-purchase-detail-page', selector: 'app-purchase-detail-page',
standalone: true, standalone: true,
imports: [CommonModule, RouterLink, PurchaseItem, MenuContentSectionComponent], imports: [RouterLink, PurchaseItem, MenuContentSectionComponent, ButtonComponent],
templateUrl: './purchase-detail-page.html', templateUrl: './purchase-detail-page.html',
styleUrl: './purchase-detail-page.scss', styleUrl: './purchase-detail-page.scss',
}) })
@@ -34,6 +35,12 @@ export class PurchaseDetailPage implements OnInit {
readonly isLoading = signal(true); readonly isLoading = signal(true);
readonly purchase = signal<PurchaseDetailViewModel | null>(null); readonly purchase = signal<PurchaseDetailViewModel | null>(null);
protected readonly whatsappUrl = computed(
() =>
this.tenantService
.tenant()
?.social_media?.find((socialMedia) => socialMedia.code === 'whatsapp')?.url ?? null,
);
async ngOnInit(): Promise<void> { async ngOnInit(): Promise<void> {
const purchaseId = this.route.snapshot.paramMap.get('id'); const purchaseId = this.route.snapshot.paramMap.get('id');
@@ -54,6 +61,7 @@ export class PurchaseDetailPage implements OnInit {
date: this.formatDate(response.created_at), date: this.formatDate(response.created_at),
total: response.total, total: response.total,
items: this.mapItems(response), items: this.mapItems(response),
isInReview: response.status === 'in_review',
}); });
} catch (error) { } catch (error) {
console.error('Failed to fetch purchase detail:', error); console.error('Failed to fetch purchase detail:', error);
@@ -64,6 +72,14 @@ export class PurchaseDetailPage implements OnInit {
} }
} }
protected openWhatsApp(): void {
const url = this.whatsappUrl();
if (url) {
window.open(url, '_blank', 'noopener,noreferrer');
}
}
private formatDate(value: string | null): string { private formatDate(value: string | null): string {
if (!value) { if (!value) {
return '-'; return '-';

View File

@@ -6,7 +6,11 @@
} @else { } @else {
<div class="checkout-page"> <div class="checkout-page">
<div class="checkout-page__stepper-col"> <div class="checkout-page__stepper-col">
<app-stepper #stepper [initialStepIndex]="checkoutStepIndex()"> <app-stepper
#stepper
[initialStepIndex]="checkoutStepIndex()"
[disabled]="hasSubmittedTransfer()"
>
<app-step label="Datos" [isValid]="isStep1Valid()"> <app-step label="Datos" [isValid]="isStep1Valid()">
<app-checkout-data-step <app-checkout-data-step
[form]="form" [form]="form"

View File

@@ -1,25 +1,27 @@
<div class="stepper-container"> <div class="stepper-container">
<!-- Horizontal indicator bar --> <!-- Horizontal indicator bar -->
<div class="stepper-header"> <div class="stepper-header" [class.is-disabled]="disabled()" [attr.aria-disabled]="disabled()">
@for (step of steps(); track step; let i = $index; let last = $last) { @for (step of steps(); track step; let i = $index; let last = $last) {
<div <div
class="stepper-header__item" class="stepper-header__item"
[ngClass]="{ [ngClass]="{
'is-active': currentStepIndex() === i, 'is-active': currentStepIndex() === i,
'is-completed': currentStepIndex() > i 'is-completed': currentStepIndex() > i,
}" }"
> >
<!-- Connecting line before (except first) --> <!-- Connecting line before (except first) -->
@if (i > 0) { @if (i > 0) {
<div class="stepper-header__line" [ngClass]="{ 'is-completed': currentStepIndex() > i - 1 }"></div> <div
class="stepper-header__line"
[ngClass]="{ 'is-completed': currentStepIndex() > i - 1 }"
></div>
} }
<!-- Circle indicator --> <!-- Circle indicator -->
<div <div
class="stepper-header__circle" class="stepper-header__circle"
[attr.aria-label]="step.label()" [attr.aria-label]="step.label()"
[class.is-clickable]="currentStepIndex() > i" [class.is-clickable]="!disabled() && currentStepIndex() > i"
(click)="goToStep(i)" (click)="goToStep(i)"
></div> ></div>
@@ -33,5 +35,4 @@
<div class="stepper-body"> <div class="stepper-body">
<ng-content></ng-content> <ng-content></ng-content>
</div> </div>
</div> </div>

View File

@@ -13,6 +13,14 @@
margin-bottom: 2rem; margin-bottom: 2rem;
position: relative; position: relative;
&.is-disabled {
opacity: 0.5;
.stepper-header__circle {
cursor: not-allowed;
}
}
&__item { &__item {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -6,7 +6,7 @@ import { StepComponent } from './step.component';
@Component({ @Component({
imports: [StepperComponent, StepComponent], imports: [StepperComponent, StepComponent],
template: ` template: `
<app-stepper #stepper> <app-stepper #stepper [disabled]="stepperDisabled()">
<app-step label="Step 1" [isValid]="step1Valid()"> <app-step label="Step 1" [isValid]="step1Valid()">
<div id="content-1">Content 1</div> <div id="content-1">Content 1</div>
</app-step> </app-step>
@@ -20,6 +20,7 @@ class TestHostComponent {
@ViewChild('stepper') stepper!: StepperComponent; @ViewChild('stepper') stepper!: StepperComponent;
step1Valid = signal(true); step1Valid = signal(true);
step2Valid = signal(true); step2Valid = signal(true);
stepperDisabled = signal(false);
} }
describe('StepperComponent & StepComponent', () => { describe('StepperComponent & StepComponent', () => {
@@ -137,4 +138,28 @@ describe('StepperComponent & StepComponent', () => {
expect(component.stepper.currentStepIndex()).toBe(0); expect(component.stepper.currentStepIndex()).toBe(0);
expect(fixture.nativeElement.querySelector('#content-1')).not.toBeNull(); expect(fixture.nativeElement.querySelector('#content-1')).not.toBeNull();
}); });
it('blocks all navigation and shows visual feedback while disabled', async () => {
const { fixture, component } = await setup();
component.stepperDisabled.set(true);
fixture.detectChanges();
component.stepper.next();
expect(component.stepper.currentStepIndex()).toBe(0);
component.stepperDisabled.set(false);
fixture.detectChanges();
component.stepper.next();
expect(component.stepper.currentStepIndex()).toBe(1);
component.stepperDisabled.set(true);
fixture.detectChanges();
component.stepper.previous();
component.stepper.goToStep(0);
expect(component.stepper.currentStepIndex()).toBe(1);
const header = fixture.nativeElement.querySelector('.stepper-header');
expect(header.classList.contains('is-disabled')).toBe(true);
expect(header.getAttribute('aria-disabled')).toBe('true');
});
}); });

View File

@@ -18,9 +18,12 @@ import { NgClass } from '@angular/common';
export class StepperComponent { export class StepperComponent {
readonly steps = contentChildren(StepComponent); readonly steps = contentChildren(StepComponent);
readonly initialStepIndex = input(0); readonly initialStepIndex = input(0);
readonly disabled = input(false);
readonly currentStepIndex = linkedSignal(() => this.initialStepIndex()); readonly currentStepIndex = linkedSignal(() => this.initialStepIndex());
next() { next() {
if (this.disabled()) return;
const currentSteps = this.steps(); const currentSteps = this.steps();
const currentIndex = this.currentStepIndex(); const currentIndex = this.currentStepIndex();
if (currentIndex < currentSteps.length - 1) { if (currentIndex < currentSteps.length - 1) {
@@ -32,6 +35,8 @@ export class StepperComponent {
} }
previous() { previous() {
if (this.disabled()) return;
const currentIndex = this.currentStepIndex(); const currentIndex = this.currentStepIndex();
if (currentIndex > 0) { if (currentIndex > 0) {
this.currentStepIndex.set(currentIndex - 1); this.currentStepIndex.set(currentIndex - 1);
@@ -39,6 +44,8 @@ export class StepperComponent {
} }
goToStep(index: number) { goToStep(index: number) {
if (this.disabled()) return;
const targetIndex = index; const targetIndex = index;
// Only allow navigating to completed steps or the current one // Only allow navigating to completed steps or the current one
if (targetIndex < this.currentStepIndex()) { if (targetIndex < this.currentStepIndex()) {