Compare commits
14 Commits
fix/zoom_b
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| f859eba8a8 | |||
| 8ba34c6c7a | |||
| d7c015e71e | |||
| d8d608c435 | |||
| 985316d3b4 | |||
| d601cc1984 | |||
| 70d47ac310 | |||
| 5d1d7086c7 | |||
| 9a6ec3d1f2 | |||
| 38ba5f4195 | |||
| 71c462aaba | |||
| 481eb30795 | |||
| afebc7f639 | |||
| 520397baa5 |
@@ -12,7 +12,7 @@ import {
|
|||||||
UrlSerializer,
|
UrlSerializer,
|
||||||
} from '@angular/router';
|
} from '@angular/router';
|
||||||
|
|
||||||
import { BehaviorSubject, of } from 'rxjs';
|
import { BehaviorSubject, of, throwError } from 'rxjs';
|
||||||
|
|
||||||
import { Tenant } from '../../services/tenant.interface';
|
import { Tenant } from '../../services/tenant.interface';
|
||||||
import { TenantService } from '../../services/tenant.service';
|
import { TenantService } from '../../services/tenant.service';
|
||||||
@@ -211,7 +211,7 @@ describe('StoreLayoutComponent', () => {
|
|||||||
useValue: {
|
useValue: {
|
||||||
user: authUserState,
|
user: authUserState,
|
||||||
isAuthenticated: isAuthenticatedState,
|
isAuthenticated: isAuthenticatedState,
|
||||||
logout: vi.fn().mockReturnValue(of(void 0)),
|
logout: vi.fn().mockReturnValue(of({ message: 'Sesión cerrada correctamente.' })),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -574,6 +574,24 @@ describe('StoreLayoutComponent', () => {
|
|||||||
expect(authService.logout).toHaveBeenCalled();
|
expect(authService.logout).toHaveBeenCalled();
|
||||||
expect(cartService.clearCart).toHaveBeenCalled();
|
expect(cartService.clearCart).toHaveBeenCalled();
|
||||||
expect(router.navigate).toHaveBeenCalledWith(['/']);
|
expect(router.navigate).toHaveBeenCalledWith(['/']);
|
||||||
|
expect(TestBed.inject(ToastService).info).toHaveBeenCalledWith(
|
||||||
|
'Sesión cerrada correctamente.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a danger toast when logout fails', async () => {
|
||||||
|
const authService = TestBed.inject(AuthService);
|
||||||
|
const message = 'La sesión no pudo cerrarse en el servidor.';
|
||||||
|
(authService.logout as any).mockReturnValue(
|
||||||
|
throwError(() => ({ error: { message } })),
|
||||||
|
);
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(StoreLayoutComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
await (fixture.componentInstance as any).onLogoutClick();
|
||||||
|
|
||||||
|
expect(TestBed.inject(ToastService).danger).toHaveBeenCalledWith(message);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('provides account actions from the footer', () => {
|
it('provides account actions from the footer', () => {
|
||||||
|
|||||||
@@ -236,23 +236,39 @@ export class StoreLayoutComponent implements OnInit {
|
|||||||
const navigationSucceeded = await this.router.navigate(['/']);
|
const navigationSucceeded = await this.router.navigate(['/']);
|
||||||
|
|
||||||
if (!navigationSucceeded) {
|
if (!navigationSucceeded) {
|
||||||
|
this.toastService.danger('No se pudo cerrar la sesión. Intentá nuevamente.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.authService.logout().subscribe({
|
this.authService.logout().subscribe({
|
||||||
next: () => {
|
next: ({ message }) => {
|
||||||
this.cartService.clearCart();
|
this.cartService.clearCart();
|
||||||
this.isCartOpen.set(false);
|
this.isCartOpen.set(false);
|
||||||
|
this.toastService.info(message || 'Sesión cerrada correctamente.');
|
||||||
|
|
||||||
if (!isLeavingCheckout) {
|
if (!isLeavingCheckout) {
|
||||||
void this.router.navigate(['/']);
|
void this.router.navigate(['/']);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
error: (err) => console.error('Error logging out', err),
|
error: (error: unknown) => {
|
||||||
|
this.toastService.danger(this.resolveLogoutErrorMessage(error));
|
||||||
|
console.error('Error logging out', error);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resolveLogoutErrorMessage(error: unknown): string {
|
||||||
|
const payload =
|
||||||
|
typeof error === 'object' && error !== null && 'error' in error
|
||||||
|
? (error as { error?: { message?: unknown } }).error
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return typeof payload?.message === 'string' && payload.message.trim()
|
||||||
|
? payload.message
|
||||||
|
: 'No se pudo cerrar la sesión. Intentá nuevamente.';
|
||||||
|
}
|
||||||
|
|
||||||
protected async onCheckoutClick(): Promise<void> {
|
protected async onCheckoutClick(): Promise<void> {
|
||||||
if (this.isCreatingPurchase()) {
|
if (this.isCreatingPurchase()) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ export interface LoginResponse {
|
|||||||
user: AuthUser;
|
user: AuthUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LogoutResponse {
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RegisterResponse {
|
export interface RegisterResponse {
|
||||||
message: string;
|
message: string;
|
||||||
data: AuthUser;
|
data: AuthUser;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
AuthUser,
|
AuthUser,
|
||||||
LoginPayload,
|
LoginPayload,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
|
LogoutResponse,
|
||||||
RegisterPayload,
|
RegisterPayload,
|
||||||
RegisterResponse,
|
RegisterResponse,
|
||||||
ResetPasswordPayload,
|
ResetPasswordPayload,
|
||||||
@@ -155,14 +156,14 @@ export class AuthService extends BaseApiService {
|
|||||||
.pipe(tap((user) => this.userState.set(user)));
|
.pipe(tap((user) => this.userState.set(user)));
|
||||||
}
|
}
|
||||||
|
|
||||||
logout(): Observable<void> {
|
logout(): Observable<LogoutResponse> {
|
||||||
if (!this.tokenState()) {
|
if (!this.tokenState()) {
|
||||||
this.clearSession();
|
this.clearSession();
|
||||||
return of(void 0);
|
return of({ message: 'Sesión cerrada correctamente.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.http
|
return this.http
|
||||||
.post<void>(`${environment.url}logout`, {})
|
.post<LogoutResponse>(`${environment.url}logout`, {})
|
||||||
.pipe(tap(() => this.clearSession()));
|
.pipe(tap(() => this.clearSession()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export interface CatalogItemVariant {
|
|||||||
maximum_use_date?: string | null;
|
maximum_use_date?: string | null;
|
||||||
effective_minimum_use_date?: string | null;
|
effective_minimum_use_date?: string | null;
|
||||||
effective_maximum_use_date?: string | null;
|
effective_maximum_use_date?: string | null;
|
||||||
values: Record<string, string | string[]>;
|
values: Record<string, CatalogVariantValue>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SelectedCatalogItemVariant extends CatalogItemVariant {
|
export interface SelectedCatalogItemVariant extends CatalogItemVariant {
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ export interface Tenant {
|
|||||||
dominio: string;
|
dominio: string;
|
||||||
base_path?: string;
|
base_path?: string;
|
||||||
site_title?: string | null;
|
site_title?: string | null;
|
||||||
|
asset_url?: string | null;
|
||||||
address?: string | null;
|
address?: string | null;
|
||||||
phone?: string | null;
|
phone?: string | null;
|
||||||
favicon?: string | null;
|
favicon?: string | null;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const tenant: Tenant = {
|
|||||||
codigo: 'test',
|
codigo: 'test',
|
||||||
nombre: 'Test Tenant',
|
nombre: 'Test Tenant',
|
||||||
dominio: 'localhost',
|
dominio: 'localhost',
|
||||||
|
asset_url: 'https://s3.example.com/assets',
|
||||||
primary_color: '#6376F3',
|
primary_color: '#6376F3',
|
||||||
secondary_color: '#A0A0A0',
|
secondary_color: '#A0A0A0',
|
||||||
danger_color: '#FF8888',
|
danger_color: '#FF8888',
|
||||||
@@ -44,6 +45,7 @@ const tenantResponse: TenantBootstrapResponse = {
|
|||||||
|
|
||||||
describe('TenantService', () => {
|
describe('TenantService', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
document.head.querySelectorAll('link[rel="preconnect"]').forEach((link) => link.remove());
|
||||||
try {
|
try {
|
||||||
window.history.replaceState({}, '', 'http://localhost:4200/');
|
window.history.replaceState({}, '', 'http://localhost:4200/');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -72,6 +74,9 @@ describe('TenantService', () => {
|
|||||||
expect(service.status()).toBe('ready');
|
expect(service.status()).toBe('ready');
|
||||||
expect(service.tenant()).toEqual(tenant);
|
expect(service.tenant()).toEqual(tenant);
|
||||||
expect(service.getTenant()).toEqual(tenant);
|
expect(service.getTenant()).toEqual(tenant);
|
||||||
|
expect(
|
||||||
|
document.head.querySelector('link[rel="preconnect"][href="https://s3.example.com/"]'),
|
||||||
|
).not.toBeNull();
|
||||||
|
|
||||||
httpController.verify();
|
httpController.verify();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { isPlatformBrowser, isPlatformServer } from '@angular/common';
|
import { DOCUMENT, isPlatformBrowser, isPlatformServer } from '@angular/common';
|
||||||
import { HttpErrorResponse } from '@angular/common/http';
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
import {
|
import {
|
||||||
inject,
|
inject,
|
||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
})
|
})
|
||||||
export class TenantService extends BaseApiService {
|
export class TenantService extends BaseApiService {
|
||||||
|
private readonly document = inject(DOCUMENT);
|
||||||
private readonly platformId = inject(PLATFORM_ID);
|
private readonly platformId = inject(PLATFORM_ID);
|
||||||
private readonly request = inject(REQUEST, { optional: true });
|
private readonly request = inject(REQUEST, { optional: true });
|
||||||
private readonly responseInit = inject(RESPONSE_INIT, { optional: true });
|
private readonly responseInit = inject(RESPONSE_INIT, { optional: true });
|
||||||
@@ -182,10 +183,40 @@ export class TenantService extends BaseApiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private setReady(tenant: Tenant): void {
|
private setReady(tenant: Tenant): void {
|
||||||
|
this.ensureAssetPreconnect(tenant.asset_url);
|
||||||
this.tenantState.set(tenant);
|
this.tenantState.set(tenant);
|
||||||
this.statusState.set('ready');
|
this.statusState.set('ready');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ensureAssetPreconnect(assetUrl: string | null | undefined): void {
|
||||||
|
if (!assetUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let origin: string;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(assetUrl);
|
||||||
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
origin = url.origin;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingPreconnects =
|
||||||
|
this.document.head.querySelectorAll<HTMLLinkElement>('link[rel="preconnect"]');
|
||||||
|
if ([...existingPreconnects].some((link) => link.href === `${origin}/`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const link = this.document.createElement('link');
|
||||||
|
link.rel = 'preconnect';
|
||||||
|
link.href = origin;
|
||||||
|
this.document.head.append(link);
|
||||||
|
}
|
||||||
|
|
||||||
private setNotFound(): void {
|
private setNotFound(): void {
|
||||||
this.tenantState.set(null);
|
this.tenantState.set(null);
|
||||||
this.statusState.set('not-found');
|
this.statusState.set('not-found');
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { DOCUMENT } from '@angular/common';
|
||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { vi } from 'vitest';
|
import { vi } from 'vitest';
|
||||||
import { ToastService } from './toast.service';
|
import { ToastService } from './toast.service';
|
||||||
@@ -6,14 +7,16 @@ describe('ToastService', () => {
|
|||||||
let service: ToastService;
|
let service: ToastService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
providers: [ToastService]
|
providers: [ToastService]
|
||||||
});
|
});
|
||||||
|
TestBed.inject(DOCUMENT).defaultView?.sessionStorage.removeItem('shopit.pending-toast');
|
||||||
service = TestBed.inject(ToastService);
|
service = TestBed.inject(ToastService);
|
||||||
vi.useFakeTimers();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
TestBed.inject(DOCUMENT).defaultView?.sessionStorage.removeItem('shopit.pending-toast');
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -98,4 +101,23 @@ describe('ToastService', () => {
|
|||||||
|
|
||||||
expect(service.toasts().length).toBe(1);
|
expect(service.toasts().length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should restore a toast queued for after a full page reload', () => {
|
||||||
|
service.showAfterReload('Sesión iniciada correctamente.', 'success', 0);
|
||||||
|
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
TestBed.configureTestingModule({ providers: [ToastService] });
|
||||||
|
service = TestBed.inject(ToastService);
|
||||||
|
|
||||||
|
expect(service.toasts()).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
message: 'Sesión iniciada correctamente.',
|
||||||
|
type: 'success',
|
||||||
|
duration: 0,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
TestBed.inject(DOCUMENT).defaultView?.sessionStorage.getItem('shopit.pending-toast'),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { Injectable, signal } from '@angular/core';
|
import { DOCUMENT, isPlatformBrowser } from '@angular/common';
|
||||||
|
import { inject, Injectable, PLATFORM_ID, signal } from '@angular/core';
|
||||||
|
|
||||||
|
const PENDING_TOAST_STORAGE_KEY = 'shopit.pending-toast';
|
||||||
|
|
||||||
export interface Toast {
|
export interface Toast {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -11,9 +14,15 @@ export interface Toast {
|
|||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
export class ToastService {
|
export class ToastService {
|
||||||
|
private readonly document = inject(DOCUMENT);
|
||||||
|
private readonly platformId = inject(PLATFORM_ID);
|
||||||
private readonly toastsSignal = signal<Toast[]>([]);
|
private readonly toastsSignal = signal<Toast[]>([]);
|
||||||
readonly toasts = this.toastsSignal.asReadonly();
|
readonly toasts = this.toastsSignal.asReadonly();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.restorePendingToast();
|
||||||
|
}
|
||||||
|
|
||||||
show(message: string, type: 'success' | 'danger' | 'info' = 'info', duration = 3000): string {
|
show(message: string, type: 'success' | 'danger' | 'info' = 'info', duration = 3000): string {
|
||||||
const id = Math.random().toString(36).substring(2, 9);
|
const id = Math.random().toString(36).substring(2, 9);
|
||||||
const newToast: Toast = { id, message, type, duration };
|
const newToast: Toast = { id, message, type, duration };
|
||||||
@@ -41,7 +50,50 @@ export class ToastService {
|
|||||||
return this.show(message, 'success', duration);
|
return this.show(message, 'success', duration);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
showAfterReload(
|
||||||
|
message: string,
|
||||||
|
type: Toast['type'] = 'info',
|
||||||
|
duration = 3000,
|
||||||
|
): void {
|
||||||
|
if (!isPlatformBrowser(this.platformId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.document.defaultView?.sessionStorage.setItem(
|
||||||
|
PENDING_TOAST_STORAGE_KEY,
|
||||||
|
JSON.stringify({ message, type, duration }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
dismiss(id: string): void {
|
dismiss(id: string): void {
|
||||||
this.toastsSignal.update((toasts) => toasts.filter((t) => t.id !== id));
|
this.toastsSignal.update((toasts) => toasts.filter((t) => t.id !== id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private restorePendingToast(): void {
|
||||||
|
if (!isPlatformBrowser(this.platformId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = this.document.defaultView?.sessionStorage;
|
||||||
|
const pendingToast = storage?.getItem(PENDING_TOAST_STORAGE_KEY);
|
||||||
|
|
||||||
|
if (!pendingToast) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
storage?.removeItem(PENDING_TOAST_STORAGE_KEY);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(pendingToast) as Partial<Toast>;
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof parsed.message === 'string' &&
|
||||||
|
(parsed.type === 'success' || parsed.type === 'danger' || parsed.type === 'info')
|
||||||
|
) {
|
||||||
|
this.show(parsed.message, parsed.type, parsed.duration);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore malformed session data left by an older or interrupted client.
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { beforeEach, describe, expect, it } from 'vitest';
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { ProductAttribute } from '../../../../core/services/catalog/catalog.interface';
|
import {
|
||||||
|
CatalogItemVariant,
|
||||||
|
ProductAttribute,
|
||||||
|
} from '../../../../core/services/catalog/catalog.interface';
|
||||||
import { ProductAttributeSelectorComponent } from './product-attribute-selector.component';
|
import { ProductAttributeSelectorComponent } from './product-attribute-selector.component';
|
||||||
|
|
||||||
describe('ProductAttributeSelectorComponent', () => {
|
describe('ProductAttributeSelectorComponent', () => {
|
||||||
@@ -24,6 +27,63 @@ describe('ProductAttributeSelectorComponent', () => {
|
|||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([{ size: { value: 'S', label: 'Small' } }, { size: [{ value: 'S', label: 'Small' }] }])(
|
||||||
|
'initializes and matches structured variant values: %j',
|
||||||
|
(values) => {
|
||||||
|
const fixture = TestBed.createComponent(ProductAttributeSelectorComponent);
|
||||||
|
const variant: CatalogItemVariant = { id: 1, maximum_addable_quantity: null, values };
|
||||||
|
const emittedIds: Array<number | null> = [];
|
||||||
|
fixture.componentInstance.variantChange.subscribe((selected) =>
|
||||||
|
emittedIds.push(selected?.id ?? null),
|
||||||
|
);
|
||||||
|
fixture.componentRef.setInput('attributes', [sizeAttribute]);
|
||||||
|
fixture.componentRef.setInput('inventoryPolicy', 'unlimited');
|
||||||
|
fixture.componentRef.setInput('variants', [variant]);
|
||||||
|
fixture.componentRef.setInput('selectedVariant', variant);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const button = fixture.nativeElement.querySelector(
|
||||||
|
'.attribute-selector__text-option',
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
expect(button.getAttribute('aria-pressed')).toBe('true');
|
||||||
|
expect(button.disabled).toBe(false);
|
||||||
|
expect(emittedIds.at(-1)).toBe(1);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([0, 2, null])(
|
||||||
|
'uses maximum quantity %s for alternatives to a preselected option',
|
||||||
|
(maximum) => {
|
||||||
|
const fixture = TestBed.createComponent(ProductAttributeSelectorComponent);
|
||||||
|
const variants: CatalogItemVariant[] = [
|
||||||
|
{ id: 1, maximum_addable_quantity: 3, values: { size: { value: 'S', label: 'Small' } } },
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
maximum_addable_quantity: maximum,
|
||||||
|
values: { size: { value: 'M', label: 'Medium' } },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const emittedIds: Array<number | null> = [];
|
||||||
|
fixture.componentInstance.variantChange.subscribe((variant) =>
|
||||||
|
emittedIds.push(variant?.id ?? null),
|
||||||
|
);
|
||||||
|
fixture.componentRef.setInput('attributes', [sizeAttribute]);
|
||||||
|
fixture.componentRef.setInput('inventoryPolicy', maximum === null ? 'unlimited' : 'tracked');
|
||||||
|
fixture.componentRef.setInput('variants', variants);
|
||||||
|
fixture.componentRef.setInput('selectedVariant', variants[0]);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const buttons = fixture.nativeElement.querySelectorAll(
|
||||||
|
'.attribute-selector__text-option',
|
||||||
|
) as NodeListOf<HTMLButtonElement>;
|
||||||
|
expect(buttons[1].disabled).toBe(maximum === 0);
|
||||||
|
buttons[1].click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(emittedIds.at(-1)).toBe(maximum === 0 ? 1 : 2);
|
||||||
|
expect(buttons[0].disabled).toBe(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it('keeps an unlimited option available when maximum quantity is null', () => {
|
it('keeps an unlimited option available when maximum quantity is null', () => {
|
||||||
const fixture = TestBed.createComponent(ProductAttributeSelectorComponent);
|
const fixture = TestBed.createComponent(ProductAttributeSelectorComponent);
|
||||||
fixture.componentRef.setInput('attributes', [sizeAttribute]);
|
fixture.componentRef.setInput('attributes', [sizeAttribute]);
|
||||||
@@ -94,7 +154,16 @@ describe('ProductAttributeSelectorComponent', () => {
|
|||||||
fixture.componentRef.setInput('variants', [
|
fixture.componentRef.setInput('variants', [
|
||||||
{ id: 1, maximum_addable_quantity: null, values: { event_date: '1' } },
|
{ id: 1, maximum_addable_quantity: null, values: { event_date: '1' } },
|
||||||
{ id: 2, maximum_addable_quantity: null, values: { event_date: '2' } },
|
{ id: 2, maximum_addable_quantity: null, values: { event_date: '2' } },
|
||||||
{ id: 3, maximum_addable_quantity: null, values: { event_date: ['1', '2'] } },
|
{
|
||||||
|
id: 3,
|
||||||
|
maximum_addable_quantity: null,
|
||||||
|
values: {
|
||||||
|
event_date: [
|
||||||
|
{ value: '1', label: '09/10/2026' },
|
||||||
|
{ value: '2', label: '10/10/2026' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
|||||||
@@ -51,20 +51,13 @@ export class ProductAttributeSelectorComponent {
|
|||||||
const optionNormalized = this.normalizeText(option.value || option.label);
|
const optionNormalized = this.normalizeText(option.value || option.label);
|
||||||
const selectedForAttribute = selections[attribute.codigo] ?? [];
|
const selectedForAttribute = selections[attribute.codigo] ?? [];
|
||||||
|
|
||||||
if (
|
|
||||||
!attribute.allow_multi_select &&
|
|
||||||
selectedForAttribute.length >= 1 &&
|
|
||||||
!selectedForAttribute.includes(option.id)
|
|
||||||
) {
|
|
||||||
availability[attribute.codigo][option.id] = false;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isAvailable = variants.some((variant) => {
|
const isAvailable = variants.some((variant) => {
|
||||||
if (!this.isVariantAvailable(variant)) return false;
|
if (!this.isVariantAvailable(variant)) return false;
|
||||||
|
|
||||||
const variantAttrValues = this.getVariantAttributeValues(attribute, variant.values);
|
const variantAttrValues = this.getVariantAttributeValues(attribute, variant.values);
|
||||||
const desiredOptionIds = selectedForAttribute.includes(option.id)
|
const desiredOptionIds = !attribute.allow_multi_select
|
||||||
|
? [option.id]
|
||||||
|
: selectedForAttribute.includes(option.id)
|
||||||
? selectedForAttribute
|
? selectedForAttribute
|
||||||
: [...selectedForAttribute, option.id];
|
: [...selectedForAttribute, option.id];
|
||||||
const desiredValues = desiredOptionIds
|
const desiredValues = desiredOptionIds
|
||||||
@@ -202,7 +195,7 @@ export class ProductAttributeSelectorComponent {
|
|||||||
|
|
||||||
private getVariantAttributeValues(
|
private getVariantAttributeValues(
|
||||||
attribute: ProductAttribute,
|
attribute: ProductAttribute,
|
||||||
variantAttributes: Record<string, string | string[]>,
|
variantAttributes: CatalogItemVariant['values'],
|
||||||
): string[] {
|
): string[] {
|
||||||
const normalizedCodigo = this.normalizeText(attribute.codigo);
|
const normalizedCodigo = this.normalizeText(attribute.codigo);
|
||||||
const normalizedNombre = this.normalizeText(attribute.nombre);
|
const normalizedNombre = this.normalizeText(attribute.nombre);
|
||||||
@@ -211,7 +204,9 @@ export class ProductAttributeSelectorComponent {
|
|||||||
const normalizedKey = this.normalizeText(key);
|
const normalizedKey = this.normalizeText(key);
|
||||||
|
|
||||||
if (normalizedKey === normalizedCodigo || normalizedKey === normalizedNombre) {
|
if (normalizedKey === normalizedCodigo || normalizedKey === normalizedNombre) {
|
||||||
return (Array.isArray(value) ? value : [value]).map((item) => this.normalizeText(item));
|
return (Array.isArray(value) ? value : [value]).map((item) =>
|
||||||
|
this.normalizeText(typeof item === 'string' ? item : item.value || item.label),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,7 @@
|
|||||||
<!-- Thumbnails Row -->
|
<!-- Thumbnails Row -->
|
||||||
@if (images().length > 1) {
|
@if (images().length > 1) {
|
||||||
<div class="product-carousel__thumbnails">
|
<div class="product-carousel__thumbnails">
|
||||||
@for (image of images(); track image; let idx = $index) {
|
@for (image of images(); track $index; let idx = $index) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="product-carousel__thumbnail border-0 p-0 overflow-hidden bg-light"
|
class="product-carousel__thumbnail border-0 p-0 overflow-hidden bg-light"
|
||||||
|
|||||||
@@ -3,11 +3,21 @@ import { provideRouter, Router } from '@angular/router';
|
|||||||
import { of, throwError } from 'rxjs';
|
import { of, throwError } from 'rxjs';
|
||||||
|
|
||||||
import { AuthService } from '../../../../core/services/auth/auth.service';
|
import { AuthService } from '../../../../core/services/auth/auth.service';
|
||||||
|
import { ToastService } from '../../../../core/services/toast.service';
|
||||||
import { LoginPageComponent } from './login-page.component';
|
import { LoginPageComponent } from './login-page.component';
|
||||||
|
|
||||||
describe('LoginPageComponent', () => {
|
describe('LoginPageComponent', () => {
|
||||||
|
let toastService: {
|
||||||
|
danger: ReturnType<typeof vi.fn>;
|
||||||
|
showAfterReload: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
TestBed.resetTestingModule();
|
TestBed.resetTestingModule();
|
||||||
|
toastService = {
|
||||||
|
danger: vi.fn(),
|
||||||
|
showAfterReload: vi.fn(),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
it('submits credentials and redirects to home with a full page reload on success', async () => {
|
it('submits credentials and redirects to home with a full page reload on success', async () => {
|
||||||
@@ -16,14 +26,18 @@ describe('LoginPageComponent', () => {
|
|||||||
of({
|
of({
|
||||||
id: 1,
|
id: 1,
|
||||||
nombre_apellido: 'Ada Lovelace',
|
nombre_apellido: 'Ada Lovelace',
|
||||||
email: 'ada@example.com'
|
email: 'ada@example.com',
|
||||||
})
|
}),
|
||||||
)
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [LoginPageComponent],
|
imports: [LoginPageComponent],
|
||||||
providers: [provideRouter([]), { provide: AuthService, useValue: authService }]
|
providers: [
|
||||||
|
provideRouter([]),
|
||||||
|
{ provide: AuthService, useValue: authService },
|
||||||
|
{ provide: ToastService, useValue: toastService },
|
||||||
|
],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
|
|
||||||
const fixture = TestBed.createComponent(LoginPageComponent);
|
const fixture = TestBed.createComponent(LoginPageComponent);
|
||||||
@@ -34,17 +48,56 @@ describe('LoginPageComponent', () => {
|
|||||||
|
|
||||||
component.form.setValue({
|
component.form.setValue({
|
||||||
email: 'ada@example.com',
|
email: 'ada@example.com',
|
||||||
password: 'secret123'
|
password: 'secret123',
|
||||||
});
|
});
|
||||||
|
|
||||||
component.onSubmit();
|
component.onSubmit();
|
||||||
|
|
||||||
expect(authService.login).toHaveBeenCalledWith({
|
expect(authService.login).toHaveBeenCalledWith({
|
||||||
email: 'ada@example.com',
|
email: 'ada@example.com',
|
||||||
password: 'secret123'
|
password: 'secret123',
|
||||||
});
|
});
|
||||||
expect(redirectSpy).toHaveBeenCalled();
|
expect(redirectSpy).toHaveBeenCalled();
|
||||||
expect(navigateSpy).not.toHaveBeenCalled();
|
expect(navigateSpy).not.toHaveBeenCalled();
|
||||||
|
expect(toastService.showAfterReload).toHaveBeenCalledWith(
|
||||||
|
'Sesión iniciada correctamente.',
|
||||||
|
'success',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serializes a stored return URL before reloading so tenant base paths are restored', async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [LoginPageComponent],
|
||||||
|
providers: [
|
||||||
|
provideRouter([]),
|
||||||
|
{ provide: AuthService, useValue: {} },
|
||||||
|
{ provide: ToastService, useValue: toastService },
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(LoginPageComponent);
|
||||||
|
const component = fixture.componentInstance as any;
|
||||||
|
const router = TestBed.inject(Router);
|
||||||
|
const assign = vi.fn();
|
||||||
|
const parsedUrl = router.parseUrl('/');
|
||||||
|
|
||||||
|
component.document = {
|
||||||
|
defaultView: {
|
||||||
|
sessionStorage: {
|
||||||
|
getItem: vi.fn().mockReturnValue('/'),
|
||||||
|
removeItem: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
location: { assign },
|
||||||
|
};
|
||||||
|
const parseUrlSpy = vi.spyOn(router, 'parseUrl').mockReturnValue(parsedUrl);
|
||||||
|
const serializeUrlSpy = vi.spyOn(router, 'serializeUrl').mockReturnValue('/sonder');
|
||||||
|
|
||||||
|
component.redirectToHome();
|
||||||
|
|
||||||
|
expect(parseUrlSpy).toHaveBeenCalledWith('/');
|
||||||
|
expect(serializeUrlSpy).toHaveBeenCalledWith(parsedUrl);
|
||||||
|
expect(assign).toHaveBeenCalledWith('/sonder');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('surfaces backend login errors', async () => {
|
it('surfaces backend login errors', async () => {
|
||||||
@@ -53,16 +106,20 @@ describe('LoginPageComponent', () => {
|
|||||||
throwError(() => ({
|
throwError(() => ({
|
||||||
error: {
|
error: {
|
||||||
errors: {
|
errors: {
|
||||||
email: ['Las credenciales son invalidas.']
|
email: ['Las credenciales son invalidas.'],
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}))
|
})),
|
||||||
)
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [LoginPageComponent],
|
imports: [LoginPageComponent],
|
||||||
providers: [provideRouter([]), { provide: AuthService, useValue: authService }]
|
providers: [
|
||||||
|
provideRouter([]),
|
||||||
|
{ provide: AuthService, useValue: authService },
|
||||||
|
{ provide: ToastService, useValue: toastService },
|
||||||
|
],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
|
|
||||||
const fixture = TestBed.createComponent(LoginPageComponent);
|
const fixture = TestBed.createComponent(LoginPageComponent);
|
||||||
@@ -70,22 +127,27 @@ describe('LoginPageComponent', () => {
|
|||||||
|
|
||||||
component.form.setValue({
|
component.form.setValue({
|
||||||
email: 'ada@example.com',
|
email: 'ada@example.com',
|
||||||
password: 'wrong-password'
|
password: 'wrong-password',
|
||||||
});
|
});
|
||||||
|
|
||||||
component.onSubmit();
|
component.onSubmit();
|
||||||
|
|
||||||
expect(component.serverError()).toBe('Las credenciales son invalidas.');
|
expect(component.serverError()).toBe('Las credenciales son invalidas.');
|
||||||
|
expect(toastService.danger).toHaveBeenCalledWith('Las credenciales son invalidas.');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('validates email length and password minimum length before submit', async () => {
|
it('validates email length and password minimum length before submit', async () => {
|
||||||
const authService = {
|
const authService = {
|
||||||
login: vi.fn()
|
login: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [LoginPageComponent],
|
imports: [LoginPageComponent],
|
||||||
providers: [provideRouter([]), { provide: AuthService, useValue: authService }]
|
providers: [
|
||||||
|
provideRouter([]),
|
||||||
|
{ provide: AuthService, useValue: authService },
|
||||||
|
{ provide: ToastService, useValue: toastService },
|
||||||
|
],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
|
|
||||||
const fixture = TestBed.createComponent(LoginPageComponent);
|
const fixture = TestBed.createComponent(LoginPageComponent);
|
||||||
@@ -93,12 +155,15 @@ describe('LoginPageComponent', () => {
|
|||||||
|
|
||||||
component.form.setValue({
|
component.form.setValue({
|
||||||
email: `${'a'.repeat(250)}@example.com`,
|
email: `${'a'.repeat(250)}@example.com`,
|
||||||
password: '1234567'
|
password: '1234567',
|
||||||
});
|
});
|
||||||
component.onSubmit();
|
component.onSubmit();
|
||||||
|
|
||||||
expect(authService.login).not.toHaveBeenCalled();
|
expect(authService.login).not.toHaveBeenCalled();
|
||||||
expect(component.getControlError('email')).toBe('No puede superar los 255 caracteres.');
|
expect(component.getControlError('email')).toBe('No puede superar los 255 caracteres.');
|
||||||
expect(component.getControlError('password')).toBe('Debe tener al menos 8 caracteres.');
|
expect(component.getControlError('password')).toBe('Debe tener al menos 8 caracteres.');
|
||||||
|
expect(toastService.danger).toHaveBeenCalledWith(
|
||||||
|
'Revisá los datos ingresados para iniciar sesión.',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
|||||||
import { ActivatedRoute, 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 { ToastService } from '../../../../core/services/toast.service';
|
||||||
import { ButtonComponent } from '../../../../shared/components/button/button.component';
|
import { ButtonComponent } from '../../../../shared/components/button/button.component';
|
||||||
import { InputComponent } from '../../../../shared/components/input/input.component';
|
import { InputComponent } from '../../../../shared/components/input/input.component';
|
||||||
|
|
||||||
@@ -16,13 +17,14 @@ const POST_LOGIN_RETURN_URL_KEY = 'shopit.auth.return-url';
|
|||||||
imports: [ReactiveFormsModule, InputComponent, ButtonComponent],
|
imports: [ReactiveFormsModule, InputComponent, ButtonComponent],
|
||||||
templateUrl: './login-page.component.html',
|
templateUrl: './login-page.component.html',
|
||||||
styleUrl: './login-page.component.scss',
|
styleUrl: './login-page.component.scss',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
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 route = inject(ActivatedRoute);
|
||||||
private readonly authService = inject(AuthService);
|
private readonly authService = inject(AuthService);
|
||||||
|
private readonly toastService = inject(ToastService);
|
||||||
private readonly document = inject(DOCUMENT);
|
private readonly document = inject(DOCUMENT);
|
||||||
private readonly platformId = inject(PLATFORM_ID);
|
private readonly platformId = inject(PLATFORM_ID);
|
||||||
|
|
||||||
@@ -32,7 +34,7 @@ export class LoginPageComponent {
|
|||||||
|
|
||||||
protected readonly form = this.formBuilder.nonNullable.group({
|
protected readonly form = this.formBuilder.nonNullable.group({
|
||||||
email: ['', [Validators.required, Validators.email, Validators.maxLength(EMAIL_MAX_LENGTH)]],
|
email: ['', [Validators.required, Validators.email, Validators.maxLength(EMAIL_MAX_LENGTH)]],
|
||||||
password: ['', [Validators.required, Validators.minLength(PASSWORD_MIN_LENGTH)]]
|
password: ['', [Validators.required, Validators.minLength(PASSWORD_MIN_LENGTH)]],
|
||||||
});
|
});
|
||||||
protected readonly submitted = this.submittedState.asReadonly();
|
protected readonly submitted = this.submittedState.asReadonly();
|
||||||
protected readonly serverError = this.serverErrorState.asReadonly();
|
protected readonly serverError = this.serverErrorState.asReadonly();
|
||||||
@@ -64,6 +66,7 @@ export class LoginPageComponent {
|
|||||||
|
|
||||||
if (this.form.invalid) {
|
if (this.form.invalid) {
|
||||||
this.form.markAllAsTouched();
|
this.form.markAllAsTouched();
|
||||||
|
this.toastService.danger('Revisá los datos ingresados para iniciar sesión.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,12 +75,13 @@ export class LoginPageComponent {
|
|||||||
this.authService.login(this.form.getRawValue()).subscribe({
|
this.authService.login(this.form.getRawValue()).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.isSubmittingState.set(false);
|
this.isSubmittingState.set(false);
|
||||||
|
this.toastService.showAfterReload('Sesión iniciada correctamente.', 'success');
|
||||||
this.redirectToHome();
|
this.redirectToHome();
|
||||||
},
|
},
|
||||||
error: (error: unknown) => {
|
error: (error: unknown) => {
|
||||||
this.isSubmittingState.set(false);
|
this.isSubmittingState.set(false);
|
||||||
this.serverErrorState.set(this.resolveErrorMessage(error));
|
this.showLoginError(error);
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +95,7 @@ export class LoginPageComponent {
|
|||||||
}
|
}
|
||||||
this.authService.loginWithGoogle();
|
this.authService.loginWithGoogle();
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
this.serverErrorState.set(this.resolveErrorMessage(error));
|
this.showLoginError(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,10 +143,9 @@ export class LoginPageComponent {
|
|||||||
const requestedUrl =
|
const requestedUrl =
|
||||||
this.route.snapshot.queryParamMap.get('returnUrl') ??
|
this.route.snapshot.queryParamMap.get('returnUrl') ??
|
||||||
this.document.defaultView?.sessionStorage.getItem(POST_LOGIN_RETURN_URL_KEY);
|
this.document.defaultView?.sessionStorage.getItem(POST_LOGIN_RETURN_URL_KEY);
|
||||||
const destination =
|
const internalDestination =
|
||||||
requestedUrl?.startsWith('/') && !requestedUrl.startsWith('//')
|
requestedUrl?.startsWith('/') && !requestedUrl.startsWith('//') ? requestedUrl : '/';
|
||||||
? requestedUrl
|
const destination = this.router.serializeUrl(this.router.parseUrl(internalDestination));
|
||||||
: this.router.serializeUrl(this.router.createUrlTree(['/']));
|
|
||||||
|
|
||||||
this.document.defaultView?.sessionStorage.removeItem(POST_LOGIN_RETURN_URL_KEY);
|
this.document.defaultView?.sessionStorage.removeItem(POST_LOGIN_RETURN_URL_KEY);
|
||||||
this.document.location.assign(destination);
|
this.document.location.assign(destination);
|
||||||
@@ -155,15 +158,22 @@ export class LoginPageComponent {
|
|||||||
this.authService.completeGoogleLogin(oauthCode).subscribe({
|
this.authService.completeGoogleLogin(oauthCode).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.isSubmittingState.set(false);
|
this.isSubmittingState.set(false);
|
||||||
|
this.toastService.showAfterReload('Sesión iniciada correctamente.', 'success');
|
||||||
this.redirectToHome();
|
this.redirectToHome();
|
||||||
},
|
},
|
||||||
error: (error: unknown) => {
|
error: (error: unknown) => {
|
||||||
this.isSubmittingState.set(false);
|
this.isSubmittingState.set(false);
|
||||||
this.serverErrorState.set(this.resolveErrorMessage(error));
|
this.showLoginError(error);
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private showLoginError(error: unknown): void {
|
||||||
|
const message = this.resolveErrorMessage(error);
|
||||||
|
this.serverErrorState.set(message);
|
||||||
|
this.toastService.danger(message);
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
No hay productos disponibles en este momento.
|
No hay productos disponibles en este momento.
|
||||||
</p>
|
</p>
|
||||||
} @else {
|
} @else {
|
||||||
@for (group of catalog(); track group.id) {
|
@for (group of catalog(); track group.id; let first = $first) {
|
||||||
<app-store-section [attr.id]="group.code" [title]="group.title">
|
<app-store-section [attr.id]="group.code" [title]="group.title">
|
||||||
<app-product-list
|
<app-product-list
|
||||||
[layout]="group.layout"
|
[layout]="group.layout"
|
||||||
@@ -30,6 +30,7 @@
|
|||||||
[items]="group.items"
|
[items]="group.items"
|
||||||
[loading]="isGroupLoading(group.id)"
|
[loading]="isGroupLoading(group.id)"
|
||||||
[loadImages]="!hasMainCarouselImages() || mainCarouselReady()"
|
[loadImages]="!hasMainCarouselImages() || mainCarouselReady()"
|
||||||
|
[prioritizeFirstImage]="first && !hasMainCarouselImages()"
|
||||||
[unavailableVariantIds]="unavailableVariantIds()"
|
[unavailableVariantIds]="unavailableVariantIds()"
|
||||||
[savingProductIds]="savingProductIds()"
|
[savingProductIds]="savingProductIds()"
|
||||||
(buy)="onBuyProduct($event)"
|
(buy)="onBuyProduct($event)"
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<div class="hero-banner-container">
|
<div class="hero-banner-container">
|
||||||
<div class="hero-banner position-relative rounded">
|
<div
|
||||||
|
class="hero-banner position-relative rounded"
|
||||||
|
[class.hero-banner--with-media]="desktopImageUrl"
|
||||||
|
>
|
||||||
@if (desktopImageUrl) {
|
@if (desktopImageUrl) {
|
||||||
<picture class="hero-media" aria-hidden="true">
|
<picture class="hero-media" aria-hidden="true">
|
||||||
@if (mobileImageUrl) {
|
@if (mobileImageUrl) {
|
||||||
|
|||||||
@@ -7,23 +7,23 @@
|
|||||||
min-height: 400px;
|
min-height: 400px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
|
&--with-media {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-media {
|
.hero-media {
|
||||||
position: absolute;
|
position: relative;
|
||||||
inset: 0;
|
|
||||||
display: block;
|
display: block;
|
||||||
|
width: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background-position: center;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-size: cover;
|
|
||||||
border-radius: inherit;
|
border-radius: inherit;
|
||||||
|
|
||||||
img {
|
img {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: auto;
|
||||||
object-fit: cover;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
@@ -52,6 +52,11 @@
|
|||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hero-banner--with-media .hero-content {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
::ng-deep .hero-title,
|
::ng-deep .hero-title,
|
||||||
.hero-title {
|
.hero-title {
|
||||||
color: #666666;
|
color: #666666;
|
||||||
@@ -179,24 +184,29 @@
|
|||||||
inset: auto;
|
inset: auto;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: clamp(8.75rem, 44vw, 211px);
|
overflow: visible;
|
||||||
background-position: center center;
|
|
||||||
background-size: 140% auto;
|
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-media::after {
|
.hero-media::after {
|
||||||
top: auto;
|
top: auto;
|
||||||
height: 48%;
|
bottom: -2px;
|
||||||
|
height: calc(48% + 2px);
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
to bottom,
|
to bottom,
|
||||||
rgba(245, 245, 245, 0) 0%,
|
rgba(245, 245, 245, 0) 0%,
|
||||||
rgba(245, 245, 245, 0.78) 55%,
|
rgba(245, 245, 245, 0.78) 55%,
|
||||||
|
#f5f5f5 96%,
|
||||||
#f5f5f5 100%
|
#f5f5f5 100%
|
||||||
);
|
);
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hero-banner--with-media .hero-content {
|
||||||
|
position: relative;
|
||||||
|
inset: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.hero-content {
|
.hero-content {
|
||||||
justify-content: center !important;
|
justify-content: center !important;
|
||||||
height: auto !important;
|
height: auto !important;
|
||||||
|
|||||||
@@ -22,6 +22,21 @@ describe('HeroBannerComponent', () => {
|
|||||||
expect(element.querySelector('img')?.getAttribute('src')).toBe(
|
expect(element.querySelector('img')?.getAttribute('src')).toBe(
|
||||||
'https://example.com/desktop.jpg',
|
'https://example.com/desktop.jpg',
|
||||||
);
|
);
|
||||||
|
expect(element.querySelector('.hero-banner')?.classList).toContain(
|
||||||
|
'hero-banner--with-media',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the fallback banner sizing when there is no image', async () => {
|
||||||
|
await TestBed.configureTestingModule({ imports: [HeroBannerComponent] }).compileComponents();
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(HeroBannerComponent);
|
||||||
|
fixture.componentRef.setInput('heroConfig', { title_html: 'Banner sin imagen' });
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const banner = (fixture.nativeElement as HTMLElement).querySelector('.hero-banner');
|
||||||
|
|
||||||
|
expect(banner?.classList).not.toContain('hero-banner--with-media');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('expands and collapses the event schedules', async () => {
|
it('expands and collapses the event schedules', async () => {
|
||||||
|
|||||||
@@ -51,7 +51,9 @@
|
|||||||
[title]="item.nombre"
|
[title]="item.nombre"
|
||||||
[originalPrice]="price(item)"
|
[originalPrice]="price(item)"
|
||||||
[unavailableMessage]="item.unavailable_message ?? null"
|
[unavailableMessage]="item.unavailable_message ?? null"
|
||||||
[imagePriority]="loadImages() && index < 4"
|
[imagePriority]="
|
||||||
|
loadImages() && prioritizeFirstImage() && groupLayout() !== 'carousel' && index === 0
|
||||||
|
"
|
||||||
(buy)="emitProductDetailBuy(item)"
|
(buy)="emitProductDetailBuy(item)"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,6 +143,14 @@ describe('ProductListComponent', () => {
|
|||||||
|
|
||||||
expect(element.querySelector('.product-list--column')).not.toBeNull();
|
expect(element.querySelector('.product-list--column')).not.toBeNull();
|
||||||
expect(element.querySelectorAll('app-product-column-with-image')).toHaveLength(2);
|
expect(element.querySelectorAll('app-product-column-with-image')).toHaveLength(2);
|
||||||
|
expect(element.querySelectorAll('img[fetchpriority="high"]')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not prioritize images rendered in a circular carousel', async () => {
|
||||||
|
const fixture = await render('column_with_image', items, 'carousel');
|
||||||
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
|
||||||
|
expect(element.querySelectorAll('img[fetchpriority="high"]')).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders cart products next to each other in the column grid', async () => {
|
it('renders cart products next to each other in the column grid', async () => {
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export class ProductListComponent {
|
|||||||
readonly items = input.required<CatalogFeaturedItems>();
|
readonly items = input.required<CatalogFeaturedItems>();
|
||||||
readonly loading = input(false);
|
readonly loading = input(false);
|
||||||
readonly loadImages = input(true);
|
readonly loadImages = input(true);
|
||||||
|
readonly prioritizeFirstImage = input(true);
|
||||||
readonly unavailableVariantIds = input<ReadonlySet<number>>(new Set<number>());
|
readonly unavailableVariantIds = input<ReadonlySet<number>>(new Set<number>());
|
||||||
readonly savingProductIds = input<ReadonlySet<number>>(new Set<number>());
|
readonly savingProductIds = input<ReadonlySet<number>>(new Set<number>());
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user