diff --git a/src/app/app.routes.spec.ts b/src/app/app.routes.spec.ts index f5861a0..9b21c14 100644 --- a/src/app/app.routes.spec.ts +++ b/src/app/app.routes.spec.ts @@ -235,7 +235,7 @@ describe('app routes', () => { it('redirects unauthenticated users from /checkout to /login', async () => { const { router } = await renderAppAt('/checkout', createTenantServiceStub(), createAuthServiceStub(false)); - expect(router.url).toBe('/login'); + expect(router.url).toBe('/login?returnUrl=%2Fcheckout'); }); it('allows authenticated users to access /checkout', async () => { diff --git a/src/app/core/layout/store-layout/store-layout.component.html b/src/app/core/layout/store-layout/store-layout.component.html index 2533ecc..242eda6 100644 --- a/src/app/core/layout/store-layout/store-layout.component.html +++ b/src/app/core/layout/store-layout/store-layout.component.html @@ -14,7 +14,10 @@ /> @if (isCartOpen()) { -
+
- Seguir comprando - Comprar + Comprar +
} diff --git a/src/app/core/layout/store-layout/store-layout.component.spec.ts b/src/app/core/layout/store-layout/store-layout.component.spec.ts index 0816b56..45ffffc 100644 --- a/src/app/core/layout/store-layout/store-layout.component.spec.ts +++ b/src/app/core/layout/store-layout/store-layout.component.spec.ts @@ -15,6 +15,7 @@ import { AuthService } from '../../services/auth/auth.service'; import { AuthUser } from '../../services/auth/auth.interfaces'; import { StoreLayoutComponent } from './store-layout.component'; import { StoreHeaderComponent } from './store-header/store-header.component'; +import { CheckoutService } from '../../services/checkout.service'; const tenant: Tenant = { id: 1, @@ -127,12 +128,17 @@ const tenant: Tenant = { describe('StoreLayoutComponent', () => { let tenantState = signal(tenant); let cartState = signal(null); + let authUserState = signal(null); + let checkoutServiceStub: { startCheckout: ReturnType }; beforeEach(async () => { tenantState = signal(tenant); cartState = signal(null); - const authUserState = signal(null); + authUserState = signal(null); const isAuthenticatedState = signal(false); + checkoutServiceStub = { + startCheckout: vi.fn().mockResolvedValue({ id: 55 }), + }; await TestBed.configureTestingModule({ imports: [StoreLayoutComponent], @@ -156,6 +162,7 @@ describe('StoreLayoutComponent', () => { .fn() .mockReturnValue(of({ data: { id: 1, items: [], subtotal: '0' } })), removeItem: vi.fn().mockReturnValue(of({ data: { id: 1, items: [], subtotal: '0' } })), + clearCart: vi.fn(() => cartState.set(null)), }, }, { @@ -176,6 +183,10 @@ describe('StoreLayoutComponent', () => { logout: vi.fn().mockReturnValue(of(void 0)), }, }, + { + provide: CheckoutService, + useValue: checkoutServiceStub, + }, ], }).compileComponents(); }); @@ -394,8 +405,9 @@ describe('StoreLayoutComponent', () => { expect(router.navigate).not.toHaveBeenCalled(); }); - it('logs out from the authenticated user dropdown', () => { + it('logs out from the authenticated user dropdown, clears the cart and returns home', () => { const authService = TestBed.inject(AuthService); + const cartService = TestBed.inject(CartService); const router = TestBed.inject(Router); vi.spyOn(router, 'navigate'); @@ -424,11 +436,13 @@ describe('StoreLayoutComponent', () => { fixture.detectChanges(); expect(authService.logout).toHaveBeenCalled(); - expect(router.navigate).toHaveBeenCalledWith(['/login']); + expect(cartService.clearCart).toHaveBeenCalled(); + expect(router.navigate).toHaveBeenCalledWith(['/']); }); it('provides account actions from the footer', () => { const authService = TestBed.inject(AuthService); + const cartService = TestBed.inject(CartService); const router = TestBed.inject(Router); vi.spyOn(router, 'navigate'); @@ -455,7 +469,28 @@ describe('StoreLayoutComponent', () => { fixture.detectChanges(); expect(authService.logout).toHaveBeenCalled(); - expect(router.navigate).toHaveBeenCalledWith(['/login']); + expect(cartService.clearCart).toHaveBeenCalled(); + expect(router.navigate).toHaveBeenCalledWith(['/']); + }); + + it('leaves checkout before logging out so the pending purchase can be cancelled', async () => { + const authService = TestBed.inject(AuthService); + const cartService = TestBed.inject(CartService); + const router = TestBed.inject(Router); + vi.spyOn(router, 'url', 'get').mockReturnValue('/checkout?purchase=25'); + const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true); + + const fixture = TestBed.createComponent(StoreLayoutComponent); + fixture.detectChanges(); + + await (fixture.componentInstance as any).onLogoutClick(); + + expect(navigateSpy).toHaveBeenCalledWith(['/']); + expect(authService.logout).toHaveBeenCalled(); + expect(navigateSpy.mock.invocationCallOrder[0]).toBeLessThan( + (authService.logout as any).mock.invocationCallOrder[0], + ); + expect(cartService.clearCart).toHaveBeenCalled(); }); it('renders only the help submenus assigned to the tenant in the footer', () => { @@ -481,7 +516,7 @@ describe('StoreLayoutComponent', () => { expect(compiled.textContent).not.toContain('Medios de pago'); }); - it('redirects to /checkout when cart buy button is clicked', () => { + it('creates a purchase and redirects to checkout when cart buy is clicked', async () => { const router = TestBed.inject(Router); vi.spyOn(router, 'navigate'); cartState.set({ @@ -503,23 +538,73 @@ describe('StoreLayoutComponent', () => { }, ], }); + authUserState.set({ + id: 7, + nombre_apellido: 'Juan Perez', + email: 'juan@example.com', + dni: '12345678', + telefono: '3415555555', + }); + + const fixture = TestBed.createComponent(StoreLayoutComponent); + fixture.detectChanges(); + + (fixture.componentInstance as any).isCartOpen.set(true); + fixture.detectChanges(); + + const compiled = fixture.nativeElement as HTMLElement; + const buyButton = Array.from(compiled.querySelectorAll('app-button button')).find( + (button) => button.textContent?.trim() === 'Comprar', + ) as HTMLButtonElement | undefined; + + expect(buyButton).toBeDefined(); + buyButton!.click(); + await Promise.resolve(); + fixture.detectChanges(); + + expect(checkoutServiceStub.startCheckout).toHaveBeenCalledWith('test', { + cart_id: 1, + }); + expect(router.navigate).toHaveBeenCalledWith(['/checkout'], { + queryParams: { purchase: 55 }, + }); + expect((fixture.componentInstance as any).isCartOpen()).toBe(false); + }); + + it('allows modifying quantities directly in the regular cart without a toggle', () => { + 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, + product: { + nombre: 'Producto', + imagen: null, + }, + }, + ], + }); const fixture = TestBed.createComponent(StoreLayoutComponent); fixture.detectChanges(); - (fixture.componentInstance as any).isCartOpen.set(true); fixture.detectChanges(); - const compiled = fixture.nativeElement as HTMLElement; - const buyButton = Array.from(compiled.querySelectorAll('app-button button')).find( - (button) => button.textContent?.trim() === 'Comprar', - ) as HTMLButtonElement | undefined; + const element = fixture.nativeElement as HTMLElement; + const cartItem = fixture.debugElement.query(By.css('app-cart-item')); + const buyButton = Array.from( + element.querySelectorAll('app-button button'), + ).find((button) => button.textContent?.trim() === 'Comprar'); + expect(element.querySelector('.cart-edit-btn')).toBeNull(); + expect(cartItem.componentInstance.quantityDisabled()).toBe(false); expect(buyButton).toBeDefined(); - buyButton!.click(); - fixture.detectChanges(); - - expect(router.navigate).toHaveBeenCalledWith(['/checkout']); - expect((fixture.componentInstance as any).isCartOpen()).toBe(false); }); }); diff --git a/src/app/core/layout/store-layout/store-layout.component.ts b/src/app/core/layout/store-layout/store-layout.component.ts index 5ff75f4..914b9a7 100644 --- a/src/app/core/layout/store-layout/store-layout.component.ts +++ b/src/app/core/layout/store-layout/store-layout.component.ts @@ -9,6 +9,8 @@ import { ButtonComponent } from '../../../shared/components/button/button.compon import { CartItem } from '../../services/cart/cart.interface'; import { AuthService } from '../../services/auth/auth.service'; import { findMenu } from '../../services/menu.utils'; +import { CheckoutService } from '../../services/checkout.service'; +import { ToastService } from '../../services/toast.service'; @Component({ selector: 'app-store-layout', @@ -26,9 +28,12 @@ export class StoreLayoutComponent implements OnInit { private readonly tenantService = inject(TenantService); private readonly cartService = inject(CartService); private readonly authService = inject(AuthService); + private readonly checkoutService = inject(CheckoutService); + private readonly toastService = inject(ToastService); private readonly router = inject(Router); protected readonly isCartOpen = signal(false); + protected readonly isCreatingPurchase = signal(false); protected readonly cartSubtotal = computed(() => { const cart = this.cartService.cart(); @@ -128,16 +133,69 @@ export class StoreLayoutComponent implements OnInit { } } - protected onLogoutClick(): void { + protected async onLogoutClick(): Promise { + const isLeavingCheckout = this.router.url.startsWith('/checkout'); + + // Checkout must be left while the authenticated session is still valid so + // its CanDeactivate guard can cancel the pending purchase. + if (isLeavingCheckout) { + const navigationSucceeded = await this.router.navigate(['/']); + + if (!navigationSucceeded) { + return; + } + } + this.authService.logout().subscribe({ - next: () => void this.router.navigate(['/login']), + next: () => { + this.cartService.clearCart(); + this.isCartOpen.set(false); + + if (!isLeavingCheckout) { + void this.router.navigate(['/']); + } + }, error: (err) => console.error('Error logging out', err), }); } - protected onCheckoutClick(): void { - this.isCartOpen.set(false); - void this.router.navigate(['/checkout']); + protected async onCheckoutClick(): Promise { + if (this.isCreatingPurchase()) { + return; + } + + const cart = this.cartService.cart(); + const tenant = this.tenantService.tenant(); + const user = this.authService.user(); + + if (!user) { + this.isCartOpen.set(false); + void this.router.navigate(['/login'], { queryParams: { returnUrl: '/' } }); + return; + } + + if (!cart?.id || !tenant) { + this.toastService.danger('No hay un carrito activo para iniciar la compra.'); + return; + } + + this.isCreatingPurchase.set(true); + try { + const purchase = await this.checkoutService.startCheckout(tenant.codigo, { + cart_id: cart.id, + }); + + this.cartService.clearCart(); + this.isCartOpen.set(false); + await this.router.navigate(['/checkout'], { + queryParams: { purchase: purchase.id }, + }); + } catch (error) { + console.error('Failed to create cart purchase:', error); + this.toastService.danger('No se pudo iniciar la compra.'); + } finally { + this.isCreatingPurchase.set(false); + } } protected readonly footerSections = computed(() => { diff --git a/src/app/core/services/auth/auth.guards.spec.ts b/src/app/core/services/auth/auth.guards.spec.ts index 318a54f..485a57a 100644 --- a/src/app/core/services/auth/auth.guards.spec.ts +++ b/src/app/core/services/auth/auth.guards.spec.ts @@ -23,10 +23,14 @@ describe('auth guards', () => { providers: [provideRouter([]), { provide: AuthService, useValue: createAuthServiceStub() }] }); - const result = TestBed.runInInjectionContext(() => authGuard(null as never, null as never)); + const result = TestBed.runInInjectionContext(() => + authGuard(null as never, { url: '/checkout?mode=direct' } as never), + ); expect(result instanceof UrlTree).toBe(true); - expect(TestBed.inject(Router).serializeUrl(result as UrlTree)).toBe('/login'); + expect(TestBed.inject(Router).serializeUrl(result as UrlTree)).toBe( + '/login?returnUrl=%2Fcheckout%3Fmode%3Ddirect', + ); }); it('allows authenticated users through authGuard', () => { @@ -34,7 +38,9 @@ describe('auth guards', () => { providers: [provideRouter([]), { provide: AuthService, useValue: createAuthServiceStub(true) }] }); - const result = TestBed.runInInjectionContext(() => authGuard(null as never, null as never)); + const result = TestBed.runInInjectionContext(() => + authGuard(null as never, { url: '/checkout' } as never), + ); expect(result).toBe(true); }); diff --git a/src/app/core/services/auth/auth.guards.ts b/src/app/core/services/auth/auth.guards.ts index c8ce0a2..538bdab 100644 --- a/src/app/core/services/auth/auth.guards.ts +++ b/src/app/core/services/auth/auth.guards.ts @@ -3,11 +3,15 @@ import { CanActivateFn, Router } from '@angular/router'; import { AuthService } from './auth.service'; -export const authGuard: CanActivateFn = () => { +export const authGuard: CanActivateFn = (_route, state) => { const authService = inject(AuthService); const router = inject(Router); - return authService.isAuthenticated() ? true : router.createUrlTree(['/login']); + return authService.isAuthenticated() + ? true + : router.createUrlTree(['/login'], { + queryParams: { returnUrl: state.url }, + }); }; export const guestOnlyGuard: CanActivateFn = () => { diff --git a/src/app/core/services/auth/auth.service.spec.ts b/src/app/core/services/auth/auth.service.spec.ts index 1feaa71..f9f6d3f 100644 --- a/src/app/core/services/auth/auth.service.spec.ts +++ b/src/app/core/services/auth/auth.service.spec.ts @@ -19,8 +19,12 @@ describe('AuthService', () => { function createCookieServiceStub() { return { get: (name: string) => cookieStore[name] || null, - set: (name: string, value: string) => { cookieStore[name] = value; }, - delete: (name: string) => { delete cookieStore[name]; } + set: (name: string, value: string) => { + cookieStore[name] = value; + }, + delete: (name: string) => { + delete cookieStore[name]; + } }; } @@ -31,6 +35,10 @@ describe('AuthService', () => { provideHttpClientTesting(), AuthService, { provide: CookieService, useValue: createCookieServiceStub() }, + { + provide: TenantService, + useValue: { getTenant: () => ({ codigo: 'tenant-test' }) } + }, TransferState ] }); @@ -44,6 +52,12 @@ describe('AuthService', () => { const request = httpController.expectOne(`${environment.url}login`); expect(request.request.method).toBe('POST'); + expect(request.request.body).toEqual({ + email: 'ada@example.com', + password: 'secret123', + tenant_codigo: 'tenant-test' + }); + expect(request.request.withCredentials).toBe(true); request.flush({ message: 'Sesion iniciada correctamente.', token: 'plain-text-token', @@ -96,6 +110,47 @@ describe('AuthService', () => { httpController.verify(); }); + it('sends tenant and credentials when completing Google login', () => { + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + AuthService, + { provide: CookieService, useValue: createCookieServiceStub() }, + { + provide: TenantService, + useValue: { getTenant: () => ({ codigo: 'tenant-test' }) } + }, + TransferState + ] + }); + + const service = TestBed.inject(AuthService); + const httpController = TestBed.inject(HttpTestingController); + + service.completeGoogleLogin('oauth-code').subscribe(); + + const request = httpController.expectOne(`${environment.url}auth/google/exchange`); + expect(request.request.method).toBe('POST'); + expect(request.request.body).toEqual({ + oauth_code: 'oauth-code', + tenant_codigo: 'tenant-test' + }); + expect(request.request.withCredentials).toBe(true); + request.flush({ + message: 'Sesion iniciada correctamente.', + token: 'google-token', + token_type: 'Bearer', + user: { + id: 1, + nombre_apellido: 'Ada Lovelace', + email: 'ada@example.com' + } + }); + + httpController.verify(); + }); + it('clears an expired session when /me returns 401', async () => { cookieStore['shopit.auth.token'] = 'expired-token'; @@ -233,9 +288,7 @@ describe('AuthService', () => { expect(response.body?.status).toBe('validated'); }); - const request = httpController.expectOne( - `${environment.url}password/reset-attempts/validate` - ); + const request = httpController.expectOne(`${environment.url}password/reset-attempts/validate`); expect(request.request.method).toBe('POST'); expect(request.request.body).toEqual({ email: 'ada@example.com', @@ -298,6 +351,10 @@ describe('AuthService', () => { provideHttpClientTesting(), AuthService, { provide: CookieService, useValue: createCookieServiceStub() }, + { + provide: TenantService, + useValue: { getTenant: () => ({ codigo: 'tenant-test' }) } + }, TransferState ] }); diff --git a/src/app/core/services/auth/auth.service.ts b/src/app/core/services/auth/auth.service.ts index 28ce48a..3fdc3af 100644 --- a/src/app/core/services/auth/auth.service.ts +++ b/src/app/core/services/auth/auth.service.ts @@ -42,10 +42,21 @@ export class AuthService { readonly isAuthenticated = computed(() => this.tokenState() !== null); login(payload: LoginPayload): Observable { - return this.http.post(`${environment.url}login`, payload).pipe( - tap((response) => this.applyAuthenticatedState(response.token, response.user)), - map((response) => response.user) - ); + const tenant = this.tenantService.getTenant(); + if (!tenant) { + throw new Error('No se pudo resolver el tenant activo.'); + } + + return this.http + .post( + `${environment.url}login`, + { ...payload, tenant_codigo: tenant.codigo }, + { withCredentials: true } + ) + .pipe( + tap((response) => this.applyAuthenticatedState(response.token, response.user)), + map((response) => response.user) + ); } register(payload: RegisterPayload): Observable { @@ -57,9 +68,7 @@ export class AuthService { }); } - requestPasswordReset( - email: string - ): Observable> { + requestPasswordReset(email: string): Observable> { const tenantCode = this.tenantService.getTenant()?.codigo; return this.http.post( @@ -83,14 +92,10 @@ export class AuthService { ); } - resetPassword( - payload: ResetPasswordPayload - ): Observable> { - return this.http.post( - `${environment.url}password/reset`, - payload, - { observe: 'response' } - ); + resetPassword(payload: ResetPasswordPayload): Observable> { + return this.http.post(`${environment.url}password/reset`, payload, { + observe: 'response' + }); } loginWithGoogle(): void { @@ -108,16 +113,25 @@ export class AuthService { } completeGoogleLogin(oauthCode: string): Observable { - return this.http.post(`${environment.url}auth/google/exchange`, { oauth_code: oauthCode }).pipe( - tap((response) => this.applyAuthenticatedState(response.token, response.user)), - map((response) => response.user) - ); + const tenant = this.tenantService.getTenant(); + if (!tenant) { + throw new Error('No se pudo resolver el tenant activo.'); + } + + return this.http + .post( + `${environment.url}auth/google/exchange`, + { oauth_code: oauthCode, tenant_codigo: tenant.codigo }, + { withCredentials: true } + ) + .pipe( + tap((response) => this.applyAuthenticatedState(response.token, response.user)), + map((response) => response.user) + ); } 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 { @@ -126,9 +140,7 @@ 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 { @@ -167,9 +179,7 @@ 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/checkout.service.ts b/src/app/core/services/checkout.service.ts index 609014a..ab22b4e 100644 --- a/src/app/core/services/checkout.service.ts +++ b/src/app/core/services/checkout.service.ts @@ -4,14 +4,25 @@ import { firstValueFrom } from 'rxjs'; import { environment } from '../../../environments/environment'; -export interface CreatePurchasePayload { - cart_id: number; +export interface UpdatePurchaseCustomerPayload { dni: string; telefono: string; nombre_apellido: string; email: string; } +export type StartCheckoutPayload = + | { + cart_id: number; + } + | { + direct_item: { + catalog_item_id: number; + variant_id: number | null; + cantidad: number; + }; + }; + export interface PurchaseStatusResponse { status: string | null; } @@ -62,17 +73,23 @@ export interface PurchaseDetailResponse extends PurchaseStatusResponse { } @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class CheckoutService { private readonly http = inject(HttpClient); - async createPurchase(tenantCode: string, payload: CreatePurchasePayload): Promise<{ id: number }> { + async startCheckout( + tenantCode: string, + payload: StartCheckoutPayload, + ): Promise { const response = await firstValueFrom( - this.http.post<{ data?: { id: number }; id?: number }>(`${environment.url}tenants/${tenantCode}/compras`, payload) + this.http.post<{ data?: PurchaseDetailResponse } | PurchaseDetailResponse>( + `${environment.url}tenants/${tenantCode}/compras/start-checkout`, + payload, + ), ); - const purchase = this.extractResponseData<{ id: number }>(response); + const purchase = this.extractResponseData(response); if (!purchase?.id) { throw new Error('Error al crear la compra.'); @@ -81,13 +98,21 @@ export class CheckoutService { return purchase; } - async generatePaymentIntent(tenantCode: string, purchaseId: number, method: 'qr' | 'transfer' | 'telepagos', payerDni?: string): Promise { + async generatePaymentIntent( + tenantCode: string, + purchaseId: number, + method: 'qr' | 'transfer' | 'telepagos', + payerDni?: string, + ): Promise { const payload: any = { method }; if (payerDni) { payload.transfer_payer_dni = payerDni; } const response = await firstValueFrom( - this.http.post(`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/payment-intent`, payload) + this.http.post( + `${environment.url}tenants/${tenantCode}/compras/${purchaseId}/payment-intent`, + payload, + ), ); if (!response) { throw new Error('Error al generar la intención de pago.'); @@ -95,9 +120,72 @@ export class CheckoutService { return response; } + async updateCustomerData( + tenantCode: string, + purchaseId: number, + payload: UpdatePurchaseCustomerPayload, + ): Promise { + const response = await firstValueFrom( + this.http.patch<{ data?: PurchaseDetailResponse } | PurchaseDetailResponse>( + `${environment.url}tenants/${tenantCode}/compras/${purchaseId}/customer-data`, + payload, + ), + ); + + const purchase = this.extractResponseData(response); + if (!purchase) { + throw new Error('Error al actualizar los datos de la compra.'); + } + + return purchase; + } + + async updateItemQuantity( + tenantCode: string, + purchaseId: number, + itemId: number, + quantity: number, + ): Promise { + const response = await firstValueFrom( + this.http.patch<{ data?: PurchaseDetailResponse } | PurchaseDetailResponse>( + `${environment.url}tenants/${tenantCode}/compras/${purchaseId}/items/${itemId}`, + { quantity }, + ), + ); + + const purchase = this.extractResponseData(response); + if (!purchase) { + throw new Error('Error al actualizar la cantidad del producto.'); + } + + return purchase; + } + + async prepareItemEditing( + tenantCode: string, + purchaseId: number, + ): Promise { + const response = await firstValueFrom( + this.http.post<{ data?: PurchaseDetailResponse } | PurchaseDetailResponse>( + `${environment.url}tenants/${tenantCode}/compras/${purchaseId}/edit-items`, + {}, + ), + ); + + const purchase = this.extractResponseData(response); + if (!purchase) { + throw new Error('Error al preparar la compra para editarla.'); + } + + return purchase; + } + async completePurchase(tenantCode: string, purchaseId: number): Promise { const response = await firstValueFrom( - this.http.post<{ data?: PurchaseStatusResponse } | PurchaseStatusResponse>(`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/complete`, {}) + this.http.post<{ data?: PurchaseStatusResponse } | PurchaseStatusResponse>( + `${environment.url}tenants/${tenantCode}/compras/${purchaseId}/complete`, + {}, + ), ); const purchase = this.extractResponseData(response); @@ -107,27 +195,49 @@ export class CheckoutService { } return { - status: purchase.status ?? null + status: purchase.status ?? null, }; } - async getPurchases(tenantCode: string, status?: string): Promise<{ data: PurchaseSummaryResponse[] }> { + async cancelPurchase(tenantCode: string, purchaseId: number): Promise { + const response = await firstValueFrom( + this.http.post<{ data?: PurchaseStatusResponse } | PurchaseStatusResponse>( + `${environment.url}tenants/${tenantCode}/compras/${purchaseId}/cancel`, + {}, + ), + ); + + const purchase = this.extractResponseData(response); + if (!purchase) { + throw new Error('Error al cancelar la compra.'); + } + + return { status: purchase.status ?? null }; + } + + async getPurchases( + tenantCode: string, + status?: string, + ): Promise<{ data: PurchaseSummaryResponse[] }> { let url = `${environment.url}tenants/${tenantCode}/compras`; 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.'); } return response; } - async getPurchase(tenantCode: string, purchaseId: string | number): Promise { + async getPurchase( + tenantCode: string, + purchaseId: string | number, + ): Promise { const response = await firstValueFrom( - this.http.get<{ data?: PurchaseDetailResponse } | PurchaseDetailResponse>(`${environment.url}tenants/${tenantCode}/compras/${purchaseId}`) + this.http.get<{ data?: PurchaseDetailResponse } | PurchaseDetailResponse>( + `${environment.url}tenants/${tenantCode}/compras/${purchaseId}`, + ), ); const purchase = this.extractResponseData(response); diff --git a/src/app/features/store/pages/checkout-page/checkout-page.component.html b/src/app/features/store/pages/checkout-page/checkout-page.component.html index 79ac560..30a3e1c 100644 --- a/src/app/features/store/pages/checkout-page/checkout-page.component.html +++ b/src/app/features/store/pages/checkout-page/checkout-page.component.html @@ -1,7 +1,11 @@ - -
-
- +
+
+ - -
- -
- -
+
+ + @if (isEditingItems()) { +
+

Terminá de modificar las cantidades para continuar con el pago.

+
+ } + +
+ +
+
diff --git a/src/app/features/store/pages/checkout-page/checkout-page.component.scss b/src/app/features/store/pages/checkout-page/checkout-page.component.scss index 402c27f..c06082e 100644 --- a/src/app/features/store/pages/checkout-page/checkout-page.component.scss +++ b/src/app/features/store/pages/checkout-page/checkout-page.component.scss @@ -12,6 +12,27 @@ min-width: 0; border-radius: 4px; min-height: 420px; + + &--editing { + display: none; + } + } + + &__editing-notice { + display: grid; + min-height: 420px; + place-items: center; + padding: 2rem; + border-radius: 4px; + background: #f5f5f5; + color: #666666; + text-align: center; + + p { + max-width: 360px; + margin: 0; + font-size: 14px; + } } &__cart-col { 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 55be767..0f35f4d 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 @@ -1,18 +1,24 @@ import { signal } from '@angular/core'; import { getTestBed, TestBed } from '@angular/core/testing'; import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing'; -import { Router } from '@angular/router'; +import { ActivatedRoute, convertToParamMap, Router } from '@angular/router'; import { of } from 'rxjs'; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { AuthService } from '../../../../core/services/auth/auth.service'; import { CartService } from '../../../../core/services/cart/cart.service'; import { CheckoutService } from '../../../../core/services/checkout.service'; +import { CatalogService } from '../../../../core/services/catalog/catalog.service'; import { TenantService } from '../../../../core/services/tenant.service'; import { CheckoutPageComponent } from './checkout-page.component'; describe('CheckoutPageComponent payment validation', () => { let checkoutServiceStub: { + startCheckout: ReturnType; + updateCustomerData: ReturnType; + updateItemQuantity: ReturnType; + prepareItemEditing: ReturnType; + cancelPurchase: ReturnType; generatePaymentIntent: ReturnType; getPurchase: ReturnType; }; @@ -23,6 +29,8 @@ describe('CheckoutPageComponent payment validation', () => { clearCart: ReturnType; }; let routerStub: { navigate: ReturnType }; + let routeQueryParamMap: ReturnType; + let authUserState: ReturnType; beforeAll(() => { try { @@ -36,6 +44,16 @@ describe('CheckoutPageComponent payment validation', () => { vi.useFakeTimers(); checkoutServiceStub = { + startCheckout: vi.fn().mockResolvedValue({ + id: 25, + items: [], + subtotal: '0.00', + total: '0.00', + }), + updateCustomerData: vi.fn(), + updateItemQuantity: vi.fn(), + prepareItemEditing: vi.fn(), + cancelPurchase: vi.fn().mockResolvedValue({ status: 'cancelled' }), generatePaymentIntent: vi.fn().mockResolvedValue({ qr_data: { qr_code: 'qr-value' }, }), @@ -63,14 +81,27 @@ describe('CheckoutPageComponent payment validation', () => { }); }); routerStub = { navigate: vi.fn() }; + routeQueryParamMap = convertToParamMap({}); + authUserState = signal(null); await TestBed.configureTestingModule({ imports: [CheckoutPageComponent], providers: [ { provide: CheckoutService, useValue: checkoutServiceStub }, + { provide: CatalogService, useValue: { getCatalogItem: vi.fn() } }, { provide: CartService, useValue: cartServiceStub }, { provide: TenantService, useValue: { tenant: signal({ codigo: 'tenant-test' }) } }, - { provide: AuthService, useValue: { user: signal(null) } }, + { provide: AuthService, useValue: { user: authUserState } }, + { + provide: ActivatedRoute, + useValue: { + snapshot: { + get queryParamMap() { + return routeQueryParamMap; + }, + }, + }, + }, { provide: Router, useValue: routerStub }, ], }) @@ -87,6 +118,7 @@ describe('CheckoutPageComponent payment validation', () => { const fixture = TestBed.createComponent(CheckoutPageComponent); fixture.detectChanges(); fixture.componentInstance['createdPurchaseId'].set(25); + routerStub.navigate.mockClear(); return { fixture, component: fixture.componentInstance as any }; } @@ -105,7 +137,7 @@ describe('CheckoutPageComponent payment validation', () => { await vi.advanceTimersByTimeAsync(5_000); expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(2); - expect(cartServiceStub.clearCart).toHaveBeenCalledOnce(); + expect(cartServiceStub.clearCart).not.toHaveBeenCalled(); expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25]); expect(checkoutServiceStub.generatePaymentIntent).toHaveBeenCalledTimes(1); }); @@ -181,7 +213,7 @@ describe('CheckoutPageComponent payment validation', () => { await component.onComplete(); - expect(cartServiceStub.clearCart).toHaveBeenCalledOnce(); + expect(cartServiceStub.clearCart).not.toHaveBeenCalled(); expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25]); }); @@ -197,4 +229,175 @@ describe('CheckoutPageComponent payment validation', () => { expect(cartServiceStub.clearCart).not.toHaveBeenCalled(); expect(routerStub.navigate).not.toHaveBeenCalled(); }); + + it('loads purchase items but prefills customer data from the user', async () => { + const purchase = { + id: 25, + cart_id: null, + nombre_apellido: 'Datos de la compra', + email: 'compra@example.com', + dni: '11111111', + telefono: '1111111111', + items: [ + { + id: 91, + quantity: 2, + unit_price: '1250.50', + line_total: '2501.00', + source_catalog_item_id: 8, + source_variant_id: 21, + item_details: { + nombre: 'Remera', + descripcion: null, + slug: 'remera', + imagen: 'https://example.com/remera.jpg', + attributes: [{ name: 'Color', value: 'Negro' }], + }, + }, + ], + subtotal: '2501.00', + total: '2501.00', + }; + routeQueryParamMap = convertToParamMap({ purchase: 25 }); + checkoutServiceStub.getPurchase.mockResolvedValue(purchase); + authUserState.set({ + id: 7, + nombre_apellido: 'Juan Perez', + email: 'juan@example.com', + dni: '12345678', + telefono: '3415555555', + }); + const { component } = createComponent(); + await Promise.resolve(); + + expect(checkoutServiceStub.getPurchase).toHaveBeenCalledWith('tenant-test', 25); + expect(component.mappedCartItems()).toEqual([ + { + cartItemId: 91, + imageUrl: 'https://example.com/remera.jpg', + product: 'Remera', + originalPrice: null, + discountedPrice: 1250.5, + discountPercentage: null, + attributes: [{ label: 'Color', value: 'Negro' }], + quantity: 2, + }, + ]); + expect(component.cartSubtotal()).toBe(2501); + expect(component.cartTotal()).toBe(2501); + expect(component.form.getRawValue()).toEqual({ + nombre: 'Juan Perez', + email: 'juan@example.com', + dni: '12345678', + telefono: '3415555555', + }); + }); + + it('updates a purchase item while editing and refreshes checkout totals', async () => { + const updatedPurchase = { + id: 25, + items: [], + subtotal: '300.00', + total: '300.00', + }; + checkoutServiceStub.updateItemQuantity.mockResolvedValue(updatedPurchase); + const { component } = createComponent(); + component.isEditingItems.set(true); + + await component.onPurchaseItemQuantityChange({ + item: { + cartItemId: 91, + imageUrl: null, + product: 'Remera', + originalPrice: null, + discountedPrice: 100, + discountPercentage: null, + attributes: [], + quantity: 2, + }, + quantity: 3, + }); + + expect(checkoutServiceStub.updateItemQuantity).toHaveBeenCalledWith( + 'tenant-test', + 25, + 91, + 3, + ); + expect(component.createdPurchase()).toBe(updatedPurchase); + expect(component.isUpdatingItem()).toBe(false); + }); + + it('keeps the payment step selected while editing and regenerates payment afterward', async () => { + const editablePurchase = { + id: 25, + status: 'created', + items: [], + subtotal: '100.00', + total: '100.00', + }; + checkoutServiceStub.prepareItemEditing.mockResolvedValue(editablePurchase); + const { component } = createComponent(); + component.stepper = { currentStepIndex: signal(1) }; + const selectPaymentMethod = vi + .spyOn(component, 'selectPaymentMethod') + .mockResolvedValue(undefined); + + await component.onEditingItemsChange(true); + + expect(checkoutServiceStub.prepareItemEditing).toHaveBeenCalledWith('tenant-test', 25); + expect(component.isEditingItems()).toBe(true); + expect(component.createdPurchase()).toBe(editablePurchase); + + await component.onEditingItemsChange(false); + + expect(component.stepper.currentStepIndex()).toBe(1); + expect(selectPaymentMethod).toHaveBeenCalledWith('qr'); + expect(component.isEditingItems()).toBe(false); + }); + + it('updates customer data on the existing purchase before payment', async () => { + const updatedPurchase = { + id: 25, + cart_id: 10, + items: [], + subtotal: '100.00', + total: '100.00', + }; + checkoutServiceStub.updateCustomerData.mockResolvedValue(updatedPurchase); + const { component } = createComponent(); + component.form.setValue({ + nombre: 'Juan Perez', + email: 'juan@example.com', + dni: '12345678', + telefono: '3415555555', + }); + component.stepper = { next: vi.fn() }; + + await component.onStep1Continue(); + + expect(checkoutServiceStub.startCheckout).not.toHaveBeenCalled(); + expect(checkoutServiceStub.updateCustomerData).toHaveBeenCalledWith('tenant-test', 25, { + dni: '12345678', + telefono: '3415555555', + email: 'juan@example.com', + nombre_apellido: 'Juan Perez', + }); + expect(component.createdPurchase()).toEqual({ + ...updatedPurchase, + status: 'pending_payment', + }); + expect(component.stepper.next).toHaveBeenCalledOnce(); + }); + + it('cancels the pending purchase before allowing navigation away', async () => { + const { component } = createComponent(); + + await expect(component.canDeactivate()).resolves.toBe(true); + + expect(checkoutServiceStub.cancelPurchase).toHaveBeenCalledWith('tenant-test', 25); + expect(cartServiceStub.loadCart).toHaveBeenCalled(); + expect(cartServiceStub.clearCart).not.toHaveBeenCalled(); + expect(component.createdPurchaseId()).toBeNull(); + }); }); 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 19f4c69..861270a 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 @@ -2,24 +2,25 @@ import { ChangeDetectionStrategy, Component, computed, - effect, inject, OnDestroy, OnInit, signal, - untracked, ViewChild, } from '@angular/core'; import { FormBuilder, Validators } from '@angular/forms'; -import { Router } from '@angular/router'; -import { startWith } from 'rxjs'; +import { ActivatedRoute, Router } from '@angular/router'; +import { firstValueFrom, startWith } from 'rxjs'; -import { CartService } from '../../../../core/services/cart/cart.service'; import { TenantService } from '../../../../core/services/tenant.service'; -import { CheckoutService, CreatePurchasePayload } from '../../../../core/services/checkout.service'; +import { + CheckoutService, + PurchaseDetailItemResponse, + PurchaseDetailResponse, +} from '../../../../core/services/checkout.service'; import { AuthService } from '../../../../core/services/auth/auth.service'; +import { CartService } from '../../../../core/services/cart/cart.service'; import { BankAccount } from '../../../../core/services/tenant.interface'; -import { CartItem } from '../../../../core/services/cart/cart.interface'; import { CartComponent, CartItemMock } from '../../../../shared/components/cart/cart.component'; import { StepComponent } from '../../../../shared/components/stepper/step.component'; import { StepperComponent } from '../../../../shared/components/stepper/stepper.component'; @@ -51,11 +52,12 @@ import { }) export class CheckoutPageComponent implements OnInit, OnDestroy { private readonly formBuilder = inject(FormBuilder); - private readonly cartService = inject(CartService); + private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly tenantService = inject(TenantService); private readonly checkoutService = inject(CheckoutService); private readonly authService = inject(AuthService); + private readonly cartService = inject(CartService); private readonly qrPollingIntervalMs = 5_000; private readonly qrPollingMaxAttempts = 60; @@ -74,19 +76,23 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { telefono: ['', [Validators.required]], }); + protected readonly createdPurchase = signal(null); + protected readonly cartSubtotal = computed(() => { - const cart = this.cartService.cart(); - return cart ? parseFloat(cart.subtotal) : 0; + const purchase = this.createdPurchase(); + return purchase ? parseFloat(purchase.subtotal) : 0; }); protected readonly cartDiscount = computed(() => 0); - protected readonly cartTotal = computed(() => this.cartSubtotal() - this.cartDiscount()); + protected readonly cartTotal = computed(() => { + const purchase = this.createdPurchase(); + return purchase ? parseFloat(purchase.total) : this.cartSubtotal() - this.cartDiscount(); + }); protected readonly mappedCartItems = computed(() => { - const cart = this.cartService.cart(); - if (!cart || !cart.items) return []; - return cart.items.map((item) => this.mapCartItemToMock(item)); + const purchase = this.createdPurchase(); + return purchase ? purchase.items.map((item) => this.mapPurchaseItemToMock(item)) : []; }); protected readonly isStep1Valid = signal(this.form.valid); @@ -100,32 +106,19 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { protected readonly transferAccount = signal(null); protected readonly transferDni = signal(''); - protected readonly isCreatingPurchase = signal(false); + protected readonly isUpdatingPurchase = signal(false); + protected readonly isEditingItems = signal(false); + protected readonly isUpdatingItem = signal(false); + protected readonly isPreparingItemEdit = signal(false); protected readonly createdPurchaseId = signal(null); protected readonly isGeneratingIntent = signal(false); - protected readonly isPaymentLoading = computed( - () => this.isGeneratingIntent() || this.cartService.isUpdating(), - ); + protected readonly isPaymentLoading = computed(() => this.isGeneratingIntent()); protected readonly qrData = signal(null); protected readonly qrPaymentStatus = signal('idle'); protected readonly isCheckingQrPayment = signal(false); protected readonly transferValidationStatus = signal('idle'); constructor() { - effect(() => { - // We only want to trigger the intent generation when the cart changes. - // So we track the cart, but untrack the other signals to prevent duplicate calls. - const cart = this.cartService.cart(); - - untracked(() => { - const purchaseId = this.createdPurchaseId(); - if (cart && purchaseId && !this.navigationStarted) { - // Trigger payment intent generation when cart changes and we are on the payment step - void this.selectPaymentMethod(this.selectedPaymentMethod()); - } - }); - }); - this.form.statusChanges .pipe(startWith(this.form.status)) .subscribe(() => this.isStep1Valid.set(this.form.valid)); @@ -144,68 +137,126 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { } ngOnInit(): void { - this.cartService.loadCart().subscribe(); + const purchaseId = Number(this.route.snapshot.queryParamMap.get('purchase')); + if (!Number.isInteger(purchaseId) || purchaseId <= 0) { + void this.router.navigate(['/']); + return; + } + + void this.loadPurchase(purchaseId); } ngOnDestroy(): void { this.stopQrPolling(); } - private mapCartItemToMock(item: CartItem): CartItemMock { - const fullName = item.product?.nombre ?? ''; - let product = fullName; - let attributes: { label: string; value: string }[] = []; - - const match = fullName.match(/^(.*?)\s*\((.*?)\)$/); - if (match) { - product = match[1]; - const attributesString = match[2]; - attributes = attributesString.split(',').map((attr) => { - const parts = attr.split(':'); - if (parts.length === 2) { - return { label: parts[0].trim(), value: parts[1].trim() }; - } - return { label: '', value: attr.trim() }; - }); - } - + private mapPurchaseItemToMock(item: PurchaseDetailItemResponse): CartItemMock { return { cartItemId: item.id, - imageUrl: item.product?.imagen ?? null, - product, + imageUrl: item.item_details.imagen, + product: item.item_details.nombre, originalPrice: null, - discountedPrice: parseFloat(item.precio_unitario), + discountedPrice: parseFloat(item.unit_price), discountPercentage: null, - attributes, - quantity: item.cantidad, + attributes: item.item_details.attributes.map((attribute) => ({ + label: attribute.name, + value: attribute.value === null ? '' : String(attribute.value), + })), + quantity: item.quantity, }; } - protected async onStep1Continue(): Promise { - if (this.form.invalid) return; + protected async onEditingItemsChange(editing: boolean): Promise { + if (this.isUpdatingItem() || this.isPreparingItemEdit()) { + return; + } - const tenant = this.tenantService.tenant(); - if (!tenant) return; + if (!editing) { + this.isEditingItems.set(false); - this.isCreatingPurchase.set(true); - - try { - const cart = this.cartService.cart(); - if (!cart?.id) { - throw new Error('No hay un carrito activo para finalizar la compra.'); + if (this.stepper?.currentStepIndex() === 1) { + void this.selectPaymentMethod(this.selectedPaymentMethod()); } + return; + } + + this.isEditingItems.set(true); + this.stopQrPolling(); + this.qrData.set(null); + this.qrPaymentStatus.set('idle'); + this.transferAccount.set(null); + this.transferValidationStatus.set('idle'); + + const tenant = this.tenantService.tenant(); + const purchaseId = this.createdPurchaseId(); + + if (!tenant || !purchaseId) { + this.isEditingItems.set(false); + return; + } + + this.isPreparingItemEdit.set(true); + try { + const purchase = await this.checkoutService.prepareItemEditing( + tenant.codigo, + purchaseId, + ); + this.createdPurchase.set(purchase); + } catch (error) { + console.error('Failed to prepare purchase item editing:', error); + this.isEditingItems.set(false); + } finally { + this.isPreparingItemEdit.set(false); + } + } + + protected async onPurchaseItemQuantityChange(event: { + item: CartItemMock; + quantity: number; + }): Promise { + const tenant = this.tenantService.tenant(); + const purchaseId = this.createdPurchaseId(); + const itemId = event.item.cartItemId; + + if (!tenant || !purchaseId || !itemId || this.isUpdatingItem() || !this.isEditingItems()) { + return; + } + + this.isUpdatingItem.set(true); + try { + const purchase = await this.checkoutService.updateItemQuantity( + tenant.codigo, + purchaseId, + itemId, + event.quantity, + ); + this.createdPurchase.set(purchase); + } catch (error) { + console.error('Failed to update purchase item quantity:', error); + } finally { + this.isUpdatingItem.set(false); + } + } + + protected async onStep1Continue(): Promise { + if (this.form.invalid || this.isUpdatingPurchase() || this.isEditingItems()) return; + + const tenant = this.tenantService.tenant(); + const purchaseId = this.createdPurchaseId(); + if (!tenant || !purchaseId) return; + + this.isUpdatingPurchase.set(true); + + try { const formValue = this.form.getRawValue(); - const payload: CreatePurchasePayload = { - cart_id: cart.id, + const purchase = await this.checkoutService.updateCustomerData(tenant.codigo, purchaseId, { dni: formValue.dni, telefono: formValue.telefono, email: formValue.email, nombre_apellido: formValue.nombre, - }; - - const response = await this.checkoutService.createPurchase(tenant.codigo, payload); - this.createdPurchaseId.set(response.id); + }); + this.createdPurchase.set(purchase); this.stepper.next(); @@ -215,17 +266,50 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { console.error('Failed to create purchase:', error); // Here we could show an alert or toast } finally { - this.isCreatingPurchase.set(false); + this.isUpdatingPurchase.set(false); } } - protected onCancel(): void { + protected async onCancel(): Promise { + if (await this.canDeactivate()) { + void this.router.navigate(['/']); + } + } + + public async canDeactivate(): Promise { this.stopQrPolling(); - void this.router.navigate(['/']); + + if (this.navigationStarted) { + return true; + } + + const purchaseId = this.createdPurchaseId(); + const tenant = this.tenantService.tenant(); + + if (!purchaseId || !tenant) { + return true; + } + + try { + await this.checkoutService.cancelPurchase(tenant.codigo, purchaseId); + this.createdPurchaseId.set(null); + this.createdPurchase.set(null); + + try { + await firstValueFrom(this.cartService.loadCart()); + } catch (error) { + console.error('Failed to restore cart after cancelling checkout:', error); + } + + return true; + } catch (error) { + console.error('Failed to cancel purchase:', error); + return false; + } } protected async selectPaymentMethod(method: PaymentMethod): Promise { - if (this.navigationStarted) { + if (this.navigationStarted || this.isEditingItems()) { return; } @@ -266,6 +350,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { } this.qrData.set(response.qr_data.qr_code); + this.markPurchasePendingPayment(); this.startQrPolling(); } } catch (error) { @@ -276,6 +361,10 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { } protected async generateTransferIntent(dni: string): Promise { + if (this.isEditingItems()) { + return; + } + const purchaseId = this.createdPurchaseId(); const tenant = this.tenantService.tenant(); @@ -291,10 +380,11 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { tenant.codigo, purchaseId, 'transfer', - dni + dni, ); if (response.transfer_data) { + this.markPurchasePendingPayment(); this.transferAccount.set({ titular: response.transfer_data.titular, entidad: response.transfer_data.entidad, @@ -334,6 +424,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { !purchaseId || !tenant || this.navigationStarted || + this.isEditingItems() || this.transferValidationStatus() === 'checking' ) { return; @@ -448,11 +539,22 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { } } - private handleConfirmedPayment(purchaseId: number): void { - this.navigateToPurchaseStatus(purchaseId, true); + private markPurchasePendingPayment(): void { + this.createdPurchase.update((purchase) => + purchase + ? { + ...purchase, + status: 'pending_payment', + } + : purchase, + ); } - private navigateToPurchaseStatus(purchaseId: number, clearCart = false): void { + private handleConfirmedPayment(purchaseId: number): void { + this.navigateToPurchaseStatus(purchaseId); + } + + private navigateToPurchaseStatus(purchaseId: number): void { if (this.navigationStarted) { return; } @@ -460,10 +562,23 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { this.navigationStarted = true; this.stopQrPolling(); - if (clearCart) { - this.cartService.clearCart(); - } - void this.router.navigate(['/checkout/status', purchaseId]); } + + private async loadPurchase(purchaseId: number): Promise { + const tenant = this.tenantService.tenant(); + if (!tenant) { + void this.router.navigate(['/']); + return; + } + + try { + const purchase = await this.checkoutService.getPurchase(tenant.codigo, purchaseId); + this.createdPurchaseId.set(purchase.id); + this.createdPurchase.set(purchase); + } catch (error) { + console.error('Failed to load purchase:', error); + void this.router.navigate(['/']); + } + } } diff --git a/src/app/features/store/pages/checkout-page/checkout-page.guard.ts b/src/app/features/store/pages/checkout-page/checkout-page.guard.ts new file mode 100644 index 0000000..955e7d4 --- /dev/null +++ b/src/app/features/store/pages/checkout-page/checkout-page.guard.ts @@ -0,0 +1,8 @@ +import { CanDeactivateFn } from '@angular/router'; + +interface PendingCheckoutComponent { + canDeactivate(): Promise; +} + +export const checkoutPendingPurchaseGuard: CanDeactivateFn = (component) => + component.canDeactivate(); diff --git a/src/app/features/store/pages/login-page/login-page.component.ts b/src/app/features/store/pages/login-page/login-page.component.ts index 31588d2..d573af4 100644 --- a/src/app/features/store/pages/login-page/login-page.component.ts +++ b/src/app/features/store/pages/login-page/login-page.component.ts @@ -9,6 +9,7 @@ import { InputComponent } from '../../../../shared/components/input/input.compon const PASSWORD_MIN_LENGTH = 8; const EMAIL_MAX_LENGTH = 255; +const POST_LOGIN_RETURN_URL_KEY = 'shopit.auth.return-url'; @Component({ selector: 'app-login-page', @@ -84,6 +85,10 @@ export class LoginPageComponent { this.serverErrorState.set(null); try { + const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl'); + if (returnUrl?.startsWith('/') && !returnUrl.startsWith('//')) { + this.document.defaultView?.sessionStorage.setItem(POST_LOGIN_RETURN_URL_KEY, returnUrl); + } this.authService.loginWithGoogle(); } catch (error: unknown) { this.serverErrorState.set(this.resolveErrorMessage(error)); @@ -131,8 +136,16 @@ export class LoginPageComponent { } protected redirectToHome(): void { - const homeUrl = this.router.serializeUrl(this.router.createUrlTree(['/'])); - this.document.location.assign(homeUrl); + const requestedUrl = + this.route.snapshot.queryParamMap.get('returnUrl') ?? + this.document.defaultView?.sessionStorage.getItem(POST_LOGIN_RETURN_URL_KEY); + const destination = + requestedUrl?.startsWith('/') && !requestedUrl.startsWith('//') + ? requestedUrl + : this.router.serializeUrl(this.router.createUrlTree(['/'])); + + this.document.defaultView?.sessionStorage.removeItem(POST_LOGIN_RETURN_URL_KEY); + this.document.location.assign(destination); } private completeGoogleLogin(oauthCode: string): void { diff --git a/src/app/features/store/pages/product-detail-page/product-detail-page.component.html b/src/app/features/store/pages/product-detail-page/product-detail-page.component.html index 8ee711b..1d18081 100644 --- a/src/app/features/store/pages/product-detail-page/product-detail-page.component.html +++ b/src/app/features/store/pages/product-detail-page/product-detail-page.component.html @@ -73,9 +73,12 @@ - @if (variantLoading()) { + @if (variantLoading() || creatingDirectPurchase()) {
} @else { Comprar 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 d9cb4be..c58e42a 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 @@ -1,4 +1,5 @@ import { TestBed, getTestBed } from '@angular/core/testing'; +import { signal } from '@angular/core'; import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing'; import { ActivatedRoute, Router } from '@angular/router'; import { By } from '@angular/platform-browser'; @@ -10,6 +11,9 @@ import { CatalogService } from '../../../../core/services/catalog/catalog.servic import { CartService } from '../../../../core/services/cart/cart.service'; import { ToastService } from '../../../../core/services/toast.service'; import { ProductDetailPageComponent } from './product-detail-page.component'; +import { CheckoutService } from '../../../../core/services/checkout.service'; +import { TenantService } from '../../../../core/services/tenant.service'; +import { AuthService } from '../../../../core/services/auth/auth.service'; import { PRODUCT_DETAIL_ERROR_MESSAGE, ProductDetailResolvedData, @@ -41,6 +45,7 @@ describe('ProductDetailPageComponent', () => { let routerStub: any; let cartServiceStub: any; let toastServiceStub: any; + let checkoutServiceStub: any; beforeAll(() => { try { @@ -72,6 +77,9 @@ describe('ProductDetailPageComponent', () => { danger: vi.fn(), info: vi.fn(), }; + checkoutServiceStub = { + startCheckout: vi.fn().mockResolvedValue({ id: 44 }), + }; }); async function configureTestingModule() { @@ -100,6 +108,26 @@ describe('ProductDetailPageComponent', () => { provide: ToastService, useValue: toastServiceStub, }, + { + provide: CheckoutService, + useValue: checkoutServiceStub, + }, + { + provide: TenantService, + useValue: { tenant: signal({ codigo: 'tenant-test' }) }, + }, + { + provide: AuthService, + useValue: { + user: signal({ + id: 7, + nombre_apellido: 'Juan Perez', + email: 'juan@example.com', + dni: '12345678', + telefono: '3415555555', + }), + }, + }, ], }).compileComponents(); } @@ -364,7 +392,7 @@ describe('ProductDetailPageComponent', () => { expect(toggleButton.textContent?.trim()).toBe('Mostrar menos'); }); - it('renders purchase actions without side effects', async () => { + it('renders purchase actions and starts a direct checkout', async () => { await configureTestingModule(); const fixture = TestBed.createComponent(ProductDetailPageComponent); fixture.detectChanges(); @@ -380,8 +408,18 @@ describe('ProductDetailPageComponent', () => { buttons[0].click(); buttons[1].click(); + await Promise.resolve(); - expect(routerStub.navigate).not.toHaveBeenCalled(); + expect(checkoutServiceStub.startCheckout).toHaveBeenCalledWith('tenant-test', { + direct_item: { + catalog_item_id: 1, + variant_id: null, + cantidad: 1, + }, + }); + expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout'], { + queryParams: { purchase: 44 }, + }); }); it('calls CartService.addItem when variant is selected and Add to Cart is clicked', async () => { 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 0335823..d520de8 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 @@ -28,6 +28,9 @@ import { ButtonComponent } from '../../../../shared/components/button/button.com import { ProductAttributeSelectorComponent } from '../../components/product-attribute-selector/product-attribute-selector.component'; import { QuantitySelectorComponent } from '../../../../shared/components/quantity-selector/quantity-selector.component'; import { ProductDetailResolvedData } from './product-detail-page.resolver'; +import { CheckoutService } from '../../../../core/services/checkout.service'; +import { TenantService } from '../../../../core/services/tenant.service'; +import { AuthService } from '../../../../core/services/auth/auth.service'; @Component({ selector: 'app-product-detail-page', @@ -50,6 +53,9 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy { private readonly catalogService = inject(CatalogService); private readonly toastService = inject(ToastService); private readonly cartService = inject(CartService); + private readonly checkoutService = inject(CheckoutService); + private readonly tenantService = inject(TenantService); + private readonly authService = inject(AuthService); private readonly attributeSelector = viewChild(ProductAttributeSelectorComponent); private readonly carouselHost = viewChild>('carouselHost'); private readonly descriptionBody = viewChild>('descriptionBody'); @@ -77,6 +83,7 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy { protected readonly loading = signal(false); protected readonly variantLoading = signal(false); protected readonly addingToCart = signal(false); + protected readonly creatingDirectPurchase = signal(false); protected readonly error = signal(null); protected readonly selectedVariant = signal(null); protected readonly quantity = signal(1); @@ -253,6 +260,55 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy { }); } + protected async buyNow(): Promise { + const currentProduct = this.product(); + const variant = this.selectedVariant(); + + if (this.creatingDirectPurchase()) { + return; + } + + if (!currentProduct || !this.selectedVariantAvailable()) { + this.toastService.danger('Por favor, selecciona una variante.'); + return; + } + + const tenant = this.tenantService.tenant(); + const user = this.authService.user(); + + if (!user) { + void this.router.navigate(['/login'], { + queryParams: { returnUrl: `/producto/${currentProduct.id}` }, + }); + return; + } + + if (!tenant) { + this.toastService.danger('No se pudo identificar la tienda.'); + return; + } + + this.creatingDirectPurchase.set(true); + try { + const purchase = await this.checkoutService.startCheckout(tenant.codigo, { + direct_item: { + catalog_item_id: currentProduct.id, + variant_id: variant?.id ?? null, + cantidad: this.quantity(), + }, + }); + + await this.router.navigate(['/checkout'], { + queryParams: { purchase: purchase.id }, + }); + } catch (error) { + console.error('Failed to create direct purchase:', error); + this.toastService.danger('No se pudo iniciar la compra directa.'); + } finally { + this.creatingDirectPurchase.set(false); + } + } + private isVariantAvailable(variant: CatalogItemVariant, product: CatalogItemDetail): boolean { return product.inventory_policy === 'unlimited' || (variant.stock_tecnico ?? 0) > 0; } diff --git a/src/app/features/store/pages/purchase-status-page/purchase-status-page.component.html b/src/app/features/store/pages/purchase-status-page/purchase-status-page.component.html index 4ee4d66..cb32afd 100644 --- a/src/app/features/store/pages/purchase-status-page/purchase-status-page.component.html +++ b/src/app/features/store/pages/purchase-status-page/purchase-status-page.component.html @@ -57,6 +57,20 @@ Actualizar estado
+ } @else if (status() === 'expired') { +
+
+ +
+

LA COMPRA VENCIÓ

+

El plazo de pago terminó y liberamos el stock reservado.

+
+ +
+ +
+

Podés volver a la tienda e iniciar una nueva compra.

+
} @else if (status() === 'rejected') {
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 4828430..90b3923 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 @@ -5,7 +5,7 @@ import { CheckoutService, PurchaseStatusResponse } from '../../../../core/servic import { TenantService } from '../../../../core/services/tenant.service'; import { ButtonComponent } from '../../../../shared/components/button/button.component'; -type PurchaseStatusView = 'approved' | 'pending' | 'rejected' | 'error'; +type PurchaseStatusView = 'approved' | 'pending' | 'rejected' | 'expired' | 'error'; @Component({ selector: 'app-purchase-status-page', @@ -93,6 +93,10 @@ export class PurchaseStatusPageComponent implements OnInit, OnDestroy { return 'rejected'; } + if (purchase.status === 'expired') { + return 'expired'; + } + return 'pending'; } diff --git a/src/app/features/store/store.routes.ts b/src/app/features/store/store.routes.ts index f360335..3195142 100644 --- a/src/app/features/store/store.routes.ts +++ b/src/app/features/store/store.routes.ts @@ -12,6 +12,7 @@ import { ResetPasswordPageComponent } from './pages/reset-password-page/reset-pa import { RegisterPageComponent } from './pages/register-page/register-page.component'; import { StoreHomePageComponent } from './pages/store-home-page/store-home-page.component'; import { storeHomeCatalogResolver } from './pages/store-home-page/store-home-page.resolver'; +import { checkoutPendingPurchaseGuard } from './pages/checkout-page/checkout-page.guard'; export const routes: Routes = [ { @@ -86,6 +87,7 @@ export const routes: Routes = [ { path: 'checkout', canActivate: [authGuard, hasMenuGuard('checkout')], + canDeactivate: [checkoutPendingPurchaseGuard], loadComponent: () => import('./pages/checkout-page/checkout-page.component').then( (m) => m.CheckoutPageComponent, diff --git a/src/app/shared/components/cart-item/cart-item.component.html b/src/app/shared/components/cart-item/cart-item.component.html index 78aadca..d425ffe 100644 --- a/src/app/shared/components/cart-item/cart-item.component.html +++ b/src/app/shared/components/cart-item/cart-item.component.html @@ -34,20 +34,25 @@ }
-
- - -
+ @if (!readonly()) { +
+ + @if (!quantityDisabled() && showRemove()) { + + } +
+ }
diff --git a/src/app/shared/components/cart-item/cart-item.component.ts b/src/app/shared/components/cart-item/cart-item.component.ts index 15921e1..7c752fe 100644 --- a/src/app/shared/components/cart-item/cart-item.component.ts +++ b/src/app/shared/components/cart-item/cart-item.component.ts @@ -23,6 +23,9 @@ export class CartItemComponent { readonly discountPercentage = input(null); readonly attributes = input([]); readonly quantity = input(1); + readonly readonly = input(false); + readonly quantityDisabled = input(false); + readonly showRemove = input(true); readonly quantityChange = output(); readonly remove = output(); @@ -30,6 +33,7 @@ export class CartItemComponent { readonly decrease = output(); protected onQuantityChange(newQuantity: number): void { + if (this.quantityDisabled()) return; this.quantityChange.emit(newQuantity); } diff --git a/src/app/shared/components/cart/cart.component.html b/src/app/shared/components/cart/cart.component.html index 468cd11..a04b6d1 100644 --- a/src/app/shared/components/cart/cart.component.html +++ b/src/app/shared/components/cart/cart.component.html @@ -5,16 +5,30 @@

{{ title() }}

- @if (showClose()) { - - } +
+ @if (!readonly() && allowEditing() && items().length > 0) { + + } + + @if (showClose()) { + + } +
@@ -31,6 +45,9 @@ [discountPercentage]="item.discountPercentage" [attributes]="item.attributes" [quantity]="getItemQuantity(item)" + [readonly]="readonly()" + [quantityDisabled]="editingDisabled() || (allowEditing() && !editing())" + [showRemove]="allowRemove()" (quantityChange)="onItemQuantityChange(idx, $event)" (remove)="onItemRemove(idx)" /> diff --git a/src/app/shared/components/cart/cart.component.scss b/src/app/shared/components/cart/cart.component.scss index b80a2c0..f38f1c5 100644 --- a/src/app/shared/components/cart/cart.component.scss +++ b/src/app/shared/components/cart/cart.component.scss @@ -21,6 +21,17 @@ color: #a0a0a0; } +.cart-header-actions { + gap: 14px; +} + +.cart-edit-btn { + color: var(--bs-primary); + font-size: 11px; + font-weight: 500; + text-decoration: none; +} + .cart-items-container { min-height: 0; gap: 0; diff --git a/src/app/shared/components/cart/cart.component.spec.ts b/src/app/shared/components/cart/cart.component.spec.ts index fe8ec93..d7a1d49 100644 --- a/src/app/shared/components/cart/cart.component.spec.ts +++ b/src/app/shared/components/cart/cart.component.spec.ts @@ -306,4 +306,105 @@ describe('CartComponent', () => { vi.useRealTimers(); }); + + it('enables quantity editing only while Modificar mode is active', async () => { + await TestBed.configureTestingModule({ + imports: [CartComponent], + providers: [ + { + provide: CartService, + useValue: { + cart: signal(null).asReadonly(), + updateItemQuantity: vi.fn(), + removeItem: vi.fn(), + }, + }, + { provide: ModalService, useValue: {} }, + { + provide: ToastService, + useValue: { success: vi.fn(), info: vi.fn(), danger: vi.fn() }, + }, + ], + }).compileComponents(); + + const fixture = TestBed.createComponent(CartComponent); + fixture.componentRef.setInput('items', [ + { + cartItemId: 10, + imageUrl: null, + product: 'Producto de prueba', + originalPrice: null, + discountedPrice: 1000, + discountPercentage: null, + attributes: [], + quantity: 1, + }, + ]); + fixture.componentRef.setInput('allowEditing', true); + const editingChange = vi.fn(); + fixture.componentInstance.editing.subscribe(editingChange); + fixture.detectChanges(); + + const editButton = fixture.debugElement.query(By.css('.cart-edit-btn')); + expect(editButton.nativeElement.textContent.trim()).toBe('Modificar'); + expect( + fixture.debugElement.query(By.css('app-cart-item')).componentInstance.quantityDisabled(), + ).toBe(true); + + editButton.nativeElement.click(); + fixture.detectChanges(); + + expect(editButton.nativeElement.textContent.trim()).toBe('Listo'); + expect( + fixture.debugElement.query(By.css('app-cart-item')).componentInstance.quantityDisabled(), + ).toBe(false); + expect(editingChange).toHaveBeenCalledWith(true); + + editButton.nativeElement.click(); + fixture.detectChanges(); + + expect(editButton.nativeElement.textContent.trim()).toBe('Modificar'); + expect(editingChange).toHaveBeenLastCalledWith(false); + }); + + it('allows editing directly when the optional Modificar toggle is disabled', async () => { + await TestBed.configureTestingModule({ + imports: [CartComponent], + providers: [ + { + provide: CartService, + useValue: { + cart: signal(null).asReadonly(), + updateItemQuantity: vi.fn(), + removeItem: vi.fn(), + }, + }, + { provide: ModalService, useValue: {} }, + { + provide: ToastService, + useValue: { success: vi.fn(), info: vi.fn(), danger: vi.fn() }, + }, + ], + }).compileComponents(); + + const fixture = TestBed.createComponent(CartComponent); + fixture.componentRef.setInput('items', [ + { + cartItemId: 10, + imageUrl: null, + product: 'Producto de prueba', + originalPrice: null, + discountedPrice: 1000, + discountPercentage: null, + attributes: [], + quantity: 1, + }, + ]); + fixture.detectChanges(); + + expect(fixture.debugElement.query(By.css('.cart-edit-btn'))).toBeNull(); + expect( + fixture.debugElement.query(By.css('app-cart-item')).componentInstance.quantityDisabled(), + ).toBe(false); + }); }); diff --git a/src/app/shared/components/cart/cart.component.ts b/src/app/shared/components/cart/cart.component.ts index f68b331..7a78e16 100644 --- a/src/app/shared/components/cart/cart.component.ts +++ b/src/app/shared/components/cart/cart.component.ts @@ -4,6 +4,7 @@ import { computed, inject, input, + model, output, signal, } from '@angular/core'; @@ -48,11 +49,21 @@ export class CartComponent { readonly discount = input(0); readonly total = input(0); readonly backgroundColor = input('#ffffff'); + readonly readonly = input(false); + readonly allowEditing = input(false); + readonly allowRemove = input(true); + readonly persistQuantityChanges = input(true); + readonly editingDisabled = input(false); + readonly editing = model(false); readonly closed = output(); + readonly itemQuantityChange = output<{ + item: CartItemMock; + index: number; + quantity: number; + }>(); protected readonly quantityOverrides = signal>({}); - constructor() { this.quantityUpdates$ .pipe( @@ -103,6 +114,20 @@ export class CartComponent { protected onItemQuantityChange(index: number, newQuantity: number): void { const mockItem = this.items()[index]; + if (!mockItem) { + return; + } + + this.itemQuantityChange.emit({ + item: mockItem, + index, + quantity: newQuantity, + }); + + if (!this.persistQuantityChanges()) { + return; + } + const cartItemId = mockItem?.cartItemId; if (cartItemId) { this.quantityOverrides.update((overrides) => ({ @@ -153,6 +178,15 @@ export class CartComponent { protected readonly formattedDiscount = computed(() => this.formatCurrency(this.discount())); protected readonly formattedTotal = computed(() => this.formatCurrency(this.total())); + protected toggleEditing(): void { + if (this.editingDisabled()) { + return; + } + + const editing = !this.editing(); + this.editing.set(editing); + } + private formatCurrency(value: number): string { const rounded = Math.round(value); const parts = rounded.toString().split('.'); diff --git a/src/app/shared/components/quantity-selector/quantity-selector.component.html b/src/app/shared/components/quantity-selector/quantity-selector.component.html index f5aeabe..4bd1383 100644 --- a/src/app/shared/components/quantity-selector/quantity-selector.component.html +++ b/src/app/shared/components/quantity-selector/quantity-selector.component.html @@ -1,13 +1,15 @@