Compare commits
3 Commits
472dead0ff
...
auth/googl
| Author | SHA1 | Date | |
|---|---|---|---|
| 20c8b04638 | |||
| c662c27fc7 | |||
| 3cefaf85d9 |
@@ -6,6 +6,7 @@ import { HttpTestingController, provideHttpClientTesting } from '@angular/common
|
|||||||
import { environment } from '../../../../environments/environment';
|
import { environment } from '../../../../environments/environment';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { CookieService } from '../cookie/cookie.service';
|
import { CookieService } from '../cookie/cookie.service';
|
||||||
|
import { TenantService } from '../tenant.service';
|
||||||
|
|
||||||
describe('AuthService', () => {
|
describe('AuthService', () => {
|
||||||
let cookieStore: Record<string, string> = {};
|
let cookieStore: Record<string, string> = {};
|
||||||
@@ -95,7 +96,7 @@ describe('AuthService', () => {
|
|||||||
httpController.verify();
|
httpController.verify();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('propagates a 401 from /me without clearing the session', async () => {
|
it('clears an expired session when /me returns 401', async () => {
|
||||||
cookieStore['shopit.auth.token'] = 'expired-token';
|
cookieStore['shopit.auth.token'] = 'expired-token';
|
||||||
|
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
@@ -116,11 +117,11 @@ describe('AuthService', () => {
|
|||||||
|
|
||||||
request.flush({ message: 'Unauthorized' }, { status: 401, statusText: 'Unauthorized' });
|
request.flush({ message: 'Unauthorized' }, { status: 401, statusText: 'Unauthorized' });
|
||||||
|
|
||||||
await expect(bootstrapPromise).rejects.toMatchObject({ status: 401 });
|
await expect(bootstrapPromise).resolves.toBeUndefined();
|
||||||
expect(service.user()).toBeNull();
|
expect(service.user()).toBeNull();
|
||||||
expect(service.token()).toBe('expired-token');
|
expect(service.token()).toBeNull();
|
||||||
expect(service.isAuthenticated()).toBe(true);
|
expect(service.isAuthenticated()).toBe(false);
|
||||||
expect(cookieStore['shopit.auth.token']).toBe('expired-token');
|
expect(cookieStore['shopit.auth.token']).toBeUndefined();
|
||||||
|
|
||||||
httpController.verify();
|
httpController.verify();
|
||||||
});
|
});
|
||||||
@@ -132,6 +133,10 @@ describe('AuthService', () => {
|
|||||||
provideHttpClientTesting(),
|
provideHttpClientTesting(),
|
||||||
AuthService,
|
AuthService,
|
||||||
{ provide: CookieService, useValue: createCookieServiceStub() },
|
{ provide: CookieService, useValue: createCookieServiceStub() },
|
||||||
|
{
|
||||||
|
provide: TenantService,
|
||||||
|
useValue: { getTenant: () => ({ codigo: 'tenant-test' }) }
|
||||||
|
},
|
||||||
TransferState
|
TransferState
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
@@ -152,6 +157,7 @@ describe('AuthService', () => {
|
|||||||
|
|
||||||
const request = httpController.expectOne(`${environment.url}register`);
|
const request = httpController.expectOne(`${environment.url}register`);
|
||||||
expect(request.request.method).toBe('POST');
|
expect(request.request.method).toBe('POST');
|
||||||
|
expect(request.request.body.tenant_codigo).toBe('tenant-test');
|
||||||
request.flush({
|
request.flush({
|
||||||
message: 'Usuario registrado correctamente.',
|
message: 'Usuario registrado correctamente.',
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { HttpClient } from '@angular/common/http';
|
import { DOCUMENT } from '@angular/common';
|
||||||
|
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
||||||
import { isPlatformBrowser, isPlatformServer } from '@angular/common';
|
import { isPlatformBrowser, isPlatformServer } from '@angular/common';
|
||||||
import { computed, inject, Injectable, PLATFORM_ID, signal, TransferState, makeStateKey } from '@angular/core';
|
import { computed, inject, Injectable, PLATFORM_ID, signal, TransferState, makeStateKey } from '@angular/core';
|
||||||
import { firstValueFrom, map, Observable, of, tap } from 'rxjs';
|
import { firstValueFrom, map, Observable, of, tap } from 'rxjs';
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
UpdateProfilePayload
|
UpdateProfilePayload
|
||||||
} from './auth.interfaces';
|
} from './auth.interfaces';
|
||||||
import { CookieService } from '../cookie/cookie.service';
|
import { CookieService } from '../cookie/cookie.service';
|
||||||
|
import { TenantService } from '../tenant.service';
|
||||||
|
|
||||||
const AUTH_TOKEN_COOKIE_KEY = 'shopit.auth.token';
|
const AUTH_TOKEN_COOKIE_KEY = 'shopit.auth.token';
|
||||||
const AUTH_USER_SSR_STATE_KEY = makeStateKey<AuthUser>('shopit.auth.user');
|
const AUTH_USER_SSR_STATE_KEY = makeStateKey<AuthUser>('shopit.auth.user');
|
||||||
@@ -22,9 +24,11 @@ const AUTH_USER_SSR_STATE_KEY = makeStateKey<AuthUser>('shopit.auth.user');
|
|||||||
})
|
})
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
private readonly http = inject(HttpClient);
|
private readonly http = inject(HttpClient);
|
||||||
|
private readonly document = inject(DOCUMENT);
|
||||||
private readonly platformId = inject(PLATFORM_ID);
|
private readonly platformId = inject(PLATFORM_ID);
|
||||||
private readonly cookieService = inject(CookieService);
|
private readonly cookieService = inject(CookieService);
|
||||||
private readonly transferState = inject(TransferState);
|
private readonly transferState = inject(TransferState);
|
||||||
|
private readonly tenantService = inject(TenantService);
|
||||||
|
|
||||||
private readonly userState = signal<AuthUser | null>(null);
|
private readonly userState = signal<AuthUser | null>(null);
|
||||||
private readonly tokenState = signal<string | null>(null);
|
private readonly tokenState = signal<string | null>(null);
|
||||||
@@ -41,7 +45,33 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
register(payload: RegisterPayload): Observable<RegisterResponse> {
|
register(payload: RegisterPayload): Observable<RegisterResponse> {
|
||||||
return this.http.post<RegisterResponse>(`${environment.url}register`, payload);
|
const tenantCode = this.tenantService.getTenant()?.codigo;
|
||||||
|
|
||||||
|
return this.http.post<RegisterResponse>(`${environment.url}register`, {
|
||||||
|
...payload,
|
||||||
|
...(tenantCode ? { tenant_codigo: tenantCode } : {})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loginWithGoogle(): void {
|
||||||
|
const tenant = this.tenantService.getTenant();
|
||||||
|
if (!tenant) {
|
||||||
|
throw new Error('No se pudo resolver el tenant activo.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiUrl = new URL(environment.url);
|
||||||
|
const authorizationUrl = new URL('/auth/google/redirect', apiUrl.origin);
|
||||||
|
authorizationUrl.searchParams.set('tenant', tenant.codigo);
|
||||||
|
authorizationUrl.searchParams.set('return_url', this.document.location.origin);
|
||||||
|
|
||||||
|
this.document.location.assign(authorizationUrl.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateProfile(payload: UpdateProfilePayload): Observable<AuthUser> {
|
updateProfile(payload: UpdateProfilePayload): Observable<AuthUser> {
|
||||||
@@ -75,9 +105,24 @@ export class AuthService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await firstValueFrom(this.loadCurrentUser());
|
try {
|
||||||
if (isPlatformServer(this.platformId)) {
|
const user = await firstValueFrom(this.loadCurrentUser());
|
||||||
this.transferState.set(AUTH_USER_SSR_STATE_KEY, user);
|
|
||||||
|
if (isPlatformServer(this.platformId)) {
|
||||||
|
this.transferState.set(AUTH_USER_SSR_STATE_KEY, user);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// An expired token is an expected state, including while rendering on the
|
||||||
|
// server. Do not let it reject the application initializer: Angular's SSR
|
||||||
|
// pipeline can otherwise attempt to serialize HttpErrorResponse internals
|
||||||
|
// as HTTP headers.
|
||||||
|
if (error instanceof HttpErrorResponse && error.status === 401) {
|
||||||
|
this.clearSession();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,37 +1,23 @@
|
|||||||
import '@angular/compiler';
|
import '@angular/compiler';
|
||||||
import { Component, inject } from '@angular/core';
|
import { Component, inject } from '@angular/core';
|
||||||
import { TestBed, getTestBed } from '@angular/core/testing';
|
import { TestBed, getTestBed } from '@angular/core/testing';
|
||||||
import {
|
import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
|
||||||
BrowserTestingModule,
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
platformBrowserTesting
|
|
||||||
} from '@angular/platform-browser/testing';
|
|
||||||
import {
|
|
||||||
afterEach,
|
|
||||||
beforeAll,
|
|
||||||
beforeEach,
|
|
||||||
describe,
|
|
||||||
expect,
|
|
||||||
it,
|
|
||||||
vi
|
|
||||||
} from 'vitest';
|
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
|
||||||
import { ConfirmDeleteModalComponent } from '../../shared/components/confirm-delete-modal/confirm-delete-modal.component';
|
import { ConfirmDeleteModalComponent } from '../../shared/components/confirm-delete-modal/confirm-delete-modal.component';
|
||||||
import { ConfirmModalComponent } from '../../shared/components/confirm-modal/confirm-modal.component';
|
import { ConfirmModalComponent } from '../../shared/components/confirm-modal/confirm-modal.component';
|
||||||
|
import { QrModalComponent } from '../../shared/components/qr-modal/qr-modal.component';
|
||||||
import { SimpleModalComponent } from '../../shared/components/simple-modal/simple-modal.component';
|
import { SimpleModalComponent } from '../../shared/components/simple-modal/simple-modal.component';
|
||||||
import {
|
import { MODAL_DATA, ModalRef, ModalService } from './modal.service';
|
||||||
MODAL_DATA,
|
|
||||||
ModalRef,
|
|
||||||
ModalService
|
|
||||||
} from './modal.service';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
template: ''
|
template: '',
|
||||||
})
|
})
|
||||||
class FirstTestModalComponent {}
|
class FirstTestModalComponent {}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
template: ''
|
template: '',
|
||||||
})
|
})
|
||||||
class SecondTestModalComponent {}
|
class SecondTestModalComponent {}
|
||||||
|
|
||||||
@@ -40,10 +26,7 @@ describe('ModalService', () => {
|
|||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
try {
|
try {
|
||||||
getTestBed().initTestEnvironment(
|
getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
|
||||||
BrowserTestingModule,
|
|
||||||
platformBrowserTesting()
|
|
||||||
);
|
|
||||||
} catch {
|
} catch {
|
||||||
// Test environment may already be initialized by another setup entrypoint.
|
// Test environment may already be initialized by another setup entrypoint.
|
||||||
}
|
}
|
||||||
@@ -55,7 +38,7 @@ describe('ModalService', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
providers: [ModalService]
|
providers: [ModalService],
|
||||||
});
|
});
|
||||||
|
|
||||||
service = TestBed.inject(ModalService);
|
service = TestBed.inject(ModalService);
|
||||||
@@ -66,7 +49,7 @@ describe('ModalService', () => {
|
|||||||
title: 'Confirmar compra',
|
title: 'Confirmar compra',
|
||||||
data: { productId: 10 },
|
data: { productId: 10 },
|
||||||
size: 'lg',
|
size: 'lg',
|
||||||
closeOnBackdrop: false
|
closeOnBackdrop: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const activeModal = service.activeModal();
|
const activeModal = service.activeModal();
|
||||||
@@ -80,7 +63,7 @@ describe('ModalService', () => {
|
|||||||
size: 'lg',
|
size: 'lg',
|
||||||
closeOnBackdrop: false,
|
closeOnBackdrop: false,
|
||||||
closeOnEscape: true,
|
closeOnEscape: true,
|
||||||
showCloseButton: true
|
showCloseButton: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -115,7 +98,7 @@ describe('ModalService', () => {
|
|||||||
firstRef.afterClosed$.subscribe(firstClosedSpy);
|
firstRef.afterClosed$.subscribe(firstClosedSpy);
|
||||||
|
|
||||||
const secondRef = service.open(SecondTestModalComponent, {
|
const secondRef = service.open(SecondTestModalComponent, {
|
||||||
title: 'Segundo modal'
|
title: 'Segundo modal',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(firstClosedSpy).toHaveBeenCalledWith(undefined);
|
expect(firstClosedSpy).toHaveBeenCalledWith(undefined);
|
||||||
@@ -126,7 +109,7 @@ describe('ModalService', () => {
|
|||||||
|
|
||||||
it('injects MODAL_DATA and ModalRef into opened components through the host injector contract', () => {
|
it('injects MODAL_DATA and ModalRef into opened components through the host injector contract', () => {
|
||||||
const ref = service.open(DataTestModalComponent, {
|
const ref = service.open(DataTestModalComponent, {
|
||||||
data: { amount: 3 }
|
data: { amount: 3 },
|
||||||
});
|
});
|
||||||
|
|
||||||
const activeModal = service.activeModal();
|
const activeModal = service.activeModal();
|
||||||
@@ -138,7 +121,7 @@ describe('ModalService', () => {
|
|||||||
it('opens the standard confirm modal with default labels', () => {
|
it('opens the standard confirm modal with default labels', () => {
|
||||||
const result$ = service.openConfirm({
|
const result$ = service.openConfirm({
|
||||||
title: 'Confirmar compra',
|
title: 'Confirmar compra',
|
||||||
content: 'Esto confirmara la compra actual.'
|
content: 'Esto confirmara la compra actual.',
|
||||||
});
|
});
|
||||||
|
|
||||||
const activeModal = service.activeModal();
|
const activeModal = service.activeModal();
|
||||||
@@ -150,19 +133,19 @@ describe('ModalService', () => {
|
|||||||
data: {
|
data: {
|
||||||
content: 'Esto confirmara la compra actual.',
|
content: 'Esto confirmara la compra actual.',
|
||||||
confirmLabel: 'Confirmar',
|
confirmLabel: 'Confirmar',
|
||||||
cancelLabel: 'Cancelar'
|
cancelLabel: 'Cancelar',
|
||||||
},
|
},
|
||||||
size: 'md',
|
size: 'md',
|
||||||
closeOnBackdrop: true,
|
closeOnBackdrop: true,
|
||||||
closeOnEscape: true,
|
closeOnEscape: true,
|
||||||
showCloseButton: true
|
showCloseButton: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('maps the confirm modal close result to true', async () => {
|
it('maps the confirm modal close result to true', async () => {
|
||||||
const result$ = service.openConfirm({
|
const result$ = service.openConfirm({
|
||||||
title: 'Confirmar compra',
|
title: 'Confirmar compra',
|
||||||
content: 'Esto confirmara la compra actual.'
|
content: 'Esto confirmara la compra actual.',
|
||||||
});
|
});
|
||||||
const activeModal = service.activeModal();
|
const activeModal = service.activeModal();
|
||||||
const resultPromise = firstValueFrom(result$);
|
const resultPromise = firstValueFrom(result$);
|
||||||
@@ -175,7 +158,7 @@ describe('ModalService', () => {
|
|||||||
it('maps dismissing a confirm modal to false', async () => {
|
it('maps dismissing a confirm modal to false', async () => {
|
||||||
const result$ = service.openConfirmDelete({
|
const result$ = service.openConfirmDelete({
|
||||||
title: 'Eliminar producto',
|
title: 'Eliminar producto',
|
||||||
content: 'Se eliminara el producto.'
|
content: 'Se eliminara el producto.',
|
||||||
});
|
});
|
||||||
const activeModal = service.activeModal();
|
const activeModal = service.activeModal();
|
||||||
const resultPromise = firstValueFrom(result$);
|
const resultPromise = firstValueFrom(result$);
|
||||||
@@ -194,7 +177,7 @@ describe('ModalService', () => {
|
|||||||
size: 'lg',
|
size: 'lg',
|
||||||
closeOnBackdrop: false,
|
closeOnBackdrop: false,
|
||||||
closeOnEscape: false,
|
closeOnEscape: false,
|
||||||
showCloseButton: false
|
showCloseButton: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const activeModal = service.activeModal();
|
const activeModal = service.activeModal();
|
||||||
@@ -205,19 +188,19 @@ describe('ModalService', () => {
|
|||||||
data: {
|
data: {
|
||||||
content: 'Se eliminara el producto.',
|
content: 'Se eliminara el producto.',
|
||||||
confirmLabel: 'Eliminar',
|
confirmLabel: 'Eliminar',
|
||||||
cancelLabel: 'Conservar'
|
cancelLabel: 'Conservar',
|
||||||
},
|
},
|
||||||
size: 'lg',
|
size: 'lg',
|
||||||
closeOnBackdrop: false,
|
closeOnBackdrop: false,
|
||||||
closeOnEscape: false,
|
closeOnEscape: false,
|
||||||
showCloseButton: false
|
showCloseButton: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('opens the simple modal with default button label', () => {
|
it('opens the simple modal with default button label', () => {
|
||||||
service.openSimple({
|
service.openSimple({
|
||||||
title: 'Aviso',
|
title: 'Aviso',
|
||||||
content: 'Este es un aviso simple.'
|
content: 'Este es un aviso simple.',
|
||||||
});
|
});
|
||||||
|
|
||||||
const activeModal = service.activeModal();
|
const activeModal = service.activeModal();
|
||||||
@@ -227,19 +210,19 @@ describe('ModalService', () => {
|
|||||||
title: 'Aviso',
|
title: 'Aviso',
|
||||||
data: {
|
data: {
|
||||||
content: 'Este es un aviso simple.',
|
content: 'Este es un aviso simple.',
|
||||||
buttonLabel: 'Entendido'
|
buttonLabel: 'Entendido',
|
||||||
},
|
},
|
||||||
size: 'md',
|
size: 'md',
|
||||||
closeOnBackdrop: true,
|
closeOnBackdrop: true,
|
||||||
closeOnEscape: true,
|
closeOnEscape: true,
|
||||||
showCloseButton: true
|
showCloseButton: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('maps the simple modal close result to undefined', async () => {
|
it('maps the simple modal close result to undefined', async () => {
|
||||||
const result$ = service.openSimple({
|
const result$ = service.openSimple({
|
||||||
title: 'Aviso',
|
title: 'Aviso',
|
||||||
content: 'Este es un aviso simple.'
|
content: 'Este es un aviso simple.',
|
||||||
});
|
});
|
||||||
const activeModal = service.activeModal();
|
const activeModal = service.activeModal();
|
||||||
const resultPromise = firstValueFrom(result$);
|
const resultPromise = firstValueFrom(result$);
|
||||||
@@ -248,10 +231,35 @@ describe('ModalService', () => {
|
|||||||
|
|
||||||
await expect(resultPromise).resolves.toBeUndefined();
|
await expect(resultPromise).resolves.toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('opens the QR modal with the ticket information and compact size', () => {
|
||||||
|
service.openQr({
|
||||||
|
title: 'Entrada general',
|
||||||
|
id: 42,
|
||||||
|
date: '09 de octubre',
|
||||||
|
ticket: 'ticket-payload',
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeModal = service.activeModal();
|
||||||
|
|
||||||
|
expect(activeModal?.component).toBe(QrModalComponent);
|
||||||
|
expect(activeModal?.config).toEqual({
|
||||||
|
title: 'Entrada general',
|
||||||
|
data: {
|
||||||
|
id: 42,
|
||||||
|
date: '09 de octubre',
|
||||||
|
ticket: 'ticket-payload',
|
||||||
|
},
|
||||||
|
size: 'qr',
|
||||||
|
closeOnBackdrop: true,
|
||||||
|
closeOnEscape: true,
|
||||||
|
showCloseButton: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
template: ''
|
template: '',
|
||||||
})
|
})
|
||||||
class DataTestModalComponent {
|
class DataTestModalComponent {
|
||||||
readonly data = inject(MODAL_DATA);
|
readonly data = inject(MODAL_DATA);
|
||||||
|
|||||||
@@ -2,14 +2,11 @@ import { InjectionToken, Injectable, Type, signal } from '@angular/core';
|
|||||||
import { Observable, Subject, map } from 'rxjs';
|
import { Observable, Subject, map } from 'rxjs';
|
||||||
import { ConfirmDeleteModalComponent } from '../../shared/components/confirm-delete-modal/confirm-delete-modal.component';
|
import { ConfirmDeleteModalComponent } from '../../shared/components/confirm-delete-modal/confirm-delete-modal.component';
|
||||||
import { ConfirmModalComponent } from '../../shared/components/confirm-modal/confirm-modal.component';
|
import { ConfirmModalComponent } from '../../shared/components/confirm-modal/confirm-modal.component';
|
||||||
|
import { QrModalComponent } from '../../shared/components/qr-modal/qr-modal.component';
|
||||||
import { SimpleModalComponent } from '../../shared/components/simple-modal/simple-modal.component';
|
import { SimpleModalComponent } from '../../shared/components/simple-modal/simple-modal.component';
|
||||||
|
|
||||||
export type ModalSize = 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
export type ModalSize = 'qr' | 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
||||||
export type ModalDismissReason =
|
export type ModalDismissReason = 'backdrop' | 'escape' | 'programmatic' | 'replaced';
|
||||||
| 'backdrop'
|
|
||||||
| 'escape'
|
|
||||||
| 'programmatic'
|
|
||||||
| 'replaced';
|
|
||||||
|
|
||||||
export interface ModalConfig<TData = unknown> {
|
export interface ModalConfig<TData = unknown> {
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -20,11 +17,10 @@ export interface ModalConfig<TData = unknown> {
|
|||||||
showCloseButton?: boolean;
|
showCloseButton?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NormalizedModalConfig<TData = unknown>
|
export interface NormalizedModalConfig<TData = unknown> extends Omit<
|
||||||
extends Omit<
|
ModalConfig<TData>,
|
||||||
ModalConfig<TData>,
|
'size' | 'closeOnBackdrop' | 'closeOnEscape' | 'showCloseButton'
|
||||||
'size' | 'closeOnBackdrop' | 'closeOnEscape' | 'showCloseButton'
|
> {
|
||||||
> {
|
|
||||||
size: ModalSize;
|
size: ModalSize;
|
||||||
closeOnBackdrop: boolean;
|
closeOnBackdrop: boolean;
|
||||||
closeOnEscape: boolean;
|
closeOnEscape: boolean;
|
||||||
@@ -37,8 +33,7 @@ export interface ConfirmModalData {
|
|||||||
cancelLabel: string;
|
cancelLabel: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConfirmModalConfig
|
export interface ConfirmModalConfig extends Omit<ModalConfig<ConfirmModalData>, 'data'> {
|
||||||
extends Omit<ModalConfig<ConfirmModalData>, 'data'> {
|
|
||||||
content: string;
|
content: string;
|
||||||
confirmLabel?: string;
|
confirmLabel?: string;
|
||||||
cancelLabel?: string;
|
cancelLabel?: string;
|
||||||
@@ -49,12 +44,24 @@ export interface SimpleModalData {
|
|||||||
buttonLabel: string;
|
buttonLabel: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SimpleModalConfig
|
export interface SimpleModalConfig extends Omit<ModalConfig<SimpleModalData>, 'data'> {
|
||||||
extends Omit<ModalConfig<SimpleModalData>, 'data'> {
|
|
||||||
content: string;
|
content: string;
|
||||||
buttonLabel?: string;
|
buttonLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface QrModalData {
|
||||||
|
id: string | number;
|
||||||
|
date: string;
|
||||||
|
ticket: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QrModalConfig extends Omit<ModalConfig<QrModalData>, 'data' | 'title'> {
|
||||||
|
title: string;
|
||||||
|
id: string | number;
|
||||||
|
date: string;
|
||||||
|
ticket: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ActiveModalState<TResult = unknown, TData = unknown> {
|
export interface ActiveModalState<TResult = unknown, TData = unknown> {
|
||||||
component: Type<unknown>;
|
component: Type<unknown>;
|
||||||
config: NormalizedModalConfig<TData>;
|
config: NormalizedModalConfig<TData>;
|
||||||
@@ -70,25 +77,24 @@ const DEFAULT_MODAL_CONFIG: Pick<
|
|||||||
size: 'md',
|
size: 'md',
|
||||||
closeOnBackdrop: true,
|
closeOnBackdrop: true,
|
||||||
closeOnEscape: true,
|
closeOnEscape: true,
|
||||||
showCloseButton: true
|
showCloseButton: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_CONFIRM_MODAL_LABELS = {
|
const DEFAULT_CONFIRM_MODAL_LABELS = {
|
||||||
confirmLabel: 'Confirmar',
|
confirmLabel: 'Confirmar',
|
||||||
cancelLabel: 'Cancelar'
|
cancelLabel: 'Cancelar',
|
||||||
} satisfies Pick<ConfirmModalData, 'confirmLabel' | 'cancelLabel'>;
|
} satisfies Pick<ConfirmModalData, 'confirmLabel' | 'cancelLabel'>;
|
||||||
|
|
||||||
export class ModalRef<TResult = unknown> {
|
export class ModalRef<TResult = unknown> {
|
||||||
private readonly afterClosedSubject = new Subject<TResult | undefined>();
|
private readonly afterClosedSubject = new Subject<TResult | undefined>();
|
||||||
private closed = false;
|
private closed = false;
|
||||||
|
|
||||||
readonly afterClosed$: Observable<TResult | undefined> =
|
readonly afterClosed$: Observable<TResult | undefined> = this.afterClosedSubject.asObservable();
|
||||||
this.afterClosedSubject.asObservable();
|
|
||||||
readonly dismissReason = signal<ModalDismissReason | null>(null);
|
readonly dismissReason = signal<ModalDismissReason | null>(null);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly closeHandler: (result?: TResult) => void,
|
private readonly closeHandler: (result?: TResult) => void,
|
||||||
private readonly dismissHandler: (reason: ModalDismissReason) => void
|
private readonly dismissHandler: (reason: ModalDismissReason) => void,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
close(result?: TResult): void {
|
close(result?: TResult): void {
|
||||||
@@ -120,7 +126,7 @@ export class ModalRef<TResult = unknown> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root',
|
||||||
})
|
})
|
||||||
export class ModalService {
|
export class ModalService {
|
||||||
private readonly activeModalState = signal<ActiveModalState | null>(null);
|
private readonly activeModalState = signal<ActiveModalState | null>(null);
|
||||||
@@ -128,35 +134,31 @@ export class ModalService {
|
|||||||
|
|
||||||
open<TComponent, TResult = unknown, TData = unknown>(
|
open<TComponent, TResult = unknown, TData = unknown>(
|
||||||
component: Type<TComponent>,
|
component: Type<TComponent>,
|
||||||
config: ModalConfig<TData> = {}
|
config: ModalConfig<TData> = {},
|
||||||
): ModalRef<TResult> {
|
): ModalRef<TResult> {
|
||||||
this.activeModalState()?.ref.dismiss('replaced');
|
this.activeModalState()?.ref.dismiss('replaced');
|
||||||
|
|
||||||
let ref!: ModalRef<TResult>;
|
let ref!: ModalRef<TResult>;
|
||||||
ref = new ModalRef<TResult>(
|
ref = new ModalRef<TResult>(
|
||||||
(result) => this.close(ref, result),
|
(result) => this.close(ref, result),
|
||||||
(reason) => this.dismiss(ref, reason)
|
(reason) => this.dismiss(ref, reason),
|
||||||
);
|
);
|
||||||
|
|
||||||
this.activeModalState.set({
|
this.activeModalState.set({
|
||||||
component,
|
component,
|
||||||
config: this.normalizeConfig(config),
|
config: this.normalizeConfig(config),
|
||||||
ref: ref as ModalRef<unknown>
|
ref: ref as ModalRef<unknown>,
|
||||||
});
|
});
|
||||||
|
|
||||||
return ref;
|
return ref;
|
||||||
}
|
}
|
||||||
|
|
||||||
openConfirm(config: ConfirmModalConfig): Observable<boolean> {
|
openConfirm(config: ConfirmModalConfig): Observable<boolean> {
|
||||||
return this.openConfirmRef(config).afterClosed$.pipe(
|
return this.openConfirmRef(config).afterClosed$.pipe(map((result) => result === true));
|
||||||
map((result) => result === true)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
openConfirmDelete(config: ConfirmModalConfig): Observable<boolean> {
|
openConfirmDelete(config: ConfirmModalConfig): Observable<boolean> {
|
||||||
return this.openConfirmDeleteRef(config).afterClosed$.pipe(
|
return this.openConfirmDeleteRef(config).afterClosed$.pipe(map((result) => result === true));
|
||||||
map((result) => result === true)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
openConfirmRef(config: ConfirmModalConfig): ModalRef<boolean> {
|
openConfirmRef(config: ConfirmModalConfig): ModalRef<boolean> {
|
||||||
@@ -164,23 +166,30 @@ export class ModalService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
openConfirmDeleteRef(config: ConfirmModalConfig): ModalRef<boolean> {
|
openConfirmDeleteRef(config: ConfirmModalConfig): ModalRef<boolean> {
|
||||||
return this.open(
|
return this.open(ConfirmDeleteModalComponent, this.buildConfirmModalConfig(config));
|
||||||
ConfirmDeleteModalComponent,
|
|
||||||
this.buildConfirmModalConfig(config)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
openSimple(config: SimpleModalConfig): Observable<void> {
|
openSimple(config: SimpleModalConfig): Observable<void> {
|
||||||
return this.openSimpleRef(config).afterClosed$.pipe(
|
return this.openSimpleRef(config).afterClosed$.pipe(map(() => undefined));
|
||||||
map(() => undefined)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
openSimpleRef(config: SimpleModalConfig): ModalRef<void> {
|
openSimpleRef(config: SimpleModalConfig): ModalRef<void> {
|
||||||
return this.open(
|
return this.open(SimpleModalComponent, this.buildSimpleModalConfig(config));
|
||||||
SimpleModalComponent,
|
}
|
||||||
this.buildSimpleModalConfig(config)
|
|
||||||
);
|
openQr(config: QrModalConfig): Observable<void> {
|
||||||
|
return this.openQrRef(config).afterClosed$.pipe(map(() => undefined));
|
||||||
|
}
|
||||||
|
|
||||||
|
openQrRef(config: QrModalConfig): ModalRef<void> {
|
||||||
|
const { title, id, date, ticket, size = 'qr', ...modalConfig } = config;
|
||||||
|
|
||||||
|
return this.open(QrModalComponent, {
|
||||||
|
...modalConfig,
|
||||||
|
title,
|
||||||
|
size,
|
||||||
|
data: { id, date, ticket },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private close<TResult>(ref: ModalRef<TResult>, result?: TResult): void {
|
private close<TResult>(ref: ModalRef<TResult>, result?: TResult): void {
|
||||||
@@ -194,7 +203,7 @@ export class ModalService {
|
|||||||
|
|
||||||
private dismiss<TResult>(
|
private dismiss<TResult>(
|
||||||
ref: ModalRef<TResult>,
|
ref: ModalRef<TResult>,
|
||||||
reason: ModalDismissReason = 'programmatic'
|
reason: ModalDismissReason = 'programmatic',
|
||||||
): void {
|
): void {
|
||||||
if (this.activeModalState()?.ref !== ref) {
|
if (this.activeModalState()?.ref !== ref) {
|
||||||
return;
|
return;
|
||||||
@@ -204,18 +213,14 @@ export class ModalService {
|
|||||||
this.activeModalState.set(null);
|
this.activeModalState.set(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeConfig<TData>(
|
private normalizeConfig<TData>(config: ModalConfig<TData>): NormalizedModalConfig<TData> {
|
||||||
config: ModalConfig<TData>
|
|
||||||
): NormalizedModalConfig<TData> {
|
|
||||||
return {
|
return {
|
||||||
...DEFAULT_MODAL_CONFIG,
|
...DEFAULT_MODAL_CONFIG,
|
||||||
...config
|
...config,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildConfirmModalConfig(
|
private buildConfirmModalConfig(config: ConfirmModalConfig): ModalConfig<ConfirmModalData> {
|
||||||
config: ConfirmModalConfig
|
|
||||||
): ModalConfig<ConfirmModalData> {
|
|
||||||
const {
|
const {
|
||||||
content,
|
content,
|
||||||
confirmLabel = DEFAULT_CONFIRM_MODAL_LABELS.confirmLabel,
|
confirmLabel = DEFAULT_CONFIRM_MODAL_LABELS.confirmLabel,
|
||||||
@@ -228,26 +233,20 @@ export class ModalService {
|
|||||||
data: {
|
data: {
|
||||||
content,
|
content,
|
||||||
confirmLabel,
|
confirmLabel,
|
||||||
cancelLabel
|
cancelLabel,
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildSimpleModalConfig(
|
private buildSimpleModalConfig(config: SimpleModalConfig): ModalConfig<SimpleModalData> {
|
||||||
config: SimpleModalConfig
|
const { content, buttonLabel = 'Entendido', ...modalConfig } = config;
|
||||||
): ModalConfig<SimpleModalData> {
|
|
||||||
const {
|
|
||||||
content,
|
|
||||||
buttonLabel = 'Entendido',
|
|
||||||
...modalConfig
|
|
||||||
} = config;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...modalConfig,
|
...modalConfig,
|
||||||
data: {
|
data: {
|
||||||
content,
|
content,
|
||||||
buttonLabel
|
buttonLabel,
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
[date]="formatDate(ticket)"
|
[date]="formatDate(ticket)"
|
||||||
[selected]="isSelected(ticket.id)"
|
[selected]="isSelected(ticket.id)"
|
||||||
(selectedChange)="toggleTicket(ticket.id, $event)"
|
(selectedChange)="toggleTicket(ticket.id, $event)"
|
||||||
|
(viewQr)="viewQr(ticket)"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { TestBed } from '@angular/core/testing';
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { ToastService } from '../../../../../../core/services/toast.service';
|
import { ToastService } from '../../../../../../core/services/toast.service';
|
||||||
|
import { ModalService } from '../../../../../../core/services/modal.service';
|
||||||
import { TicketComponent } from './components/ticket/ticket.component';
|
import { TicketComponent } from './components/ticket/ticket.component';
|
||||||
import { TicketResponse, TicketService } from './ticket.service';
|
import { TicketResponse, TicketService } from './ticket.service';
|
||||||
import { TicketsPage } from './tickets-page';
|
import { TicketsPage } from './tickets-page';
|
||||||
@@ -76,4 +77,42 @@ describe('TicketsPage', () => {
|
|||||||
expect(element.querySelector('[aria-label="Compartir ticket"]')).toBeNull();
|
expect(element.querySelector('[aria-label="Compartir ticket"]')).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('opens the QR modal with the selected ticket data', async () => {
|
||||||
|
const modalService = { openQr: vi.fn() };
|
||||||
|
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [TicketsPage],
|
||||||
|
providers: [
|
||||||
|
{ provide: ToastService, useValue: { danger: vi.fn() } },
|
||||||
|
{ provide: ModalService, useValue: modalService },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.overrideComponent(TicketsPage, {
|
||||||
|
set: {
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: TicketService,
|
||||||
|
useValue: { getTickets: () => Promise.resolve([ticket({})]) },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(TicketsPage);
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const page = fixture.nativeElement as HTMLElement;
|
||||||
|
page.querySelector<HTMLButtonElement>('app-ticket app-button button')?.click();
|
||||||
|
|
||||||
|
expect(modalService.openQr).toHaveBeenCalledWith({
|
||||||
|
title: 'Entrada general',
|
||||||
|
id: 1,
|
||||||
|
date: '09-octubre',
|
||||||
|
ticket: 'ticket-1',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Component, computed, inject, OnInit, signal } from '@angular/core';
|
import { Component, computed, inject, OnInit, signal } from '@angular/core';
|
||||||
|
|
||||||
|
import { ModalService } from '../../../../../../core/services/modal.service';
|
||||||
import { ToastService } from '../../../../../../core/services/toast.service';
|
import { ToastService } from '../../../../../../core/services/toast.service';
|
||||||
import { IconButtonComponent } from '../../../../../../shared/components/icon-button/icon-button.component';
|
import { IconButtonComponent } from '../../../../../../shared/components/icon-button/icon-button.component';
|
||||||
import { TicketComponent } from './components/ticket/ticket.component';
|
import { TicketComponent } from './components/ticket/ticket.component';
|
||||||
@@ -15,6 +16,7 @@ import { TicketResponse, TicketService } from './ticket.service';
|
|||||||
export class TicketsPage implements OnInit {
|
export class TicketsPage implements OnInit {
|
||||||
private readonly ticketService = inject(TicketService);
|
private readonly ticketService = inject(TicketService);
|
||||||
private readonly toastService = inject(ToastService);
|
private readonly toastService = inject(ToastService);
|
||||||
|
private readonly modalService = inject(ModalService);
|
||||||
|
|
||||||
protected readonly tickets = signal<TicketResponse[]>([]);
|
protected readonly tickets = signal<TicketResponse[]>([]);
|
||||||
protected readonly activeTickets = computed(() =>
|
protected readonly activeTickets = computed(() =>
|
||||||
@@ -75,4 +77,13 @@ export class TicketsPage implements OnInit {
|
|||||||
timeZone: 'UTC',
|
timeZone: 'UTC',
|
||||||
}).format(date);
|
}).format(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected viewQr(ticket: TicketResponse): void {
|
||||||
|
this.modalService.openQr({
|
||||||
|
title: ticket.name,
|
||||||
|
id: ticket.id,
|
||||||
|
date: this.formatDate(ticket) ?? '',
|
||||||
|
ticket: ticket.ticket,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,8 @@
|
|||||||
variant="neutral-outline"
|
variant="neutral-outline"
|
||||||
hostClass="w-100 d-block"
|
hostClass="w-100 d-block"
|
||||||
buttonClass="w-100 d-inline-flex align-items-center justify-content-center gap-2 py-2 fw-semibold"
|
buttonClass="w-100 d-inline-flex align-items-center justify-content-center gap-2 py-2 fw-semibold"
|
||||||
[disabled]="true"
|
[disabled]="isSubmitting()"
|
||||||
|
(click)="loginWithGoogle()"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { DOCUMENT } from '@angular/common';
|
import { DOCUMENT, isPlatformBrowser } from '@angular/common';
|
||||||
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, inject, PLATFORM_ID, signal } from '@angular/core';
|
||||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||||
import { Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
|
||||||
import { AuthService } from '../../../../core/services/auth/auth.service';
|
import { AuthService } from '../../../../core/services/auth/auth.service';
|
||||||
import { ButtonComponent } from '../../../../shared/components/button/button.component';
|
import { ButtonComponent } from '../../../../shared/components/button/button.component';
|
||||||
@@ -20,8 +20,10 @@ const EMAIL_MAX_LENGTH = 255;
|
|||||||
export class LoginPageComponent {
|
export class LoginPageComponent {
|
||||||
private readonly formBuilder = inject(FormBuilder);
|
private readonly formBuilder = inject(FormBuilder);
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
private readonly authService = inject(AuthService);
|
private readonly authService = inject(AuthService);
|
||||||
private readonly document = inject(DOCUMENT);
|
private readonly document = inject(DOCUMENT);
|
||||||
|
private readonly platformId = inject(PLATFORM_ID);
|
||||||
|
|
||||||
private readonly submittedState = signal(false);
|
private readonly submittedState = signal(false);
|
||||||
private readonly serverErrorState = signal<string | null>(null);
|
private readonly serverErrorState = signal<string | null>(null);
|
||||||
@@ -35,6 +37,18 @@ export class LoginPageComponent {
|
|||||||
protected readonly serverError = this.serverErrorState.asReadonly();
|
protected readonly serverError = this.serverErrorState.asReadonly();
|
||||||
protected readonly isSubmitting = this.isSubmittingState.asReadonly();
|
protected readonly isSubmitting = this.isSubmittingState.asReadonly();
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
if (!isPlatformBrowser(this.platformId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oauthCode = this.route.snapshot.queryParamMap.get('oauth_code');
|
||||||
|
|
||||||
|
if (oauthCode) {
|
||||||
|
this.completeGoogleLogin(oauthCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
goToRegister(): void {
|
goToRegister(): void {
|
||||||
void this.router.navigate(['/register']);
|
void this.router.navigate(['/register']);
|
||||||
}
|
}
|
||||||
@@ -62,6 +76,16 @@ export class LoginPageComponent {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loginWithGoogle(): void {
|
||||||
|
this.serverErrorState.set(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.authService.loginWithGoogle();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
this.serverErrorState.set(this.resolveErrorMessage(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected updateEmail(value: string | number): void {
|
protected updateEmail(value: string | number): void {
|
||||||
this.form.controls.email.setValue(String(value));
|
this.form.controls.email.setValue(String(value));
|
||||||
}
|
}
|
||||||
@@ -107,6 +131,22 @@ export class LoginPageComponent {
|
|||||||
this.document.location.assign(homeUrl);
|
this.document.location.assign(homeUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private completeGoogleLogin(oauthCode: string): void {
|
||||||
|
this.isSubmittingState.set(true);
|
||||||
|
this.serverErrorState.set(null);
|
||||||
|
|
||||||
|
this.authService.completeGoogleLogin(oauthCode).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.isSubmittingState.set(false);
|
||||||
|
this.redirectToHome();
|
||||||
|
},
|
||||||
|
error: (error: unknown) => {
|
||||||
|
this.isSubmittingState.set(false);
|
||||||
|
this.serverErrorState.set(this.resolveErrorMessage(error));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private resolveErrorMessage(error: unknown): string {
|
private resolveErrorMessage(error: unknown): string {
|
||||||
const errorPayload =
|
const errorPayload =
|
||||||
typeof error === 'object' && error !== null && 'error' in error
|
typeof error === 'object' && error !== null && 'error' in error
|
||||||
|
|||||||
@@ -50,8 +50,8 @@ describe('RegisterPageComponent', () => {
|
|||||||
component.form.setValue({
|
component.form.setValue({
|
||||||
nombre_apellido: 'Ada Lovelace',
|
nombre_apellido: 'Ada Lovelace',
|
||||||
email: 'ada@example.com',
|
email: 'ada@example.com',
|
||||||
password: 'secret123',
|
password: 'Secret!123',
|
||||||
password_confirmation: 'secret123'
|
password_confirmation: 'Secret!123'
|
||||||
});
|
});
|
||||||
|
|
||||||
component.onSubmit();
|
component.onSubmit();
|
||||||
@@ -59,8 +59,8 @@ describe('RegisterPageComponent', () => {
|
|||||||
expect(authService.register).toHaveBeenCalledWith({
|
expect(authService.register).toHaveBeenCalledWith({
|
||||||
nombre_apellido: 'Ada Lovelace',
|
nombre_apellido: 'Ada Lovelace',
|
||||||
email: 'ada@example.com',
|
email: 'ada@example.com',
|
||||||
password: 'secret123',
|
password: 'Secret!123',
|
||||||
password_confirmation: 'secret123'
|
password_confirmation: 'Secret!123'
|
||||||
});
|
});
|
||||||
expect(modalService.openSimple).toHaveBeenCalledWith({
|
expect(modalService.openSimple).toHaveBeenCalledWith({
|
||||||
content: 'Tu cuenta fue creada correctamente',
|
content: 'Tu cuenta fue creada correctamente',
|
||||||
@@ -178,8 +178,8 @@ describe('RegisterPageComponent', () => {
|
|||||||
component.form.setValue({
|
component.form.setValue({
|
||||||
nombre_apellido: 'Ada Lovelace',
|
nombre_apellido: 'Ada Lovelace',
|
||||||
email: 'ada@example.com',
|
email: 'ada@example.com',
|
||||||
password: 'secret123',
|
password: 'Secret!123',
|
||||||
password_confirmation: 'secret123'
|
password_confirmation: 'Secret!123'
|
||||||
});
|
});
|
||||||
component.onSubmit();
|
component.onSubmit();
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,36 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-shell__dialog--qr {
|
||||||
|
width: min(100%, 15rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-shell__dialog--qr .modal-shell__content {
|
||||||
|
min-height: auto;
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-shell__dialog--qr .modal-shell__header {
|
||||||
|
padding: 1.7rem 1rem 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-shell__dialog--qr .modal-shell__title {
|
||||||
|
color: var(--bs-success, #198754);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-shell__dialog--qr .modal-shell__header .btn-close {
|
||||||
|
top: 0.85rem;
|
||||||
|
right: 0.85rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-shell__dialog--qr .modal-shell__body {
|
||||||
|
padding: 0 1rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.modal-shell__dialog.modal-sm {
|
.modal-shell__dialog.modal-sm {
|
||||||
width: min(100%, 24rem);
|
width: min(100%, 24rem);
|
||||||
}
|
}
|
||||||
@@ -40,8 +70,7 @@
|
|||||||
min-height: 180px;
|
min-height: 180px;
|
||||||
max-height: calc(100dvh - 2rem);
|
max-height: calc(100dvh - 2rem);
|
||||||
border-radius: 1.25rem;
|
border-radius: 1.25rem;
|
||||||
background:
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 248, 248, 0.98));
|
||||||
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 248, 248, 0.98));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-shell__dialog--full .modal-shell__content {
|
.modal-shell__dialog--full .modal-shell__content {
|
||||||
@@ -88,6 +117,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal-shell__dialog,
|
.modal-shell__dialog,
|
||||||
|
.modal-shell__dialog--qr,
|
||||||
.modal-shell__dialog.modal-sm,
|
.modal-shell__dialog.modal-sm,
|
||||||
.modal-shell__dialog.modal-lg,
|
.modal-shell__dialog.modal-lg,
|
||||||
.modal-shell__dialog.modal-xl,
|
.modal-shell__dialog.modal-xl,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
computed,
|
computed,
|
||||||
input,
|
input,
|
||||||
output,
|
output,
|
||||||
viewChild
|
viewChild,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
|
||||||
import { ModalSize } from '../../../core/services/modal.service';
|
import { ModalSize } from '../../../core/services/modal.service';
|
||||||
@@ -16,7 +16,7 @@ import { ModalSize } from '../../../core/services/modal.service';
|
|||||||
imports: [NgClass],
|
imports: [NgClass],
|
||||||
templateUrl: './modal-shell.component.html',
|
templateUrl: './modal-shell.component.html',
|
||||||
styleUrl: './modal-shell.component.scss',
|
styleUrl: './modal-shell.component.scss',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class ModalShellComponent {
|
export class ModalShellComponent {
|
||||||
private readonly panel = viewChild.required<ElementRef<HTMLElement>>('panel');
|
private readonly panel = viewChild.required<ElementRef<HTMLElement>>('panel');
|
||||||
@@ -32,10 +32,11 @@ export class ModalShellComponent {
|
|||||||
const size = this.size();
|
const size = this.size();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
'modal-shell__dialog--qr': size === 'qr',
|
||||||
'modal-sm': size === 'sm',
|
'modal-sm': size === 'sm',
|
||||||
'modal-lg': size === 'lg',
|
'modal-lg': size === 'lg',
|
||||||
'modal-xl': size === 'xl',
|
'modal-xl': size === 'xl',
|
||||||
'modal-shell__dialog--full': size === 'full'
|
'modal-shell__dialog--full': size === 'full',
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -55,8 +56,8 @@ export class ModalShellComponent {
|
|||||||
'input:not([disabled])',
|
'input:not([disabled])',
|
||||||
'select:not([disabled])',
|
'select:not([disabled])',
|
||||||
'textarea:not([disabled])',
|
'textarea:not([disabled])',
|
||||||
'[tabindex]:not([tabindex="-1"])'
|
'[tabindex]:not([tabindex="-1"])',
|
||||||
].join(', ')
|
].join(', '),
|
||||||
);
|
);
|
||||||
|
|
||||||
(focusTarget ?? panel).focus();
|
(focusTarget ?? panel).focus();
|
||||||
|
|||||||
16
src/app/shared/components/qr-modal/qr-modal.component.html
Normal file
16
src/app/shared/components/qr-modal/qr-modal.component.html
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<article class="qr-modal">
|
||||||
|
<header class="qr-modal__header">
|
||||||
|
<p class="qr-modal__id">ID: {{ data.id }}</p>
|
||||||
|
@if (data.date) {
|
||||||
|
<time class="qr-modal__date">{{ data.date }}</time>
|
||||||
|
}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<qrcode
|
||||||
|
class="qr-modal__code"
|
||||||
|
[qrdata]="data.ticket"
|
||||||
|
[width]="320"
|
||||||
|
errorCorrectionLevel="M"
|
||||||
|
[ariaLabel]="'Código QR del ticket ' + data.id"
|
||||||
|
/>
|
||||||
|
</article>
|
||||||
44
src/app/shared/components/qr-modal/qr-modal.component.scss
Normal file
44
src/app/shared/components/qr-modal/qr-modal.component.scss
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
.qr-modal {
|
||||||
|
display: grid;
|
||||||
|
justify-items: center;
|
||||||
|
gap: 0.65rem;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-modal__header {
|
||||||
|
display: grid;
|
||||||
|
justify-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-modal__id {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-modal__id {
|
||||||
|
color: #4f4f4f;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-modal__date {
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
color: #8a8a8a;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-modal__code {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 11rem;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .qr-modal__code canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100% !important;
|
||||||
|
height: auto !important;
|
||||||
|
}
|
||||||
15
src/app/shared/components/qr-modal/qr-modal.component.ts
Normal file
15
src/app/shared/components/qr-modal/qr-modal.component.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||||
|
|
||||||
|
import { MODAL_DATA, QrModalData } from '../../../core/services/modal.service';
|
||||||
|
import { QRCodeComponent } from '../qrcode/qrcode.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-qr-modal',
|
||||||
|
imports: [QRCodeComponent],
|
||||||
|
templateUrl: './qr-modal.component.html',
|
||||||
|
styleUrl: './qr-modal.component.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class QrModalComponent {
|
||||||
|
protected readonly data = inject<QrModalData>(MODAL_DATA);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user