Compare commits

...

5 Commits

14 changed files with 144 additions and 25 deletions

View File

@@ -241,9 +241,10 @@ export class StoreLayoutComponent implements OnInit {
}
this.authService.logout().subscribe({
next: () => {
next: ({ message }) => {
this.cartService.clearCart();
this.isCartOpen.set(false);
this.toastService.success(message || 'Sesión cerrada correctamente.');
if (!isLeavingCheckout) {
void this.router.navigate(['/']);

View File

@@ -33,6 +33,10 @@ export interface LoginResponse {
user: AuthUser;
}
export interface LogoutResponse {
message: string;
}
export interface RegisterResponse {
message: string;
data: AuthUser;

View File

@@ -10,6 +10,7 @@ import {
AuthUser,
LoginPayload,
LoginResponse,
LogoutResponse,
RegisterPayload,
RegisterResponse,
ResetPasswordPayload,
@@ -155,14 +156,14 @@ export class AuthService extends BaseApiService {
.pipe(tap((user) => this.userState.set(user)));
}
logout(): Observable<void> {
logout(): Observable<LogoutResponse> {
if (!this.tokenState()) {
this.clearSession();
return of(void 0);
return of({ message: 'Sesión cerrada correctamente.' });
}
return this.http
.post<void>(`${environment.url}logout`, {})
.post<LogoutResponse>(`${environment.url}logout`, {})
.pipe(tap(() => this.clearSession()));
}

View File

@@ -72,7 +72,7 @@ export interface CatalogItemVariant {
maximum_use_date?: string | null;
effective_minimum_use_date?: string | null;
effective_maximum_use_date?: string | null;
values: Record<string, string | string[]>;
values: Record<string, CatalogVariantValue>;
}
export interface SelectedCatalogItemVariant extends CatalogItemVariant {

View File

@@ -113,6 +113,7 @@ export interface Tenant {
dominio: string;
base_path?: string;
site_title?: string | null;
asset_url?: string | null;
address?: string | null;
phone?: string | null;
favicon?: string | null;

View File

@@ -15,6 +15,7 @@ const tenant: Tenant = {
codigo: 'test',
nombre: 'Test Tenant',
dominio: 'localhost',
asset_url: 'https://s3.example.com/assets',
primary_color: '#6376F3',
secondary_color: '#A0A0A0',
danger_color: '#FF8888',
@@ -44,6 +45,7 @@ const tenantResponse: TenantBootstrapResponse = {
describe('TenantService', () => {
beforeEach(() => {
document.head.querySelectorAll('link[rel="preconnect"]').forEach((link) => link.remove());
try {
window.history.replaceState({}, '', 'http://localhost:4200/');
} catch (e) {
@@ -72,6 +74,9 @@ describe('TenantService', () => {
expect(service.status()).toBe('ready');
expect(service.tenant()).toEqual(tenant);
expect(service.getTenant()).toEqual(tenant);
expect(
document.head.querySelector('link[rel="preconnect"][href="https://s3.example.com/"]'),
).not.toBeNull();
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 {
inject,
@@ -26,6 +26,7 @@ import {
providedIn: 'root',
})
export class TenantService extends BaseApiService {
private readonly document = inject(DOCUMENT);
private readonly platformId = inject(PLATFORM_ID);
private readonly request = inject(REQUEST, { optional: true });
private readonly responseInit = inject(RESPONSE_INIT, { optional: true });
@@ -182,10 +183,40 @@ export class TenantService extends BaseApiService {
}
private setReady(tenant: Tenant): void {
this.ensureAssetPreconnect(tenant.asset_url);
this.tenantState.set(tenant);
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 {
this.tenantState.set(null);
this.statusState.set('not-found');

View File

@@ -1,7 +1,10 @@
import { TestBed } from '@angular/core/testing';
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';
describe('ProductAttributeSelectorComponent', () => {
@@ -24,6 +27,63 @@ describe('ProductAttributeSelectorComponent', () => {
}).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', () => {
const fixture = TestBed.createComponent(ProductAttributeSelectorComponent);
fixture.componentRef.setInput('attributes', [sizeAttribute]);
@@ -94,7 +154,16 @@ describe('ProductAttributeSelectorComponent', () => {
fixture.componentRef.setInput('variants', [
{ id: 1, maximum_addable_quantity: null, values: { event_date: '1' } },
{ 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();

View File

@@ -51,22 +51,15 @@ export class ProductAttributeSelectorComponent {
const optionNormalized = this.normalizeText(option.value || option.label);
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) => {
if (!this.isVariantAvailable(variant)) return false;
const variantAttrValues = this.getVariantAttributeValues(attribute, variant.values);
const desiredOptionIds = selectedForAttribute.includes(option.id)
? selectedForAttribute
: [...selectedForAttribute, option.id];
const desiredOptionIds = !attribute.allow_multi_select
? [option.id]
: selectedForAttribute.includes(option.id)
? selectedForAttribute
: [...selectedForAttribute, option.id];
const desiredValues = desiredOptionIds
.map((id) => attribute.options.find((candidate) => candidate.id === id))
.filter((candidate): candidate is ProductAttributeOption => candidate !== undefined)
@@ -202,7 +195,7 @@ export class ProductAttributeSelectorComponent {
private getVariantAttributeValues(
attribute: ProductAttribute,
variantAttributes: Record<string, string | string[]>,
variantAttributes: CatalogItemVariant['values'],
): string[] {
const normalizedCodigo = this.normalizeText(attribute.codigo);
const normalizedNombre = this.normalizeText(attribute.nombre);
@@ -211,7 +204,9 @@ export class ProductAttributeSelectorComponent {
const normalizedKey = this.normalizeText(key);
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 -->
@if (images().length > 1) {
<div class="product-carousel__thumbnails">
@for (image of images(); track image; let idx = $index) {
@for (image of images(); track $index; let idx = $index) {
<button
type="button"
class="product-carousel__thumbnail border-0 p-0 overflow-hidden bg-light"

View File

@@ -22,7 +22,7 @@
No hay productos disponibles en este momento.
</p>
} @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-product-list
[layout]="group.layout"
@@ -30,6 +30,7 @@
[items]="group.items"
[loading]="isGroupLoading(group.id)"
[loadImages]="!hasMainCarouselImages() || mainCarouselReady()"
[prioritizeFirstImage]="first && !hasMainCarouselImages()"
[unavailableVariantIds]="unavailableVariantIds()"
[savingProductIds]="savingProductIds()"
(buy)="onBuyProduct($event)"

View File

@@ -51,7 +51,9 @@
[title]="item.nombre"
[originalPrice]="price(item)"
[unavailableMessage]="item.unavailable_message ?? null"
[imagePriority]="loadImages() && index < 4"
[imagePriority]="
loadImages() && prioritizeFirstImage() && groupLayout() !== 'carousel' && index === 0
"
(buy)="emitProductDetailBuy(item)"
/>
}

View File

@@ -143,6 +143,14 @@ describe('ProductListComponent', () => {
expect(element.querySelector('.product-list--column')).not.toBeNull();
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 () => {

View File

@@ -68,6 +68,7 @@ export class ProductListComponent {
readonly items = input.required<CatalogFeaturedItems>();
readonly loading = input(false);
readonly loadImages = input(true);
readonly prioritizeFirstImage = input(true);
readonly unavailableVariantIds = input<ReadonlySet<number>>(new Set<number>());
readonly savingProductIds = input<ReadonlySet<number>>(new Set<number>());