feat: enhance checkout process with purchase editing and status handling
- Implemented purchase editing functionality in CheckoutPageComponent, allowing users to modify items in their purchase. - Added a new guard (checkoutPendingPurchaseGuard) to prevent navigation away from the checkout page while a purchase is in progress. - Updated the login page to handle return URLs after authentication. - Enhanced product detail page to support direct purchases with a new buyNow method. - Introduced new UI elements and logic to handle purchase status, including expired and rejected states in PurchaseStatusPageComponent. - Improved cart component to allow editing of item quantities with a toggle button. - Added quantity selector enhancements to disable controls when necessary. - Updated tests to cover new functionalities and ensure proper behavior of components.
This commit is contained in:
@@ -14,7 +14,10 @@
|
||||
/>
|
||||
|
||||
@if (isCartOpen()) {
|
||||
<div class="store-layout__cart-overlay" (click)="isCartOpen.set(false)"></div>
|
||||
<div
|
||||
class="store-layout__cart-overlay"
|
||||
(click)="isCartOpen.set(false)"
|
||||
></div>
|
||||
<div class="store-layout__cart-dropdown card border-0 shadow-lg">
|
||||
<app-cart
|
||||
[showClose]="true"
|
||||
@@ -25,12 +28,20 @@
|
||||
[backgroundColor]="'#ffffff'"
|
||||
(closed)="isCartOpen.set(false)"
|
||||
>
|
||||
<app-button variant="secondary" class="flex-grow-1" (click)="isCartOpen.set(false)"
|
||||
<app-button
|
||||
variant="secondary"
|
||||
class="flex-grow-1"
|
||||
(click)="isCartOpen.set(false)"
|
||||
>Seguir comprando</app-button
|
||||
>
|
||||
<app-button variant="primary" class="flex-grow-1" (click)="onCheckoutClick()"
|
||||
>Comprar</app-button
|
||||
<app-button
|
||||
variant="primary"
|
||||
class="flex-grow-1"
|
||||
[disabled]="isCreatingPurchase()"
|
||||
(click)="onCheckoutClick()"
|
||||
>
|
||||
Comprar
|
||||
</app-button>
|
||||
</app-cart>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -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 | null>(tenant);
|
||||
let cartState = signal<Cart | null>(null);
|
||||
let authUserState = signal<AuthUser | null>(null);
|
||||
let checkoutServiceStub: { startCheckout: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(async () => {
|
||||
tenantState = signal<Tenant | null>(tenant);
|
||||
cartState = signal<Cart | null>(null);
|
||||
const authUserState = signal<AuthUser | null>(null);
|
||||
authUserState = signal<AuthUser | null>(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<HTMLButtonElement>('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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<StoreFooterSection[]>(() => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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
|
||||
]
|
||||
});
|
||||
|
||||
@@ -42,10 +42,21 @@ export class AuthService {
|
||||
readonly isAuthenticated = computed(() => this.tokenState() !== null);
|
||||
|
||||
login(payload: LoginPayload): Observable<AuthUser> {
|
||||
return this.http.post<LoginResponse>(`${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<LoginResponse>(
|
||||
`${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<RegisterResponse> {
|
||||
@@ -57,9 +68,7 @@ export class AuthService {
|
||||
});
|
||||
}
|
||||
|
||||
requestPasswordReset(
|
||||
email: string
|
||||
): Observable<HttpResponse<ResetPasswordAttemptResponse>> {
|
||||
requestPasswordReset(email: string): Observable<HttpResponse<ResetPasswordAttemptResponse>> {
|
||||
const tenantCode = this.tenantService.getTenant()?.codigo;
|
||||
|
||||
return this.http.post<ResetPasswordAttemptResponse>(
|
||||
@@ -83,14 +92,10 @@ export class AuthService {
|
||||
);
|
||||
}
|
||||
|
||||
resetPassword(
|
||||
payload: ResetPasswordPayload
|
||||
): Observable<HttpResponse<ResetPasswordResponse>> {
|
||||
return this.http.post<ResetPasswordResponse>(
|
||||
`${environment.url}password/reset`,
|
||||
payload,
|
||||
{ observe: 'response' }
|
||||
);
|
||||
resetPassword(payload: ResetPasswordPayload): Observable<HttpResponse<ResetPasswordResponse>> {
|
||||
return this.http.post<ResetPasswordResponse>(`${environment.url}password/reset`, payload, {
|
||||
observe: 'response'
|
||||
});
|
||||
}
|
||||
|
||||
loginWithGoogle(): void {
|
||||
@@ -108,16 +113,25 @@ export class AuthService {
|
||||
}
|
||||
|
||||
completeGoogleLogin(oauthCode: string): Observable<AuthUser> {
|
||||
return this.http.post<LoginResponse>(`${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<LoginResponse>(
|
||||
`${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<AuthUser> {
|
||||
return this.http.put<AuthUser>(`${environment.url}me`, payload).pipe(
|
||||
tap((user) => this.userState.set(user))
|
||||
);
|
||||
return this.http.put<AuthUser>(`${environment.url}me`, payload).pipe(tap((user) => this.userState.set(user)));
|
||||
}
|
||||
|
||||
logout(): Observable<void> {
|
||||
@@ -126,9 +140,7 @@ export class AuthService {
|
||||
return of(void 0);
|
||||
}
|
||||
|
||||
return this.http.post<void>(`${environment.url}logout`, {}).pipe(
|
||||
tap(() => this.clearSession())
|
||||
);
|
||||
return this.http.post<void>(`${environment.url}logout`, {}).pipe(tap(() => this.clearSession()));
|
||||
}
|
||||
|
||||
async bootstrap(): Promise<void> {
|
||||
@@ -167,9 +179,7 @@ export class AuthService {
|
||||
}
|
||||
|
||||
loadCurrentUser(): Observable<AuthUser> {
|
||||
return this.http.get<AuthUser>(`${environment.url}me`).pipe(
|
||||
tap((user) => this.userState.set(user))
|
||||
);
|
||||
return this.http.get<AuthUser>(`${environment.url}me`).pipe(tap((user) => this.userState.set(user)));
|
||||
}
|
||||
|
||||
hydrateSession(): void {
|
||||
|
||||
@@ -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<PurchaseDetailResponse> {
|
||||
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<PurchaseDetailResponse>(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<any> {
|
||||
async generatePaymentIntent(
|
||||
tenantCode: string,
|
||||
purchaseId: number,
|
||||
method: 'qr' | 'transfer' | 'telepagos',
|
||||
payerDni?: string,
|
||||
): Promise<any> {
|
||||
const payload: any = { method };
|
||||
if (payerDni) {
|
||||
payload.transfer_payer_dni = payerDni;
|
||||
}
|
||||
const response = await firstValueFrom(
|
||||
this.http.post<any>(`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/payment-intent`, payload)
|
||||
this.http.post<any>(
|
||||
`${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<PurchaseDetailResponse> {
|
||||
const response = await firstValueFrom(
|
||||
this.http.patch<{ data?: PurchaseDetailResponse } | PurchaseDetailResponse>(
|
||||
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/customer-data`,
|
||||
payload,
|
||||
),
|
||||
);
|
||||
|
||||
const purchase = this.extractResponseData<PurchaseDetailResponse>(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<PurchaseDetailResponse> {
|
||||
const response = await firstValueFrom(
|
||||
this.http.patch<{ data?: PurchaseDetailResponse } | PurchaseDetailResponse>(
|
||||
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/items/${itemId}`,
|
||||
{ quantity },
|
||||
),
|
||||
);
|
||||
|
||||
const purchase = this.extractResponseData<PurchaseDetailResponse>(response);
|
||||
if (!purchase) {
|
||||
throw new Error('Error al actualizar la cantidad del producto.');
|
||||
}
|
||||
|
||||
return purchase;
|
||||
}
|
||||
|
||||
async prepareItemEditing(
|
||||
tenantCode: string,
|
||||
purchaseId: number,
|
||||
): Promise<PurchaseDetailResponse> {
|
||||
const response = await firstValueFrom(
|
||||
this.http.post<{ data?: PurchaseDetailResponse } | PurchaseDetailResponse>(
|
||||
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/edit-items`,
|
||||
{},
|
||||
),
|
||||
);
|
||||
|
||||
const purchase = this.extractResponseData<PurchaseDetailResponse>(response);
|
||||
if (!purchase) {
|
||||
throw new Error('Error al preparar la compra para editarla.');
|
||||
}
|
||||
|
||||
return purchase;
|
||||
}
|
||||
|
||||
async completePurchase(tenantCode: string, purchaseId: number): Promise<PurchaseStatusResponse> {
|
||||
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<PurchaseStatusResponse>(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<PurchaseStatusResponse> {
|
||||
const response = await firstValueFrom(
|
||||
this.http.post<{ data?: PurchaseStatusResponse } | PurchaseStatusResponse>(
|
||||
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/cancel`,
|
||||
{},
|
||||
),
|
||||
);
|
||||
|
||||
const purchase = this.extractResponseData<PurchaseStatusResponse>(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<PurchaseDetailResponse> {
|
||||
async getPurchase(
|
||||
tenantCode: string,
|
||||
purchaseId: string | number,
|
||||
): Promise<PurchaseDetailResponse> {
|
||||
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<PurchaseDetailResponse>(response);
|
||||
|
||||
Reference in New Issue
Block a user