diff --git a/src/app/core/services/auth/auth.service.ts b/src/app/core/services/auth/auth.service.ts index 3fdc3af..a2d45f3 100644 --- a/src/app/core/services/auth/auth.service.ts +++ b/src/app/core/services/auth/auth.service.ts @@ -1,10 +1,11 @@ import { DOCUMENT } from '@angular/common'; -import { HttpClient, HttpErrorResponse, HttpResponse } from '@angular/common/http'; +import { HttpErrorResponse, HttpResponse } from '@angular/common/http'; import { isPlatformBrowser, isPlatformServer } from '@angular/common'; import { computed, inject, Injectable, PLATFORM_ID, signal, TransferState, makeStateKey } from '@angular/core'; import { firstValueFrom, map, Observable, of, tap } from 'rxjs'; import { environment } from '../../../../environments/environment'; +import { BaseApiService } from '../base-api.service'; import { AuthUser, LoginPayload, @@ -26,8 +27,7 @@ const AUTH_USER_SSR_STATE_KEY = makeStateKey('shopit.auth.user'); @Injectable({ providedIn: 'root' }) -export class AuthService { - private readonly http = inject(HttpClient); +export class AuthService extends BaseApiService { private readonly document = inject(DOCUMENT); private readonly platformId = inject(PLATFORM_ID); private readonly cookieService = inject(CookieService); @@ -51,7 +51,9 @@ export class AuthService { .post( `${environment.url}login`, { ...payload, tenant_codigo: tenant.codigo }, - { withCredentials: true } + { + withCredentials: true + } ) .pipe( tap((response) => this.applyAuthenticatedState(response.token, response.user)), @@ -62,13 +64,18 @@ export class AuthService { register(payload: RegisterPayload): Observable { const tenantCode = this.tenantService.getTenant()?.codigo; - return this.http.post(`${environment.url}register`, { - ...payload, - ...(tenantCode ? { tenant_codigo: tenantCode } : {}) - }); + return this.http.post( + `${environment.url}register`, + { + ...payload, + ...(tenantCode ? { tenant_codigo: tenantCode } : {}) + }, + ); } - requestPasswordReset(email: string): Observable> { + requestPasswordReset( + email: string + ): Observable> { const tenantCode = this.tenantService.getTenant()?.codigo; return this.http.post( @@ -77,7 +84,9 @@ export class AuthService { email, ...(tenantCode ? { tenant_codigo: tenantCode } : {}) }, - { observe: 'response' } + { + observe: 'response' + } ); } @@ -88,7 +97,9 @@ export class AuthService { return this.http.post( `${environment.url}password/reset-attempts/validate`, { email, codigo }, - { observe: 'response' } + { + observe: 'response' + } ); } @@ -122,7 +133,9 @@ export class AuthService { .post( `${environment.url}auth/google/exchange`, { oauth_code: oauthCode, tenant_codigo: tenant.codigo }, - { withCredentials: true } + { + withCredentials: true + } ) .pipe( tap((response) => this.applyAuthenticatedState(response.token, response.user)), @@ -131,7 +144,9 @@ export class AuthService { } updateProfile(payload: UpdateProfilePayload): Observable { - return this.http.put(`${environment.url}me`, payload).pipe(tap((user) => this.userState.set(user))); + return this.http + .put(`${environment.url}me`, payload) + .pipe(tap((user) => this.userState.set(user))); } logout(): Observable { @@ -140,7 +155,9 @@ export class AuthService { return of(void 0); } - return this.http.post(`${environment.url}logout`, {}).pipe(tap(() => this.clearSession())); + return this.http + .post(`${environment.url}logout`, {}) + .pipe(tap(() => this.clearSession())); } async bootstrap(): Promise { @@ -179,7 +196,9 @@ export class AuthService { } loadCurrentUser(): Observable { - return this.http.get(`${environment.url}me`).pipe(tap((user) => this.userState.set(user))); + return this.http + .get(`${environment.url}me`) + .pipe(tap((user) => this.userState.set(user))); } hydrateSession(): void { diff --git a/src/app/core/services/base-api.service.spec.ts b/src/app/core/services/base-api.service.spec.ts new file mode 100644 index 0000000..683f8c9 --- /dev/null +++ b/src/app/core/services/base-api.service.spec.ts @@ -0,0 +1,42 @@ +import '@angular/compiler'; +import { provideHttpClient } from '@angular/common/http'; +import { TestBed } from '@angular/core/testing'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { BaseApiService } from './base-api.service'; +import { LOADING_MODE } from './global-loading/loading-mode'; + +class TestApiService extends BaseApiService { + contextFor() { + return this.loadingContext(); + } +} + +describe('BaseApiService', () => { + afterEach(() => { + TestBed.resetTestingModule(); + }); + + it('uses global loading by default', () => { + TestBed.configureTestingModule({ providers: [provideHttpClient()] }); + const service = TestBed.runInInjectionContext(() => new TestApiService()); + + expect(service.contextFor().get(LOADING_MODE)).toBe('global'); + }); + + it('creates an isolated custom-loading scope', () => { + TestBed.configureTestingModule({ providers: [provideHttpClient()] }); + const service = TestBed.runInInjectionContext(() => new TestApiService()); + + expect(service.withCustomLoading().contextFor().get(LOADING_MODE)).toBe('custom'); + expect(service.contextFor().get(LOADING_MODE)).toBe('global'); + }); + + it('creates an isolated scope without a loading indicator', () => { + TestBed.configureTestingModule({ providers: [provideHttpClient()] }); + const service = TestBed.runInInjectionContext(() => new TestApiService()); + + expect(service.withoutLoading().contextFor().get(LOADING_MODE)).toBe('none'); + expect(service.contextFor().get(LOADING_MODE)).toBe('global'); + }); +}); diff --git a/src/app/core/services/base-api.service.ts b/src/app/core/services/base-api.service.ts new file mode 100644 index 0000000..c9143ad --- /dev/null +++ b/src/app/core/services/base-api.service.ts @@ -0,0 +1,79 @@ +import { HttpClient, HttpContext } from '@angular/common/http'; +import { inject } from '@angular/core'; + +import { + LOADING_MODE, + LoadingMode +} from './global-loading/loading-mode'; + +const REQUEST_LOADING_MODE = Symbol('request-loading-mode'); + +class LoadingHttpClient { + readonly get: HttpClient['get']; + readonly post: HttpClient['post']; + readonly put: HttpClient['put']; + readonly patch: HttpClient['patch']; + readonly delete: HttpClient['delete']; + + constructor( + http: HttpClient, + private readonly buildContext: (context?: HttpContext) => HttpContext + ) { + this.get = ((url: string, options?: object) => + http.get(url, this.withLoadingContext(options))) as HttpClient['get']; + this.post = ((url: string, body: unknown, options?: object) => + http.post(url, body, this.withLoadingContext(options))) as HttpClient['post']; + this.put = ((url: string, body: unknown, options?: object) => + http.put(url, body, this.withLoadingContext(options))) as HttpClient['put']; + this.patch = ((url: string, body: unknown, options?: object) => + http.patch(url, body, this.withLoadingContext(options))) as HttpClient['patch']; + this.delete = ((url: string, options?: object) => + http.delete(url, this.withLoadingContext(options))) as HttpClient['delete']; + } + + private withLoadingContext(options?: object): object { + const requestOptions = options as { context?: HttpContext } | undefined; + + return { + ...requestOptions, + context: this.buildContext(requestOptions?.context) + }; + } +} + +export abstract class BaseApiService { + private readonly rawHttp = inject(HttpClient); + private readonly [REQUEST_LOADING_MODE]: LoadingMode = 'global'; + + protected get http(): LoadingHttpClient { + return new LoadingHttpClient( + this.rawHttp, + (context) => this.loadingContext(context) + ); + } + + withLoading(loadingMode: LoadingMode): this { + const scopedService = Object.create(this) as this; + + Object.defineProperty(scopedService, REQUEST_LOADING_MODE, { + configurable: false, + enumerable: false, + value: loadingMode, + writable: false + }); + + return scopedService; + } + + withCustomLoading(): this { + return this.withLoading('custom'); + } + + withoutLoading(): this { + return this.withLoading('none'); + } + + protected loadingContext(context: HttpContext = new HttpContext()): HttpContext { + return context.set(LOADING_MODE, this[REQUEST_LOADING_MODE]); + } +} diff --git a/src/app/core/services/cart/cart.service.spec.ts b/src/app/core/services/cart/cart.service.spec.ts index 0fc9653..08e66e8 100644 --- a/src/app/core/services/cart/cart.service.spec.ts +++ b/src/app/core/services/cart/cart.service.spec.ts @@ -6,6 +6,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { CartService } from './cart.service'; import { TenantService } from '../tenant.service'; import { Cart } from './cart.interface'; +import { LOADING_MODE } from '../global-loading/loading-mode'; describe('CartService', () => { let service: CartService; @@ -75,6 +76,14 @@ describe('CartService', () => { req.flush({ data: mockCart }); }); + it('propagates a custom loading mode to the request context', () => { + service.withCustomLoading().loadCart().subscribe(); + + const req = httpMock.expectOne('http://api.test/tenants/acme/cart'); + expect(req.request.context.get(LOADING_MODE)).toBe('custom'); + req.flush({ data: mockCart }); + }); + it('should clear cart state locally', () => { service.clearCart(); diff --git a/src/app/core/services/cart/cart.service.ts b/src/app/core/services/cart/cart.service.ts index 94e5e0b..4a74bf2 100644 --- a/src/app/core/services/cart/cart.service.ts +++ b/src/app/core/services/cart/cart.service.ts @@ -1,16 +1,15 @@ import { inject, Injectable, signal } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; import { catchError, map, Observable, tap } from 'rxjs'; import { ApiResponse } from '../api-response.interface'; +import { BaseApiService } from '../base-api.service'; import { TenantService } from '../tenant.service'; import { Cart } from './cart.interface'; @Injectable({ providedIn: 'root', }) -export class CartService { - private readonly http = inject(HttpClient); +export class CartService extends BaseApiService { private readonly tenantService = inject(TenantService); private readonly cartState = signal(null); @@ -53,7 +52,13 @@ export class CartService { return this.http .post< ApiResponse - >(`${this.tenantApiUrl}/cart/items`, { catalog_item_id: catalogItemId, variant_id: variantId, cantidad }, { withCredentials: true }) + >( + `${this.tenantApiUrl}/cart/items`, + { catalog_item_id: catalogItemId, variant_id: variantId, cantidad }, + { + withCredentials: true, + }, + ) .pipe( tap((response) => { this.cartState.set(response.data); @@ -66,12 +71,21 @@ export class CartService { ); } - updateItemQuantity(cartItemId: number, cantidad: number): Observable> { + updateItemQuantity( + cartItemId: number, + cantidad: number, + ): Observable> { this.isUpdatingState.set(true); return this.http .patch< ApiResponse - >(`${this.tenantApiUrl}/cart/items/${cartItemId}`, { cantidad }, { withCredentials: true }) + >( + `${this.tenantApiUrl}/cart/items/${cartItemId}`, + { cantidad }, + { + withCredentials: true, + }, + ) .pipe( tap((response) => { this.cartState.set(response.data); @@ -89,7 +103,9 @@ export class CartService { return this.http .delete< ApiResponse - >(`${this.tenantApiUrl}/cart/items/${cartItemId}`, { withCredentials: true }) + >(`${this.tenantApiUrl}/cart/items/${cartItemId}`, { + withCredentials: true, + }) .pipe( tap((response) => { this.cartState.set(response.data); diff --git a/src/app/core/services/catalog/catalog.service.ts b/src/app/core/services/catalog/catalog.service.ts index 58aebf8..7ab4b00 100644 --- a/src/app/core/services/catalog/catalog.service.ts +++ b/src/app/core/services/catalog/catalog.service.ts @@ -1,10 +1,11 @@ +import { HttpParams } from '@angular/common/http'; import { inject, Injectable } from '@angular/core'; -import { HttpClient, HttpParams } from '@angular/common/http'; import { map, Observable } from 'rxjs'; import { ApiPaginationQueryParams } from '../api-pagination-query-params.interface'; import { ApiPaginatedResponse } from '../api-paginated-response.interface'; import { ApiResponse } from '../api-response.interface'; +import { BaseApiService } from '../base-api.service'; import { TenantService } from '../tenant.service'; import { CatalogFeaturedGroup, @@ -24,8 +25,7 @@ export interface CatalogSearchQueryParams extends ApiPaginationQueryParams { @Injectable({ providedIn: 'root', }) -export class CatalogService { - private readonly http = inject(HttpClient); +export class CatalogService extends BaseApiService { private readonly tenantService = inject(TenantService); private get tenantApiUrl(): string { @@ -47,7 +47,9 @@ export class CatalogService { ): Observable> { return this.http.get>( `${this.tenantApiUrl}/catalog-items`, - { params: this.buildHttpParams(params) }, + { + params: this.buildHttpParams(params), + }, ); } @@ -66,18 +68,25 @@ export class CatalogService { ): Observable { return this.http.get( `${this.tenantApiUrl}/catalog/featured-groups/${featuredGroupId}/items`, - { params: this.buildHttpParams(params) }, + { + params: this.buildHttpParams(params), + }, ); } - getCatalogItem(id: number, variantId?: number): Observable { + getCatalogItem( + id: number, + variantId?: number, + ): Observable { let params = new HttpParams(); if (variantId) { params = params.set('variant_id', variantId); } return this.http - .get>(`${this.tenantApiUrl}/catalog-items/${id}`, { params }) + .get>(`${this.tenantApiUrl}/catalog-items/${id}`, { + params, + }) .pipe(map((response) => response.data)); } diff --git a/src/app/core/services/checkout.service.ts b/src/app/core/services/checkout.service.ts index fdb9d08..81d4cfc 100644 --- a/src/app/core/services/checkout.service.ts +++ b/src/app/core/services/checkout.service.ts @@ -1,8 +1,8 @@ -import { inject, Injectable } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; import { firstValueFrom } from 'rxjs'; import { environment } from '../../../environments/environment'; +import { BaseApiService } from './base-api.service'; export interface UpdatePurchaseCustomerPayload { dni: string; @@ -77,9 +77,7 @@ export interface PurchaseDetailResponse extends PurchaseStatusResponse { @Injectable({ providedIn: 'root', }) -export class CheckoutService { - private readonly http = inject(HttpClient); - +export class CheckoutService extends BaseApiService { async startCheckout( tenantCode: string, payload: StartCheckoutPayload, @@ -182,7 +180,10 @@ export class CheckoutService { return purchase; } - async completePurchase(tenantCode: string, purchaseId: number): Promise { + async completePurchase( + tenantCode: string, + purchaseId: number, + ): Promise { const response = await firstValueFrom( this.http.post<{ data?: PurchaseStatusResponse } | PurchaseStatusResponse>( `${environment.url}tenants/${tenantCode}/compras/${purchaseId}/complete`, @@ -220,7 +221,10 @@ export class CheckoutService { return { status: purchase.status ?? null }; } - async cancelPurchase(tenantCode: string, purchaseId: number): Promise { + async cancelPurchase( + tenantCode: string, + purchaseId: number, + ): Promise { const response = await firstValueFrom( this.http.post<{ data?: PurchaseStatusResponse } | PurchaseStatusResponse>( `${environment.url}tenants/${tenantCode}/compras/${purchaseId}/cancel`, @@ -244,7 +248,9 @@ export class CheckoutService { if (status) { url += `?status=${status}`; } - const response = await firstValueFrom(this.http.get<{ data: PurchaseSummaryResponse[] }>(url)); + const response = await firstValueFrom( + this.http.get<{ data: PurchaseSummaryResponse[] }>(url), + ); if (!response) { throw new Error('Error al obtener las compras.'); } diff --git a/src/app/core/services/global-loading/global-loading.interceptor.spec.ts b/src/app/core/services/global-loading/global-loading.interceptor.spec.ts index bd031e7..03b17ea 100644 --- a/src/app/core/services/global-loading/global-loading.interceptor.spec.ts +++ b/src/app/core/services/global-loading/global-loading.interceptor.spec.ts @@ -7,11 +7,9 @@ import { import { TestBed } from '@angular/core/testing'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { - SKIP_GLOBAL_LOADING, - globalLoadingInterceptor -} from './global-loading.interceptor'; +import { globalLoadingInterceptor } from './global-loading.interceptor'; import { GlobalLoadingService } from './global-loading.service'; +import { LOADING_MODE, LoadingMode } from './loading-mode'; describe('globalLoadingInterceptor', () => { let client: HttpClient; @@ -66,14 +64,17 @@ describe('globalLoadingInterceptor', () => { expect(loadingService.isLoading()).toBe(false); }); - it('allows background requests to opt out', () => { - const context = new HttpContext().set(SKIP_GLOBAL_LOADING, true); + it.each(['custom', 'none'])( + 'does not show the global loader for %s requests', + (loadingMode) => { + const context = new HttpContext().set(LOADING_MODE, loadingMode); - client.get('/api/background', { context }).subscribe(); - vi.advanceTimersByTime(150); + client.get(`/api/${loadingMode}`, { context }).subscribe(); + vi.advanceTimersByTime(150); - expect(loadingService.isLoading()).toBe(false); + expect(loadingService.isLoading()).toBe(false); - httpTesting.expectOne('/api/background').flush({}); - }); + httpTesting.expectOne(`/api/${loadingMode}`).flush({}); + } + ); }); diff --git a/src/app/core/services/global-loading/global-loading.interceptor.ts b/src/app/core/services/global-loading/global-loading.interceptor.ts index e4f88d4..4e4ea7e 100644 --- a/src/app/core/services/global-loading/global-loading.interceptor.ts +++ b/src/app/core/services/global-loading/global-loading.interceptor.ts @@ -1,22 +1,15 @@ -import { - HttpContextToken, - HttpEvent, - HttpHandlerFn, - HttpInterceptorFn, - HttpRequest -} from '@angular/common/http'; +import { HttpEvent, HttpHandlerFn, HttpInterceptorFn, HttpRequest } from '@angular/common/http'; import { inject } from '@angular/core'; import { Observable, finalize } from 'rxjs'; import { GlobalLoadingService } from './global-loading.service'; - -export const SKIP_GLOBAL_LOADING = new HttpContextToken(() => false); +import { LOADING_MODE } from './loading-mode'; export const globalLoadingInterceptor: HttpInterceptorFn = ( request: HttpRequest, next: HttpHandlerFn ): Observable> => { - if (request.context.get(SKIP_GLOBAL_LOADING)) { + if (request.context.get(LOADING_MODE) !== 'global') { return next(request); } diff --git a/src/app/core/services/global-loading/loading-mode.ts b/src/app/core/services/global-loading/loading-mode.ts new file mode 100644 index 0000000..0cec324 --- /dev/null +++ b/src/app/core/services/global-loading/loading-mode.ts @@ -0,0 +1,5 @@ +import { HttpContextToken } from '@angular/common/http'; + +export type LoadingMode = 'global' | 'custom' | 'none'; + +export const LOADING_MODE = new HttpContextToken(() => 'global'); diff --git a/src/app/core/services/tenant.service.ts b/src/app/core/services/tenant.service.ts index 2fa2de1..c87bfd2 100644 --- a/src/app/core/services/tenant.service.ts +++ b/src/app/core/services/tenant.service.ts @@ -1,5 +1,5 @@ import { isPlatformBrowser, isPlatformServer } from '@angular/common'; -import { HttpClient, HttpErrorResponse } from '@angular/common/http'; +import { HttpErrorResponse } from '@angular/common/http'; import { inject, Injectable, @@ -13,6 +13,7 @@ import { IS_DISCOVERING_ROUTES } from '@angular/ssr'; import { firstValueFrom } from 'rxjs'; import { environment } from '../../../environments/environment'; +import { BaseApiService } from './base-api.service'; import { Tenant, TenantBootstrapResponse } from './tenant.interface'; import { TENANT_SSR_STATE_KEY, @@ -23,8 +24,7 @@ import { @Injectable({ providedIn: 'root' }) -export class TenantService { - private readonly http = inject(HttpClient); +export class TenantService extends BaseApiService { private readonly platformId = inject(PLATFORM_ID); private readonly request = inject(REQUEST, { optional: true }); private readonly responseInit = inject(RESPONSE_INIT, { optional: true }); @@ -73,7 +73,9 @@ export class TenantService { try { const response = await firstValueFrom( - this.http.get(`${environment.url}tenants/bootstrap/${domain}`) + this.http.get( + `${environment.url}tenants/bootstrap/${domain}` + ) ); if (!response?.data) { diff --git a/src/app/features/store/pages/account-page/components/purchase-list/purchase-list.ts b/src/app/features/store/pages/account-page/components/purchase-list/purchase-list.ts index dcda539..776a6cb 100644 --- a/src/app/features/store/pages/account-page/components/purchase-list/purchase-list.ts +++ b/src/app/features/store/pages/account-page/components/purchase-list/purchase-list.ts @@ -29,7 +29,9 @@ export class PurchaseList implements OnInit { try { const tenantCode = this.tenantService.tenant()?.codigo || ''; - const response = await this.checkoutService.getPurchases(tenantCode, 'paid'); + const response = await this.checkoutService + .withCustomLoading() + .getPurchases(tenantCode, 'paid'); const mappedPurchases = response.data.map((purchase: PurchaseSummaryResponse) => ({ id: purchase.id, date: this.formatDate(purchase.created_at), diff --git a/src/app/features/store/pages/account-page/pages/purchase-detail-page/purchase-detail-page.ts b/src/app/features/store/pages/account-page/pages/purchase-detail-page/purchase-detail-page.ts index 64af312..35f56e7 100644 --- a/src/app/features/store/pages/account-page/pages/purchase-detail-page/purchase-detail-page.ts +++ b/src/app/features/store/pages/account-page/pages/purchase-detail-page/purchase-detail-page.ts @@ -44,7 +44,9 @@ export class PurchaseDetailPage implements OnInit { } try { - const response = await this.checkoutService.getPurchase(tenant.codigo, purchaseId); + const response = await this.checkoutService + .withCustomLoading() + .getPurchase(tenant.codigo, purchaseId); this.purchase.set({ id: response.id, diff --git a/src/app/features/store/pages/account-page/pages/tickets-page/ticket.service.ts b/src/app/features/store/pages/account-page/pages/tickets-page/ticket.service.ts index de64472..3c97e50 100644 --- a/src/app/features/store/pages/account-page/pages/tickets-page/ticket.service.ts +++ b/src/app/features/store/pages/account-page/pages/tickets-page/ticket.service.ts @@ -1,7 +1,7 @@ -import { HttpClient } from '@angular/common/http'; import { inject, Injectable } from '@angular/core'; import { firstValueFrom } from 'rxjs'; +import { BaseApiService } from '../../../../../../core/services/base-api.service'; import { TenantService } from '../../../../../../core/services/tenant.service'; export interface TicketResponse { @@ -21,13 +21,14 @@ export interface TicketResponse { } @Injectable() -export class TicketService { - private readonly http = inject(HttpClient); +export class TicketService extends BaseApiService { private readonly tenantService = inject(TenantService); getTickets(): Promise { return firstValueFrom( - this.http.get<{ data: TicketResponse[] }>(`${this.tenantService.getTenantApiUrl()}/tickets`), + this.http.get<{ data: TicketResponse[] }>( + `${this.tenantService.getTenantApiUrl()}/tickets`, + ), ).then((response) => response.data ?? []); } @@ -36,7 +37,9 @@ export class TicketService { this.http.post( `${this.tenantService.getTenantApiUrl()}/tickets/pdf`, { ticket_ids: ticketIds }, - { responseType: 'blob' }, + { + responseType: 'blob', + }, ), ); } diff --git a/src/app/features/store/pages/account-page/pages/tickets-page/tickets-page.spec.ts b/src/app/features/store/pages/account-page/pages/tickets-page/tickets-page.spec.ts index e1d5e1d..b9a77db 100644 --- a/src/app/features/store/pages/account-page/pages/tickets-page/tickets-page.spec.ts +++ b/src/app/features/store/pages/account-page/pages/tickets-page/tickets-page.spec.ts @@ -8,6 +8,14 @@ import { TicketComponent } from './components/ticket/ticket.component'; import { TicketResponse, TicketService } from './ticket.service'; import { TicketsPage } from './tickets-page'; +function withCustomLoading( + service: T, +): T & { withCustomLoading: () => T } { + return Object.assign(service, { + withCustomLoading: () => service, + }); +} + const ticket = (overrides: Partial): TicketResponse => ({ id: 1, tenant_code: 'tenant', @@ -40,10 +48,10 @@ describe('TicketsPage', () => { providers: [ { provide: TicketService, - useValue: { + useValue: withCustomLoading({ getTickets: () => Promise.resolve([ticket({})]), downloadPdf: () => Promise.resolve(new Blob(['pdf-content'])), - }, + }), }, ], }, @@ -99,10 +107,10 @@ describe('TicketsPage', () => { providers: [ { provide: TicketService, - useValue: { + useValue: withCustomLoading({ getTickets: () => Promise.resolve([ticket({})]), downloadPdf, - }, + }), }, ], }, @@ -150,7 +158,10 @@ describe('TicketsPage', () => { .overrideComponent(TicketsPage, { set: { providers: [ - { provide: TicketService, useValue: { getTickets: () => Promise.resolve([]) } }, + { + provide: TicketService, + useValue: withCustomLoading({ getTickets: () => Promise.resolve([]) }), + }, ], }, }) @@ -183,7 +194,10 @@ describe('TicketsPage', () => { .overrideComponent(TicketsPage, { set: { providers: [ - { provide: TicketService, useValue: { getTickets: () => Promise.resolve(tickets) } }, + { + provide: TicketService, + useValue: withCustomLoading({ getTickets: () => Promise.resolve(tickets) }), + }, ], }, }) @@ -236,7 +250,9 @@ describe('TicketsPage', () => { providers: [ { provide: TicketService, - useValue: { getTickets: () => Promise.resolve([ticket({})]) }, + useValue: withCustomLoading({ + getTickets: () => Promise.resolve([ticket({})]), + }), }, ], }, diff --git a/src/app/features/store/pages/account-page/pages/tickets-page/tickets-page.ts b/src/app/features/store/pages/account-page/pages/tickets-page/tickets-page.ts index 90f033b..355c778 100644 --- a/src/app/features/store/pages/account-page/pages/tickets-page/tickets-page.ts +++ b/src/app/features/store/pages/account-page/pages/tickets-page/tickets-page.ts @@ -35,7 +35,7 @@ export class TicketsPage implements OnInit { async ngOnInit(): Promise { try { - this.tickets.set(await this.ticketService.getTickets()); + this.tickets.set(await this.ticketService.withCustomLoading().getTickets()); } catch { this.toastService.danger('Hubo un error al cargar los tickets'); } finally { @@ -151,7 +151,9 @@ export class TicketsPage implements OnInit { this.isGeneratingPdf.set(true); try { - return await this.ticketService.downloadPdf(tickets.map((ticket) => ticket.id)); + return await this.ticketService + .withCustomLoading() + .downloadPdf(tickets.map((ticket) => ticket.id)); } catch { this.toastService.danger('No se pudo generar el PDF de los tickets.'); return null; diff --git a/src/app/features/store/pages/category-items-page/category-items-page.component.spec.ts b/src/app/features/store/pages/category-items-page/category-items-page.component.spec.ts index d389b35..0d3b4df 100644 --- a/src/app/features/store/pages/category-items-page/category-items-page.component.spec.ts +++ b/src/app/features/store/pages/category-items-page/category-items-page.component.spec.ts @@ -69,7 +69,12 @@ describe('CategoryItemsPageComponent', () => { }, { provide: CatalogService, - useValue: { getCategoryItems }, + useValue: { + getCategoryItems, + withCustomLoading() { + return this; + }, + }, }, { provide: TenantService, diff --git a/src/app/features/store/pages/category-items-page/category-items-page.component.ts b/src/app/features/store/pages/category-items-page/category-items-page.component.ts index 784ee1a..82775d8 100644 --- a/src/app/features/store/pages/category-items-page/category-items-page.component.ts +++ b/src/app/features/store/pages/category-items-page/category-items-page.component.ts @@ -80,7 +80,10 @@ export class CategoryItemsPageComponent { return of(null); } - return this.catalogService.getCategoryItems(categoryId, { page }).pipe( + return this.catalogService + .withCustomLoading() + .getCategoryItems(categoryId, { page }) + .pipe( catchError(() => { this.error.set('No pudimos cargar los productos de esta categoría.'); diff --git a/src/app/features/store/pages/checkout-page/checkout-page.component.spec.ts b/src/app/features/store/pages/checkout-page/checkout-page.component.spec.ts index 563071d..3f24fcf 100644 --- a/src/app/features/store/pages/checkout-page/checkout-page.component.spec.ts +++ b/src/app/features/store/pages/checkout-page/checkout-page.component.spec.ts @@ -22,6 +22,7 @@ describe('CheckoutPageComponent payment validation', () => { generatePaymentIntent: ReturnType; getPurchase: ReturnType; submitPurchaseForReview: ReturnType; + withCustomLoading: ReturnType; }; let cartServiceStub: { cart: ReturnType; @@ -60,7 +61,9 @@ describe('CheckoutPageComponent payment validation', () => { }), getPurchase: vi.fn().mockResolvedValue({ status: 'pending_payment' }), submitPurchaseForReview: vi.fn().mockResolvedValue({ status: 'in_review' }), + withCustomLoading: vi.fn(), }; + checkoutServiceStub.withCustomLoading.mockReturnValue(checkoutServiceStub); cartServiceStub = { cart: signal({ id: 10, @@ -363,7 +366,11 @@ 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'); }); diff --git a/src/app/features/store/pages/checkout-page/checkout-page.component.ts b/src/app/features/store/pages/checkout-page/checkout-page.component.ts index 21db68e..9d023c6 100644 --- a/src/app/features/store/pages/checkout-page/checkout-page.component.ts +++ b/src/app/features/store/pages/checkout-page/checkout-page.component.ts @@ -343,11 +343,9 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { this.isGeneratingIntent.set(true); try { - const response = await this.checkoutService.generatePaymentIntent( - tenant.codigo, - purchaseId, - method, - ); + const response = await this.checkoutService + .withCustomLoading() + .generatePaymentIntent(tenant.codigo, purchaseId, method); if (method === 'qr' && response.qr_data?.qr_code) { if (requestId !== this.paymentMethodRequestId || this.selectedPaymentMethod() !== 'qr') { @@ -381,12 +379,9 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { this.transferValidationStatus.set('idle'); this.isGeneratingIntent.set(true); try { - const response = await this.checkoutService.generatePaymentIntent( - tenant.codigo, - purchaseId, - 'transfer', - dni, - ); + const response = await this.checkoutService + .withCustomLoading() + .generatePaymentIntent(tenant.codigo, purchaseId, 'transfer', dni); if (response.transfer_data) { this.markPurchasePendingPayment(); @@ -438,10 +433,9 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { this.transferValidationStatus.set('checking'); try { - const purchase = await this.checkoutService.submitPurchaseForReview( - tenant.codigo, - purchaseId, - ); + const purchase = await this.checkoutService + .withCustomLoading() + .submitPurchaseForReview(tenant.codigo, purchaseId); if (purchase.status === 'paid') { this.navigateToPurchaseStatus(purchaseId); @@ -494,7 +488,9 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { this.isCheckingQrPayment.set(true); try { - const purchase = await this.checkoutService.getPurchase(tenant.codigo, purchaseId); + const purchase = await this.checkoutService + .withCustomLoading() + .getPurchase(tenant.codigo, purchaseId); if (runId !== this.qrPollingRunId) { return; @@ -575,7 +571,9 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { } try { - const purchase = await this.checkoutService.getPurchase(tenant.codigo, purchaseId); + const purchase = await this.checkoutService + .withCustomLoading() + .getPurchase(tenant.codigo, purchaseId); if ( purchase.status === 'in_review' || diff --git a/src/app/features/store/pages/product-detail-page/product-detail-page.component.spec.ts b/src/app/features/store/pages/product-detail-page/product-detail-page.component.spec.ts index c58e42a..a969d30 100644 --- a/src/app/features/store/pages/product-detail-page/product-detail-page.component.spec.ts +++ b/src/app/features/store/pages/product-detail-page/product-detail-page.component.spec.ts @@ -65,13 +65,17 @@ describe('ProductDetailPageComponent', () => { }); catalogServiceStub = { getCatalogItem: vi.fn(), + withCustomLoading: vi.fn(), }; + catalogServiceStub.withCustomLoading.mockReturnValue(catalogServiceStub); routerStub = { navigate: vi.fn(), }; cartServiceStub = { addItem: vi.fn().mockReturnValue(of({ message: 'Producto agregado al carrito' })), + withCustomLoading: vi.fn(), }; + cartServiceStub.withCustomLoading.mockReturnValue(cartServiceStub); toastServiceStub = { success: vi.fn(), danger: vi.fn(), diff --git a/src/app/features/store/pages/product-detail-page/product-detail-page.component.ts b/src/app/features/store/pages/product-detail-page/product-detail-page.component.ts index d520de8..be6e219 100644 --- a/src/app/features/store/pages/product-detail-page/product-detail-page.component.ts +++ b/src/app/features/store/pages/product-detail-page/product-detail-page.component.ts @@ -155,7 +155,10 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy { this.variantLoading.set(true); this.productSub?.unsubscribe(); - this.productSub = this.catalogService.getCatalogItem(productId, variantId).subscribe({ + this.productSub = this.catalogService + .withCustomLoading() + .getCatalogItem(productId, variantId) + .subscribe({ next: (prod) => { this.applyProduct(prod, false); this.variantLoading.set(false); @@ -246,7 +249,10 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy { } this.addingToCart.set(true); - this.cartService.addItem(currentProduct.id, variant?.id ?? null, this.quantity()).subscribe({ + this.cartService + .withCustomLoading() + .addItem(currentProduct.id, variant?.id ?? null, this.quantity()) + .subscribe({ next: (res) => { const msg = res.message || 'Producto agregado al carrito'; this.toastService.success(msg); diff --git a/src/app/features/store/pages/purchase-status-page/purchase-status-page.component.spec.ts b/src/app/features/store/pages/purchase-status-page/purchase-status-page.component.spec.ts index 420a0e4..49c81e7 100644 --- a/src/app/features/store/pages/purchase-status-page/purchase-status-page.component.spec.ts +++ b/src/app/features/store/pages/purchase-status-page/purchase-status-page.component.spec.ts @@ -66,6 +66,9 @@ describe('PurchaseStatusPageComponent', () => { async function render(hasGeneratedTickets: boolean) { const checkoutService = { getPurchase: vi.fn().mockResolvedValue(purchase(hasGeneratedTickets)), + withCustomLoading() { + return this; + }, }; const router = { navigate: vi.fn().mockResolvedValue(true), diff --git a/src/app/features/store/pages/purchase-status-page/purchase-status-page.component.ts b/src/app/features/store/pages/purchase-status-page/purchase-status-page.component.ts index f3ad371..a397a6d 100644 --- a/src/app/features/store/pages/purchase-status-page/purchase-status-page.component.ts +++ b/src/app/features/store/pages/purchase-status-page/purchase-status-page.component.ts @@ -63,7 +63,9 @@ export class PurchaseStatusPageComponent implements OnInit { } try { - const purchase = await this.checkoutService.getPurchase(this.tenantCode, this.purchaseId); + const purchase = await this.checkoutService + .withCustomLoading() + .getPurchase(this.tenantCode, this.purchaseId); this.status.set(this.resolveStatus(purchase)); this.hasGeneratedTickets.set(purchase.has_generated_tickets === true); } catch (error) { diff --git a/src/app/features/store/pages/search-page/search-page.component.spec.ts b/src/app/features/store/pages/search-page/search-page.component.spec.ts index 8396c5e..72947af 100644 --- a/src/app/features/store/pages/search-page/search-page.component.spec.ts +++ b/src/app/features/store/pages/search-page/search-page.component.spec.ts @@ -65,7 +65,12 @@ describe('SearchPageComponent', () => { }, { provide: CatalogService, - useValue: { searchCatalog }, + useValue: { + searchCatalog, + withCustomLoading() { + return this; + }, + }, }, { provide: TenantService, diff --git a/src/app/features/store/pages/search-page/search-page.component.ts b/src/app/features/store/pages/search-page/search-page.component.ts index 6124719..3c9d7ab 100644 --- a/src/app/features/store/pages/search-page/search-page.component.ts +++ b/src/app/features/store/pages/search-page/search-page.component.ts @@ -112,7 +112,7 @@ export class SearchPageComponent { return of(null); } - return this.catalogService.searchCatalog({ q: query, page }).pipe( + return this.catalogService.withCustomLoading().searchCatalog({ q: query, page }).pipe( catchError(() => { this.error.set('No pudimos realizar la búsqueda en este momento.'); diff --git a/src/app/features/store/pages/store-home-page/store-home-page.component.spec.ts b/src/app/features/store/pages/store-home-page/store-home-page.component.spec.ts index 743f34a..982483f 100644 --- a/src/app/features/store/pages/store-home-page/store-home-page.component.spec.ts +++ b/src/app/features/store/pages/store-home-page/store-home-page.component.spec.ts @@ -216,7 +216,9 @@ describe('StoreHomePageComponent', () => { const catalogServiceStub = { getCatalog: vi.fn(), getFeaturedGroupItems: vi.fn().mockReturnValue(of(createItemsPage(pageTwoItems, 2, 2))), + withCustomLoading: vi.fn(), }; + catalogServiceStub.withCustomLoading.mockReturnValue(catalogServiceStub); await TestBed.configureTestingModule({ imports: [StoreHomePageComponent], @@ -232,6 +234,7 @@ describe('StoreHomePageComponent', () => { (element.querySelector('[data-testid="paginator-next"]') as HTMLButtonElement).click(); fixture.detectChanges(); + expect(catalogServiceStub.withCustomLoading).toHaveBeenCalledOnce(); expect(catalogServiceStub.getFeaturedGroupItems).toHaveBeenCalledWith(7, { page: 2 }); expect(element.textContent).toContain('Mouse Gamer'); expect(element.querySelector('[data-testid="paginator-status"]')?.textContent?.trim()).toBe( @@ -244,7 +247,9 @@ describe('StoreHomePageComponent', () => { const catalogServiceStub = { getCatalog: vi.fn(), getFeaturedGroupItems: vi.fn().mockReturnValue(nextPage.asObservable()), + withCustomLoading: vi.fn(), }; + catalogServiceStub.withCustomLoading.mockReturnValue(catalogServiceStub); await TestBed.configureTestingModule({ imports: [StoreHomePageComponent], diff --git a/src/app/features/store/pages/store-home-page/store-home-page.component.ts b/src/app/features/store/pages/store-home-page/store-home-page.component.ts index 63fd104..bac1577 100644 --- a/src/app/features/store/pages/store-home-page/store-home-page.component.ts +++ b/src/app/features/store/pages/store-home-page/store-home-page.component.ts @@ -105,7 +105,10 @@ export class StoreHomePageComponent implements OnInit, OnDestroy { this.setGroupLoading(groupId, true); this.groupRequestSubscriptions.get(groupId)?.unsubscribe(); - const subscription = this.catalogService.getFeaturedGroupItems(groupId, { page }).subscribe({ + const subscription = this.catalogService + .withCustomLoading() + .getFeaturedGroupItems(groupId, { page }) + .subscribe({ next: (items) => { this.catalog.update((groups) => groups.map((candidate) => @@ -182,7 +185,10 @@ export class StoreHomePageComponent implements OnInit, OnDestroy { this.error.set(null); this.catalogRequestSubscription?.unsubscribe(); - this.catalogRequestSubscription = this.catalogService.getCatalog().subscribe({ + this.catalogRequestSubscription = this.catalogService + .withCustomLoading() + .getCatalog() + .subscribe({ next: (catalog) => this.catalog.set(catalog), error: () => { this.catalog.set([]);