Compare commits

..

6 Commits

23 changed files with 303 additions and 152 deletions

View File

@@ -59,7 +59,13 @@
"maximumError": "8kB"
}
],
"outputHashing": "all"
"outputHashing": "all",
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.production.ts"
}
]
},
"development": {
"optimization": true,

View File

@@ -29,6 +29,7 @@
[subtotal]="cartSubtotal()"
[discount]="cartDiscount()"
[total]="cartTotal()"
[readonly]="!cartEditingEnabled()"
[backgroundColor]="'#ffffff'"
(closed)="isCartOpen.set(false)"
>

View File

@@ -645,4 +645,35 @@ describe('StoreLayoutComponent', () => {
expect(cartItem.componentInstance.quantityDisabled()).toBe(false);
expect(buyButton).toBeDefined();
});
it('hides quantity selectors when the tenant disables cart editing', () => {
tenantState.set({ ...tenant, cart_editing_enabled: false });
cartState.set({
id: 1,
tenant_codigo: tenant.codigo,
status: 'active',
subtotal: '100.00',
items: [
{
id: 1,
cantidad: 1,
precio_unitario: '100.00',
catalog_item_id: 1,
variant_id: null,
nombre: 'Producto',
imagen: null,
variant: null,
},
],
});
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
(fixture.componentInstance as any).isCartOpen.set(true);
fixture.detectChanges();
const cartItem = fixture.debugElement.query(By.css('app-cart-item'));
expect(cartItem.componentInstance.readonly()).toBe(true);
expect(cartItem.nativeElement.querySelector('app-quantity-selector')).toBeNull();
});
});

View File

@@ -36,6 +36,9 @@ export class StoreLayoutComponent implements OnInit {
protected readonly isCartOpen = signal(false);
protected readonly isCreatingPurchase = signal(false);
protected readonly displayCart = computed(() => this.tenant()?.display_cart ?? true);
protected readonly cartEditingEnabled = computed(
() => this.tenant()?.cart_editing_enabled ?? true,
);
protected readonly cartSubtotal = computed(() => {
const cart = this.cartService.cart();

View File

@@ -130,7 +130,7 @@ export class CheckoutService extends BaseApiService {
async generatePaymentIntent(
tenantCode: string,
purchaseId: number,
method: 'qr' | 'transfer' | 'telepagos',
method: 'qr' | 'transfer',
payerDni?: string,
): Promise<any> {
const payload: any = { method };

View File

@@ -126,6 +126,8 @@ export interface Tenant {
display_categories?: boolean;
display_seach_bar?: boolean;
display_cart?: boolean;
cart_editing_enabled?: boolean;
display_cart_item_images?: boolean;
social_media?: SocialMedia[];
menues?: Menu[];
categories: Category[];

View File

@@ -58,6 +58,7 @@
[subtotal]="cartSubtotal()"
[discount]="cartDiscount()"
[total]="cartTotal()"
[readonly]="!cartEditingEnabled()"
[allowEditing]="
createdPurchase()?.status === 'created' || createdPurchase()?.status === 'pending_payment'
"

View File

@@ -33,6 +33,7 @@ describe('CheckoutPageComponent payment validation', () => {
let routerStub: { navigate: ReturnType<typeof vi.fn> };
let routeQueryParamMap: ReturnType<typeof convertToParamMap>;
let authUserState: ReturnType<typeof signal>;
let tenantState: ReturnType<typeof signal<{ codigo: string; cart_editing_enabled?: boolean }>>;
beforeAll(() => {
try {
@@ -88,6 +89,7 @@ describe('CheckoutPageComponent payment validation', () => {
routerStub = { navigate: vi.fn() };
routeQueryParamMap = convertToParamMap({});
authUserState = signal(null);
tenantState = signal({ codigo: 'tenant-test' });
await TestBed.configureTestingModule({
imports: [CheckoutPageComponent],
@@ -95,7 +97,7 @@ describe('CheckoutPageComponent payment validation', () => {
{ provide: CheckoutService, useValue: checkoutServiceStub },
{ provide: CatalogService, useValue: { getCatalogItem: vi.fn() } },
{ provide: CartService, useValue: cartServiceStub },
{ provide: TenantService, useValue: { tenant: signal({ codigo: 'tenant-test' }) } },
{ provide: TenantService, useValue: { tenant: tenantState } },
{ provide: AuthService, useValue: { user: authUserState } },
{
provide: ActivatedRoute,
@@ -366,11 +368,7 @@ describe('CheckoutPageComponent payment validation', () => {
expect(component.checkoutStepIndex()).toBe(1);
expect(component.selectedPaymentMethod()).toBe('qr');
expect(checkoutServiceStub.generatePaymentIntent).toHaveBeenCalledWith(
'tenant-test',
25,
'qr',
);
expect(checkoutServiceStub.generatePaymentIntent).toHaveBeenCalledWith('tenant-test', 25, 'qr');
expect(component.qrData()).toBe('qr-value');
expect(component.qrPaymentStatus()).toBe('waiting');
});
@@ -472,6 +470,17 @@ describe('CheckoutPageComponent payment validation', () => {
expect(component.isEditingItems()).toBe(false);
});
it('does not allow item editing when the tenant disables cart editing', async () => {
tenantState.set({ codigo: 'tenant-test', cart_editing_enabled: false });
const { component } = createComponent();
await component.onEditingItemsChange(true);
expect(component.cartEditingEnabled()).toBe(false);
expect(component.isEditingItems()).toBe(false);
expect(checkoutServiceStub.prepareItemEditing).not.toHaveBeenCalled();
});
it('updates customer data on the existing purchase before payment', async () => {
const updatedPurchase = {
id: 25,

View File

@@ -79,6 +79,9 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
protected readonly createdPurchase = signal<PurchaseDetailResponse | null>(null);
protected readonly isLoadingPurchase = signal(true);
protected readonly checkoutStepIndex = signal(0);
protected readonly cartEditingEnabled = computed(
() => this.tenantService.tenant()?.cart_editing_enabled ?? true,
);
protected readonly cartSubtotal = computed(() => {
const purchase = this.createdPurchase();
@@ -101,7 +104,6 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
protected readonly paymentMethods: ReadonlyArray<PaymentMethodOption> = [
{ id: 'qr', label: 'QR' },
{ id: 'transfer', label: 'Transferencia' },
{ id: 'telepagos', label: 'TelePagos' },
];
protected readonly selectedPaymentMethod = signal<PaymentMethod>('qr');
protected readonly copiedTransferField = signal<TransferField | null>(null);
@@ -179,6 +181,10 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
return;
}
if (editing && !this.cartEditingEnabled()) {
return;
}
if (!editing) {
this.isEditingItems.set(false);
@@ -224,7 +230,14 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
const purchaseId = this.createdPurchaseId();
const itemId = event.item.cartItemId;
if (!tenant || !purchaseId || !itemId || this.isUpdatingItem() || !this.isEditingItems()) {
if (
!this.cartEditingEnabled() ||
!tenant ||
!purchaseId ||
!itemId ||
this.isUpdatingItem() ||
!this.isEditingItems()
) {
return;
}
@@ -331,7 +344,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
const purchaseId = this.createdPurchaseId();
const tenant = this.tenantService.tenant();
if (!purchaseId || !tenant || method === 'telepagos') {
if (!purchaseId || !tenant) {
return;
}

View File

@@ -1,6 +1,6 @@
import { FormControl, FormGroup } from '@angular/forms';
export type PaymentMethod = 'qr' | 'transfer' | 'telepagos';
export type PaymentMethod = 'qr' | 'transfer';
export type TransferField = 'cvu' | 'alias';
export type QrPaymentStatus = 'idle' | 'waiting' | 'timed_out' | 'failed';
export type TransferValidationStatus = 'idle' | 'checking' | 'pending' | 'error';

View File

@@ -18,14 +18,7 @@
/>
<span class="payment-method__label">
@if (method.id === 'telepagos') {
<span class="telepagos-logo" aria-label="TelePagos">
<span class="telepagos-logo__tele">tele</span
><span class="telepagos-logo__pagos">pagos</span>
</span>
} @else {
{{ method.label }}
}
{{ method.label }}
</span>
<i class="fa-solid fa-angle-right payment-method__chevron" aria-hidden="true"></i>
@@ -59,8 +52,6 @@
(submitDni)="generateTransferIntent.emit($event)"
(completePurchase)="complete.emit()"
/>
} @else {
<app-checkout-payment-telepagos />
}
</section>
</div>

View File

@@ -101,26 +101,13 @@
}
}
.telepagos-logo {
display: inline-block;
font-size: 1.45rem;
font-weight: 800;
line-height: 1;
letter-spacing: -0.03em;
background: linear-gradient(90deg, #0a69d8 0 78%, #f6a11a 78% 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.payment-panel {
display: flex;
justify-content: flex-end;
min-width: 0;
> app-checkout-payment-qr,
> app-checkout-payment-transfer,
> app-checkout-payment-telepagos {
> app-checkout-payment-transfer {
display: block;
width: 100%;
max-width: 320px;

View File

@@ -1,7 +1,6 @@
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
import { CheckoutPaymentQrComponent } from './components/checkout-payment-qr/checkout-payment-qr.component';
import { CheckoutPaymentTelepagosComponent } from './components/checkout-payment-telepagos/checkout-payment-telepagos.component';
import { CheckoutPaymentTransferComponent } from './components/checkout-payment-transfer/checkout-payment-transfer.component';
import {
PaymentMethod,
@@ -15,7 +14,7 @@ import {
@Component({
selector: 'app-checkout-payment-step',
standalone: true,
imports: [CheckoutPaymentQrComponent, CheckoutPaymentTelepagosComponent, CheckoutPaymentTransferComponent],
imports: [CheckoutPaymentQrComponent, CheckoutPaymentTransferComponent],
templateUrl: './checkout-payment-step.component.html',
styleUrl: './checkout-payment-step.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush

View File

@@ -1,22 +0,0 @@
<div class="payment-panel__card">
<h3 class="payment-panel__title">Descarga la app de TelePagos para finalizar la compra</h3>
<div class="payment-panel__divider"></div>
<div class="store-badges" aria-label="Tiendas disponibles">
<div class="store-badge">
<i class="fa-brands fa-google-play" aria-hidden="true"></i>
<span class="store-badge__text">
<small>Disponible en</small>
<strong>Google Play</strong>
</span>
</div>
<div class="store-badge">
<i class="fa-brands fa-apple" aria-hidden="true"></i>
<span class="store-badge__text">
<small>Descargalo en</small>
<strong>App Store</strong>
</span>
</div>
</div>
</div>

View File

@@ -1,71 +0,0 @@
.payment-panel__card {
width: 100%;
max-width: 320px;
min-height: 269px;
padding: 1.5rem 1.75rem;
border-radius: 5px;
background: #ffffff;
color: #666666;
box-shadow: 0 0 0 1px #f1f1f1;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.payment-panel__title {
max-width: 18rem;
margin: 0;
color: #8a8a8a;
font-size: 13px;
font-weight: 700;
line-height: 1.35;
}
.payment-panel__divider {
width: 100%;
max-width: 220px;
height: 1px;
margin: 1.2rem 0 1.45rem;
background: #dddddd;
}
.store-badges {
width: 100%;
max-width: 210px;
display: grid;
gap: 0.8rem;
}
.store-badge {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 0.95rem;
border-radius: 0.85rem;
background: #111111;
color: #ffffff;
box-shadow: 0 10px 24px rgba(17, 17, 17, 0.16);
i {
font-size: 1.35rem;
}
&__text {
display: grid;
text-align: left;
line-height: 1.1;
small {
font-size: 0.62rem;
letter-spacing: 0.05em;
text-transform: uppercase;
opacity: 0.8;
}
strong {
font-size: 1rem;
font-weight: 700;
}
}
}

View File

@@ -1,11 +0,0 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
@Component({
selector: 'app-checkout-payment-telepagos',
standalone: true,
imports: [],
templateUrl: './checkout-payment-telepagos.component.html',
styleUrl: './checkout-payment-telepagos.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CheckoutPaymentTelepagosComponent {}

View File

@@ -1,7 +1,10 @@
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap, Router } from '@angular/router';
import { CheckoutService, PurchaseDetailResponse } from '../../../../core/services/checkout.service';
import {
CheckoutService,
PurchaseDetailResponse,
} from '../../../../core/services/checkout.service';
import { Tenant } from '../../../../core/services/tenant.interface';
import { TenantService } from '../../../../core/services/tenant.service';
import { PurchaseStatusPageComponent } from './purchase-status-page.component';
@@ -130,4 +133,89 @@ describe('PurchaseStatusPageComponent', () => {
openSpy.mockRestore();
});
it('polls every five seconds while the payment is pending and shows the confirmation', async () => {
vi.useFakeTimers();
try {
const checkoutService = {
getPurchase: vi
.fn()
.mockResolvedValueOnce({ status: 'pending_payment' } as PurchaseDetailResponse)
.mockResolvedValueOnce(purchase(true)),
withCustomLoading() {
return this;
},
};
await TestBed.configureTestingModule({
imports: [PurchaseStatusPageComponent],
providers: [
{ provide: CheckoutService, useValue: checkoutService },
{ provide: TenantService, useValue: { tenant: () => tenant } },
{
provide: ActivatedRoute,
useValue: { snapshot: { paramMap: convertToParamMap({ id: '42' }) } },
},
{ provide: Router, useValue: { navigate: vi.fn(), navigateByUrl: vi.fn() } },
],
}).compileComponents();
const fixture = TestBed.createComponent(PurchaseStatusPageComponent);
fixture.detectChanges();
await Promise.resolve();
fixture.detectChanges();
expect(checkoutService.getPurchase).toHaveBeenCalledOnce();
expect(fixture.nativeElement.textContent).toContain('ESTAMOS VERIFICANDO TU PAGO');
await vi.advanceTimersByTimeAsync(5_000);
fixture.detectChanges();
expect(checkoutService.getPurchase).toHaveBeenCalledTimes(2);
expect(fixture.nativeElement.textContent).toContain('COMPRA REALIZADA!');
await vi.advanceTimersByTimeAsync(10_000);
expect(checkoutService.getPurchase).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
}
});
it('stops polling when the page is destroyed', async () => {
vi.useFakeTimers();
try {
const checkoutService = {
getPurchase: vi.fn().mockResolvedValue({ status: 'pending_payment' }),
withCustomLoading() {
return this;
},
};
await TestBed.configureTestingModule({
imports: [PurchaseStatusPageComponent],
providers: [
{ provide: CheckoutService, useValue: checkoutService },
{ provide: TenantService, useValue: { tenant: () => tenant } },
{
provide: ActivatedRoute,
useValue: { snapshot: { paramMap: convertToParamMap({ id: '42' }) } },
},
{ provide: Router, useValue: { navigate: vi.fn(), navigateByUrl: vi.fn() } },
],
}).compileComponents();
const fixture = TestBed.createComponent(PurchaseStatusPageComponent);
fixture.detectChanges();
await Promise.resolve();
fixture.destroy();
await vi.advanceTimersByTimeAsync(5_000);
expect(checkoutService.getPurchase).toHaveBeenCalledOnce();
} finally {
vi.useRealTimers();
}
});
});

View File

@@ -1,29 +1,47 @@
import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
OnDestroy,
OnInit,
PLATFORM_ID,
computed,
inject,
signal,
} from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { CheckoutService, PurchaseStatusResponse } from '../../../../core/services/checkout.service';
import {
CheckoutService,
PurchaseStatusResponse,
} from '../../../../core/services/checkout.service';
import { findMenu } from '../../../../core/services/menu.utils';
import { TenantService } from '../../../../core/services/tenant.service';
import { ButtonComponent } from '../../../../shared/components/button/button.component';
type PurchaseStatusView = 'approved' | 'pending' | 'rejected' | 'expired' | 'error';
const PAYMENT_STATUS_POLL_INTERVAL_MS = 5_000;
@Component({
selector: 'app-purchase-status-page',
standalone: true,
imports: [ButtonComponent],
templateUrl: './purchase-status-page.component.html',
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 router = inject(Router);
private readonly checkoutService = inject(CheckoutService);
private readonly tenantService = inject(TenantService);
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
private purchaseId: string | null = null;
private tenantCode: string | null = null;
private pollingTimeout: ReturnType<typeof setTimeout> | null = null;
private isDestroyed = false;
protected readonly isLoading = signal(true);
protected readonly status = signal<PurchaseStatusView>('pending');
@@ -57,7 +75,12 @@ export class PurchaseStatusPageComponent implements OnInit {
void this.loadStatus();
}
private async loadStatus(): Promise<void> {
ngOnDestroy(): void {
this.isDestroyed = true;
this.stopPolling();
}
private async loadStatus(isPolling = false): Promise<void> {
if (!this.purchaseId || !this.tenantCode) {
return;
}
@@ -66,13 +89,52 @@ export class PurchaseStatusPageComponent implements OnInit {
const purchase = await this.checkoutService
.withCustomLoading()
.getPurchase(this.tenantCode, this.purchaseId);
this.status.set(this.resolveStatus(purchase));
if (this.isDestroyed) {
return;
}
const status = this.resolveStatus(purchase);
this.status.set(status);
this.hasGeneratedTickets.set(purchase.has_generated_tickets === true);
if (status === 'pending') {
this.schedulePolling();
} else {
this.stopPolling();
}
} catch (error) {
console.error('Failed to fetch purchase status:', error);
this.status.set('error');
if (!this.isDestroyed) {
if (isPolling) {
this.schedulePolling();
} else {
this.status.set('error');
}
}
} finally {
this.isLoading.set(false);
if (!this.isDestroyed) {
this.isLoading.set(false);
}
}
}
private schedulePolling(): void {
if (!this.isBrowser || this.isDestroyed || this.pollingTimeout) {
return;
}
this.pollingTimeout = setTimeout(() => {
this.pollingTimeout = null;
void this.loadStatus(true);
}, PAYMENT_STATUS_POLL_INTERVAL_MS);
}
private stopPolling(): void {
if (this.pollingTimeout) {
clearTimeout(this.pollingTimeout);
this.pollingTimeout = null;
}
}

View File

@@ -20,6 +20,17 @@
<span class="ticket-selector__label">Seleccioná Entrada/s:</span>
<div class="ticket-selector__rows">
@if (attributes().length > 0) {
<div class="ticket-selector__columns" aria-hidden="true">
<div class="ticket-selector__column-labels">
@for (attribute of attributes(); track attribute.key) {
<span class="ticket-selector__column-label">{{ attribute.label }}</span>
}
</div>
<span class="ticket-selector__action-spacer"></span>
</div>
}
@for (row of rows(); track row.id) {
<div class="ticket-selector__row">
<div class="ticket-selector__row-content">

View File

@@ -57,6 +57,30 @@
gap: 0.5rem;
}
&__columns {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: end;
gap: 0.5rem;
}
&__column-labels {
min-width: 0;
display: grid;
grid-template-columns: repeat(4, minmax(120px, 1fr));
gap: 0.5rem;
}
&__column-label {
color: #666;
font-size: 13px;
font-weight: 600;
}
&__action-spacer {
width: 38px;
}
&__row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
@@ -160,6 +184,10 @@
grid-template-columns: 1fr;
}
&__columns {
display: none;
}
&__map {
margin-top: 2rem;
}

View File

@@ -209,6 +209,23 @@ describe('ProductTicketSelectorComponent', () => {
expect(removeButton?.classList.contains('icon-btn--bordered')).toBe(true);
});
it('shows one header row with the label of every selector', async () => {
const { fixture } = await createComponent({
maps: [mapResponse([variant(401, 'general', 'A', '1', '1')])],
});
const headerRows = fixture.nativeElement.querySelectorAll('.ticket-selector__columns');
const labels = fixture.nativeElement.querySelectorAll('.ticket-selector__column-label');
expect(headerRows).toHaveLength(1);
expect([...labels].map((label: Element) => label.textContent?.trim())).toEqual([
'Tipo',
'Sector',
'Fila',
'Asiento',
]);
});
it('clears only the seat after a stock conflict when the row still has alternatives', async () => {
const failed = variant(401, 'general', 'A', '1', '1');
const alternative = variant(402, 'general', 'A', '1', '2');

View File

@@ -71,7 +71,7 @@ export class ProductTicketSelectorComponent {
private readonly viewReady = signal(false);
private readonly mapReady = signal(false);
private readonly variants = signal<CatalogFeaturedItemVariant[]>([]);
private readonly attributes = signal<VariantAttribute[]>([]);
protected readonly attributes = signal<VariantAttribute[]>([]);
private mapRequest: Subscription | null = null;
readonly productId = input.required<number>();

View File

@@ -0,0 +1,6 @@
export const environment = {
production: true,
nombre: "Producción - activo",
url: "https://backend.shopit.com.ar/api/",
urlDescarga: "url/"
};