Compare commits

...

3 Commits

7 changed files with 264 additions and 63 deletions

View File

@@ -12,6 +12,11 @@ export interface CreatePurchasePayload {
email: string; email: string;
} }
export interface PurchaseStatusResponse {
payment_status: string | null;
status: string | null;
}
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
}) })
@@ -20,12 +25,16 @@ export class CheckoutService {
async createPurchase(tenantCode: string, payload: CreatePurchasePayload): Promise<{ id: number }> { async createPurchase(tenantCode: string, payload: CreatePurchasePayload): Promise<{ id: number }> {
const response = await firstValueFrom( const response = await firstValueFrom(
this.http.post<{ data: { id: number } }>(`${environment.url}tenants/${tenantCode}/compras`, payload) this.http.post<{ data?: { id: number }; id?: number }>(`${environment.url}tenants/${tenantCode}/compras`, payload)
); );
if (!response?.data) {
const purchase = this.extractResponseData<{ id: number }>(response);
if (!purchase?.id) {
throw new Error('Error al crear la compra.'); throw new Error('Error al crear la compra.');
} }
return response.data;
return purchase;
} }
async generatePaymentIntent(tenantCode: string, purchaseId: number, method: 'qr' | 'transfer' | 'telepagos'): Promise<any> { async generatePaymentIntent(tenantCode: string, purchaseId: number, method: 'qr' | 'transfer' | 'telepagos'): Promise<any> {
@@ -38,23 +47,49 @@ export class CheckoutService {
return response; return response;
} }
async finalizePurchase(tenantCode: string, purchaseId: number): Promise<{ payment_status: string; status: string }> { async finalizePurchase(tenantCode: string, purchaseId: number): Promise<PurchaseStatusResponse> {
const response = await firstValueFrom( const response = await firstValueFrom(
this.http.post<{ data: { payment_status: string; status: string } }>(`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/finalize`, {}) this.http.post<{ data?: PurchaseStatusResponse } | PurchaseStatusResponse>(`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/finalize`, {})
); );
if (!response?.data) {
const purchase = this.extractResponseData<PurchaseStatusResponse>(response);
if (!purchase) {
throw new Error('Error al finalizar la compra.'); throw new Error('Error al finalizar la compra.');
} }
return response.data;
return {
payment_status: purchase.payment_status ?? null,
status: purchase.status ?? null
};
} }
async getPurchase(tenantCode: string, purchaseId: string | number): Promise<{ payment_status: string }> { async getPurchase(tenantCode: string, purchaseId: string | number): Promise<PurchaseStatusResponse> {
const response = await firstValueFrom( const response = await firstValueFrom(
this.http.get<{ data: { payment_status: string } }>(`${environment.url}tenants/${tenantCode}/compras/${purchaseId}`) this.http.get<{ data?: PurchaseStatusResponse } | PurchaseStatusResponse>(`${environment.url}tenants/${tenantCode}/compras/${purchaseId}`)
); );
if (!response?.data) {
const purchase = this.extractResponseData<PurchaseStatusResponse>(response);
if (!purchase) {
throw new Error('Error al obtener la compra.'); throw new Error('Error al obtener la compra.');
} }
return response.data;
return {
payment_status: purchase.payment_status ?? null,
status: purchase.status ?? null
};
}
private extractResponseData<T>(response: T | { data?: T } | null | undefined): T | null {
if (!response) {
return null;
}
if (typeof response === 'object' && 'data' in response && response.data) {
return response.data;
}
return response as T;
} }
} }

View File

@@ -243,23 +243,11 @@ export class CheckoutPageComponent implements OnInit {
protected async onFinalize(): Promise<void> { protected async onFinalize(): Promise<void> {
const purchaseId = this.createdPurchaseId(); const purchaseId = this.createdPurchaseId();
const tenant = this.tenantService.tenant();
if (!purchaseId || !tenant) { if (!purchaseId) {
return; return;
} }
try { void this.router.navigate(['/checkout/status', purchaseId]);
this.isGeneratingIntent.set(true); // Re-use this flag for loading state
await this.checkoutService.finalizePurchase(tenant.codigo, purchaseId);
void this.router.navigate(['/checkout/status', purchaseId]);
} catch (error) {
console.error('Failed to finalize purchase:', error);
// Even on failure, we probably want to navigate to the status page to show 'pending' or whatever state it is in.
// But maybe the endpoint returns error if not paid. For now, navigate anyway so the status page handles the state.
void this.router.navigate(['/checkout/status', purchaseId]);
} finally {
this.isGeneratingIntent.set(false);
}
} }
} }

View File

@@ -10,7 +10,7 @@ describe('LoginPageComponent', () => {
TestBed.resetTestingModule(); TestBed.resetTestingModule();
}); });
it('submits credentials and navigates to home on success', async () => { it('submits credentials and redirects to home with a full page reload on success', async () => {
const authService = { const authService = {
login: vi.fn().mockReturnValue( login: vi.fn().mockReturnValue(
of({ of({
@@ -30,6 +30,7 @@ describe('LoginPageComponent', () => {
const component = fixture.componentInstance as any; const component = fixture.componentInstance as any;
const router = TestBed.inject(Router); const router = TestBed.inject(Router);
const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true); const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
const redirectSpy = vi.spyOn(component, 'redirectToHome').mockImplementation(() => undefined);
component.form.setValue({ component.form.setValue({
email: 'ada@example.com', email: 'ada@example.com',
@@ -42,7 +43,8 @@ describe('LoginPageComponent', () => {
email: 'ada@example.com', email: 'ada@example.com',
password: 'secret123' password: 'secret123'
}); });
expect(navigateSpy).toHaveBeenCalledWith(['/']); expect(redirectSpy).toHaveBeenCalled();
expect(navigateSpy).not.toHaveBeenCalled();
}); });
it('surfaces backend login errors', async () => { it('surfaces backend login errors', async () => {

View File

@@ -1,3 +1,4 @@
import { DOCUMENT } from '@angular/common';
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
@@ -20,6 +21,7 @@ export class LoginPageComponent {
private readonly formBuilder = inject(FormBuilder); private readonly formBuilder = inject(FormBuilder);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly authService = inject(AuthService); private readonly authService = inject(AuthService);
private readonly document = inject(DOCUMENT);
private readonly submittedState = signal(false); private readonly submittedState = signal(false);
private readonly serverErrorState = signal<string | null>(null); private readonly serverErrorState = signal<string | null>(null);
@@ -51,7 +53,7 @@ export class LoginPageComponent {
this.authService.login(this.form.getRawValue()).subscribe({ this.authService.login(this.form.getRawValue()).subscribe({
next: () => { next: () => {
this.isSubmittingState.set(false); this.isSubmittingState.set(false);
void this.router.navigate(['/']); this.redirectToHome();
}, },
error: (error: unknown) => { error: (error: unknown) => {
this.isSubmittingState.set(false); this.isSubmittingState.set(false);
@@ -100,6 +102,11 @@ export class LoginPageComponent {
return 'El valor ingresado no es valido.'; return 'El valor ingresado no es valido.';
} }
protected redirectToHome(): void {
const homeUrl = this.router.serializeUrl(this.router.createUrlTree(['/']));
this.document.location.assign(homeUrl);
}
private resolveErrorMessage(error: unknown): string { private resolveErrorMessage(error: unknown): string {
const errorPayload = const errorPayload =
typeof error === 'object' && error !== null && 'error' in error typeof error === 'object' && error !== null && 'error' in error

View File

@@ -30,20 +30,106 @@
</app-button> </app-button>
</div> </div>
} @else if (status() === 'pending') { } @else if (status() === 'pending') {
<div class="status-content__icon status-content__icon--warning"> <div class="status-content__section status-content__section--primary">
<i class="fa-solid fa-clock"></i> <div class="status-content__icon status-content__icon--warning">
<i class="fa-solid fa-clock"></i>
</div>
<h2 class="status-content__title">ESTAMOS VERIFICANDO TU PAGO</h2>
<p class="status-content__subtitle">Tu compra ya fue registrada y estamos esperando la confirmaci&oacute;n del pago.</p>
</div> </div>
<h2 class="status-content__title">PAGO PENDIENTE</h2>
<p class="status-content__subtitle">Estamos esperando la confirmaci&oacute;n de tu pago</p>
<hr class="status-content__divider" /> <hr class="status-content__divider" />
<p class="status-content__message">Una vez que el pago sea procesado, actualizaremos el estado de tu compra.</p> <div class="status-content__section status-content__section--secondary">
} @else { <p class="status-content__message">Esta pantalla se actualiza autom&aacute;ticamente cuando el pago impacta.</p>
<div class="status-content__icon status-content__icon--error"> @if (isRefreshing()) {
<i class="fa-solid fa-triangle-exclamation"></i> <p class="status-content__hint">Actualizando estado...</p>
}
<app-button
type="button"
variant="secondary"
hostClass="status-content__button d-block"
buttonClass="status-content__button-control d-inline-flex align-items-center justify-content-center py-2"
[disabled]="isRefreshing()"
(click)="retryStatusCheck()"
>
Actualizar estado
</app-button>
</div>
} @else if (status() === 'rejected') {
<div class="status-content__section status-content__section--primary">
<div class="status-content__icon status-content__icon--error">
<i class="fa-solid fa-triangle-exclamation"></i>
</div>
<h2 class="status-content__title">NO PUDIMOS CONFIRMAR EL PAGO</h2>
<p class="status-content__subtitle">Revis&aacute; el medio de pago o comunicate con nosotros para continuar.</p>
</div>
<hr class="status-content__divider" />
<div class="status-content__section status-content__section--secondary">
<p class="status-content__message">Si ya pagaste, pod&eacute;s reintentar la consulta o escribirnos para revisarlo.</p>
<div class="status-content__actions">
<app-button
type="button"
variant="secondary"
hostClass="status-content__button d-block"
buttonClass="status-content__button-control d-inline-flex align-items-center justify-content-center py-2"
(click)="retryStatusCheck()"
>
Reintentar
</app-button>
<app-button
type="button"
variant="secondary"
hostClass="status-content__button d-block"
buttonClass="status-content__button-control d-inline-flex align-items-center justify-content-center gap-2 py-2"
(click)="openWhatsApp()"
>
<i class="fa-brands fa-whatsapp status-content__button-icon"></i>
<span>WhatsApp</span>
</app-button>
</div>
</div>
} @else {
<div class="status-content__section status-content__section--primary">
<div class="status-content__icon status-content__icon--error">
<i class="fa-solid fa-triangle-exclamation"></i>
</div>
<h2 class="status-content__title">NO PUDIMOS OBTENER EL ESTADO</h2>
<p class="status-content__subtitle">Hubo un problema al consultar tu compra.</p>
</div>
<hr class="status-content__divider" />
<div class="status-content__section status-content__section--secondary">
<p class="status-content__message">Reintent&aacute; en unos segundos. Si el problema sigue, comunicate con nosotros.</p>
<div class="status-content__actions">
<app-button
type="button"
variant="secondary"
hostClass="status-content__button d-block"
buttonClass="status-content__button-control d-inline-flex align-items-center justify-content-center py-2"
(click)="retryStatusCheck()"
>
Reintentar
</app-button>
<app-button
type="button"
variant="secondary"
hostClass="status-content__button d-block"
buttonClass="status-content__button-control d-inline-flex align-items-center justify-content-center gap-2 py-2"
(click)="openWhatsApp()"
>
<i class="fa-brands fa-whatsapp status-content__button-icon"></i>
<span>WhatsApp</span>
</app-button>
</div>
</div> </div>
<h2 class="status-content__title">HUBO UN ERROR</h2>
<p class="status-content__subtitle">No pudimos obtener el estado de tu compra</p>
} }
</div> </div>

View File

@@ -47,15 +47,15 @@
} }
&--warning { &--warning {
background-color: rgba(var(--color-warning-rgb), 0.1); background-color: color-mix(in srgb, white 92%, var(--color-primary));
color: var(--color-warning); color: var(--color-primary) !important;
border: 2px solid var(--color-warning); border: 1.5px solid color-mix(in srgb, white 20%, var(--color-primary));
} }
&--error { &--error {
background-color: rgba(var(--color-danger-rgb), 0.1); background-color: color-mix(in srgb, white 92%, var(--color-primary));
color: var(--color-danger); color: var(--color-primary) !important;
border: 2px solid var(--color-danger); border: 1.5px solid color-mix(in srgb, white 20%, var(--color-primary));
} }
&--loading { &--loading {
@@ -79,6 +79,7 @@
font-weight: 700; font-weight: 700;
line-height: 1.35; line-height: 1.35;
color: #8a8a8a; color: #8a8a8a;
max-width: 320px;
} }
&__divider { &__divider {
@@ -92,7 +93,7 @@
} }
&__message { &__message {
max-width: 280px; max-width: 320px;
margin: 0; margin: 0;
font-size: 13px; font-size: 13px;
font-weight: 325; font-weight: 325;
@@ -100,10 +101,29 @@
color: var(--color-text); color: var(--color-text);
} }
&__hint {
margin: 0;
font-size: 12px;
line-height: 1.4;
color: #8a8a8a;
}
&__actions {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
width: 100%;
}
&__button { &__button {
width: auto; width: auto;
} }
&__button-control {
min-width: 180px;
}
&__button-icon { &__button-icon {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -113,4 +133,4 @@
font-size: 20px; font-size: 20px;
line-height: 1; line-height: 1;
} }
} }

View File

@@ -1,10 +1,12 @@
import { ChangeDetectionStrategy, Component, inject, OnInit, signal } from '@angular/core'; import { ChangeDetectionStrategy, Component, OnDestroy, OnInit, inject, signal } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { CheckoutService, PurchaseStatusResponse } from '../../../../core/services/checkout.service';
import { TenantService } from '../../../../core/services/tenant.service'; import { TenantService } from '../../../../core/services/tenant.service';
import { CheckoutService } from '../../../../core/services/checkout.service';
import { ButtonComponent } from '../../../../shared/components/button/button.component'; import { ButtonComponent } from '../../../../shared/components/button/button.component';
type PurchaseStatusView = 'approved' | 'pending' | 'rejected' | 'error';
@Component({ @Component({
selector: 'app-purchase-status-page', selector: 'app-purchase-status-page',
standalone: true, standalone: true,
@@ -13,14 +15,20 @@ import { ButtonComponent } from '../../../../shared/components/button/button.com
styleUrl: './purchase-status-page.component.scss', styleUrl: './purchase-status-page.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class PurchaseStatusPageComponent implements OnInit { export class PurchaseStatusPageComponent implements OnInit, OnDestroy {
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly checkoutService = inject(CheckoutService); private readonly checkoutService = inject(CheckoutService);
private readonly tenantService = inject(TenantService); private readonly tenantService = inject(TenantService);
private readonly pollIntervalMs = 5000;
private purchaseId: string | null = null;
private tenantCode: string | null = null;
private pollTimeoutId: ReturnType<typeof setTimeout> | null = null;
protected readonly isLoading = signal(true); protected readonly isLoading = signal(true);
protected readonly status = signal<'approved' | 'pending' | 'error'>('pending'); protected readonly isRefreshing = signal(false);
protected readonly status = signal<PurchaseStatusView>('pending');
ngOnInit(): void { ngOnInit(): void {
const purchaseId = this.route.snapshot.paramMap.get('id'); const purchaseId = this.route.snapshot.paramMap.get('id');
@@ -31,35 +39,90 @@ export class PurchaseStatusPageComponent implements OnInit {
return; return;
} }
this.checkPurchaseStatus(tenant.codigo, purchaseId); this.purchaseId = purchaseId;
this.tenantCode = tenant.codigo;
void this.refreshStatus(true);
} }
private async checkPurchaseStatus(tenantCode: string, purchaseId: string): Promise<void> { ngOnDestroy(): void {
try { this.clearScheduledPoll();
this.isLoading.set(true); }
const purchase = await this.checkoutService.getPurchase(tenantCode, purchaseId);
const paymentStatus = purchase.payment_status;
if (paymentStatus === 'approved') { protected retryStatusCheck(): void {
this.status.set('approved'); void this.refreshStatus(false);
} else { }
this.status.set('pending');
private async refreshStatus(showLoader: boolean): Promise<void> {
if (!this.purchaseId || !this.tenantCode) {
return;
}
this.clearScheduledPoll();
if (showLoader) {
this.isLoading.set(true);
} else {
this.isRefreshing.set(true);
}
try {
const purchase = await this.checkoutService.getPurchase(this.tenantCode, this.purchaseId);
const resolvedStatus = this.resolveStatus(purchase);
this.status.set(resolvedStatus);
if (resolvedStatus === 'pending') {
this.scheduleNextPoll();
} }
} catch (error) { } catch (error) {
console.error('Failed to fetch purchase status:', error); console.error('Failed to fetch purchase status:', error);
this.status.set('error'); this.status.set('error');
} finally { } finally {
this.isLoading.set(false); this.isLoading.set(false);
this.isRefreshing.set(false);
} }
} }
private resolveStatus(purchase: PurchaseStatusResponse): PurchaseStatusView {
if (purchase.payment_status === 'approved' || purchase.status === 'paid') {
return 'approved';
}
if (purchase.payment_status === 'rejected' || purchase.status === 'cancelled') {
return 'rejected';
}
return 'pending';
}
private scheduleNextPoll(): void {
if (this.pollTimeoutId !== null) {
return;
}
this.pollTimeoutId = setTimeout(() => {
this.pollTimeoutId = null;
void this.refreshStatus(false);
}, this.pollIntervalMs);
}
private clearScheduledPoll(): void {
if (this.pollTimeoutId === null) {
return;
}
clearTimeout(this.pollTimeoutId);
this.pollTimeoutId = null;
}
protected getWhatsAppLink(): string { protected getWhatsAppLink(): string {
const phone = '5493416658247'; const phone = '5493416658247';
const message = encodeURIComponent('Hola! Mi compra fue realizada con \u00e9xito.'); const message = encodeURIComponent('Hola! Mi compra fue realizada con \u00E9xito.');
return `https://wa.me/${phone}?text=${message}`; return `https://wa.me/${phone}?text=${message}`;
} }
protected openWhatsApp(): void { protected openWhatsApp(): void {
window.open(this.getWhatsAppLink(), '_blank', 'noopener,noreferrer'); window.open(this.getWhatsAppLink(), '_blank', 'noopener,noreferrer');
} }
} }