14 Commits

Author SHA1 Message Date
f859eba8a8 fix(logout): enhance logout handling with success and error toasts 2026-09-07 10:14:11 -03:00
8ba34c6c7a fix(auth): update logout response to include success message and adjust types
fix(tenant): add asset_url to Tenant interface and implement preconnect logic
2026-09-07 09:30:06 -03:00
d7c015e71e fix(product-list): prioritize first image in non-carousel layouts and update tests 2026-09-07 09:04:03 -03:00
d8d608c435 fix(product-carousel): correct track variable in thumbnail loop 2026-09-07 09:03:34 -03:00
985316d3b4 fix(product-attribute-selector): improve variant availability logic and add tests for maximum quantity handling 2026-09-04 11:36:21 -03:00
d601cc1984 fix(product-attribute-selector): update variant values type and normalize value extraction 2026-09-04 11:36:13 -03:00
70d47ac310 Merge pull request 'homologacion' (#4) from homologacion into main
Reviewed-on: https://gitea.quo.ar/tbianchini/shopit-front/pulls/4
2026-09-04 12:01:45 +00:00
5d1d7086c7 Merge branch 'develop' into homologacion 2026-09-03 13:39:41 -03:00
9a6ec3d1f2 feat(hero-banner): add conditional class for media presence and adjust styles 2026-09-03 13:39:23 -03:00
38ba5f4195 Merge branch 'develop' into homologacion 2026-09-03 08:55:50 -03:00
71c462aaba Merge pull request 'homologacion' (#3) from homologacion into main
Reviewed-on: https://gitea.quo.ar/tbianchini/shopit-front/pulls/3
2026-08-31 11:36:15 +00:00
481eb30795 Merge branch 'main' into homologacion 2026-08-31 11:36:09 +00:00
afebc7f639 Merge pull request 'homo_experimental' (#2) from homo_experimental into homologacion
Reviewed-on: https://gitea.quo.ar/tbianchini/shopit-front/pulls/2
2026-08-31 11:25:31 +00:00
520397baa5 Merge pull request 'homologacion' (#1) from homologacion into main
Reviewed-on: https://gitea.quo.ar/tbianchini/shopit-front/pulls/1
2026-08-26 18:10:12 +00:00
22 changed files with 398 additions and 69 deletions

View File

@@ -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', () => {

View File

@@ -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;

View File

@@ -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;

View File

@@ -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()));
} }

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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();
}); });

View File

@@ -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');

View File

@@ -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();
});
}); });

View File

@@ -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.
}
}
} }

View File

@@ -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();

View File

@@ -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),
);
} }
} }

View File

@@ -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"

View File

@@ -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.',
);
}); });
}); });

View File

@@ -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

View File

@@ -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)"

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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 () => {

View File

@@ -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)"
/> />
} }

View File

@@ -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 () => {

View File

@@ -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>());