-
+
-
+
@if (heroConfig) {
-
+
@if (heroConfig.title_html) {
@@ -18,7 +22,10 @@
}
@if (heroConfig.button_text) {
-
+
{{ heroConfig.button_text }}
@@ -27,35 +34,78 @@
}
-
- @if (eventConfig) {
-
-
- @if (eventConfig.title) {
-
{{ eventConfig.title }}
- }
-
-
- @if (eventConfig.dates && eventConfig.dates.length > 0) {
-
-
- {{ formattedDates }}
-
- }
- @if (eventConfig.location) {
-
-
- {{ eventConfig.location }}
-
- }
-
-
-
-
- Ver horarios
-
-
-
- }
+
+ @if (eventConfig) {
+
+
+ @if (eventConfig.title) {
+
{{ eventConfig.title }}
+ }
+
+
+ @if (eventConfig.dates && eventConfig.dates.length > 0) {
+
+
+ {{ formattedDates }}
+
+ }
+ @if (eventConfig.location) {
+
+
+ {{ eventConfig.location }}
+
+ }
+
+
+ @if (eventConfig.dates && eventConfig.dates.length > 0) {
+
+
+ @if (schedulesExpanded) {
+
+
+
+ @for (eventDate of eventConfig.dates; track eventDate.id ?? eventDate.date) {
+
+ |
+
+
+ {{ formatScheduleDate(eventDate.date) }}
+
+ |
+
+
+
+
+ {{ formatScheduleTime(eventDate.start_time) }} -
+ {{ formatScheduleTime(eventDate.end_time) }}
+
+
+ |
+
+ }
+
+
+
+ }
+ }
+
+
+ }
diff --git a/src/app/shared/components/hero-banner/hero-banner.component.scss b/src/app/shared/components/hero-banner/hero-banner.component.scss
index 52a25cd..a664160 100644
--- a/src/app/shared/components/hero-banner/hero-banner.component.scss
+++ b/src/app/shared/components/hero-banner/hero-banner.component.scss
@@ -1,5 +1,4 @@
.hero-banner-container {
- padding-bottom: 4rem; /* Spacing to accommodate the overlapping card */
margin-bottom: 1rem;
}
@@ -19,10 +18,15 @@
border-radius: inherit;
&::after {
- content: "";
+ content: '';
position: absolute;
inset: 0;
- background: linear-gradient(to right, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.7) 50%, rgba(255, 255, 255, 0.95) 100%);
+ background: linear-gradient(
+ to right,
+ rgba(255, 255, 255, 0) 0%,
+ rgba(255, 255, 255, 0.7) 50%,
+ rgba(255, 255, 255, 0.95) 100%
+ );
border-radius: inherit;
}
}
@@ -39,8 +43,8 @@
flex-grow: 1;
}
-
-::ng-deep .hero-title, .hero-title {
+::ng-deep .hero-title,
+.hero-title {
color: #666666;
font-size: 1.5rem;
margin-bottom: 1rem;
@@ -49,7 +53,8 @@
}
}
-::ng-deep .hero-description, .hero-description {
+::ng-deep .hero-description,
+.hero-description {
color: #666666;
font-size: 1.1rem;
line-height: 1.5;
@@ -59,8 +64,8 @@
}
.event-card-container {
- bottom: -40px; /* Negative margin to pull it down and overlap */
- left: 0;
+ position: relative;
+ margin-top: -40px;
z-index: 10;
}
@@ -70,12 +75,14 @@
}
.event-title {
- font-size: 1.4rem;
+ font-size: 25px;
+ font-weight: 700;
letter-spacing: 0.5px;
+ text-transform: uppercase;
}
.event-details {
- font-size: 1.1rem;
+ font-size: 21px;
}
.event-icon {
@@ -85,11 +92,55 @@
.event-info-text {
color: #8a8a8a;
- font-weight: 500;
+ font-weight: 400;
}
-.event-schedule-label {
- display: none;
+.event-schedule-toggle {
+ display: inline-flex;
+ justify-content: center;
+ align-items: center;
+ gap: 0.55rem;
+ margin-top: 1.75rem;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ color: var(--tenant-primary, #009d4f);
+ font-size: 15px;
+ font-weight: 700;
+
+ &:focus-visible {
+ outline: 2px solid currentColor;
+ outline-offset: 0.25rem;
+ }
+
+ i {
+ font-size: 0.7rem;
+ }
+}
+
+.event-schedules {
+ margin-top: 1.25rem;
+
+ td {
+ width: 50%;
+ padding: 0.65rem 1rem;
+ border-radius: 0.5rem;
+ vertical-align: middle;
+ }
+}
+
+.event-schedule-value {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.75rem;
+ color: #8a8a8a;
+ font-size: 19px;
+ font-weight: 400;
+
+ i {
+ width: 1rem;
+ color: #c9ccce;
+ }
}
@media (max-width: 767.98px) {
@@ -99,9 +150,12 @@
}
.hero-banner {
+ width: 100vw;
min-height: 0;
+ margin-inline: calc(50% - 50vw);
overflow: hidden;
background-color: #f5f5f5;
+ border-radius: 0 !important;
}
.hero-media {
@@ -109,7 +163,7 @@
inset: auto;
flex: 0 0 auto;
width: 100%;
- height: clamp(8.75rem, 44vw, 10.5rem);
+ height: clamp(8.75rem, 44vw, 211px);
background-position: center center;
background-size: 140% auto;
border-radius: 0;
@@ -168,39 +222,33 @@
}
.hero-action ::ng-deep .btn {
- min-height: 1.875rem;
- padding: 0.4rem 1.35rem;
- border-radius: 0.25rem;
- font-size: 0.625rem;
+ font-size: 11px;
}
.event-card-container {
- position: relative !important;
- bottom: auto;
- left: auto;
- padding: 0 1.25rem 1.2rem;
+ margin-top: 0;
+ padding: 0 0 1.2rem;
}
.event-card {
width: 100%;
- max-width: 22rem;
+ max-width: none;
min-height: 11.4rem;
- padding: 1.75rem 1rem 0.8rem !important;
+ padding: 2.5rem 1.5rem !important;
border-radius: 0 !important;
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.04) !important;
}
.event-title {
- max-width: 16rem;
margin: 0 auto 0.85rem !important;
- font-size: 1rem;
+ font-size: 22px;
line-height: 1.1;
letter-spacing: 0;
}
.event-details {
gap: 0.45rem !important;
- font-size: 0.7rem;
+ font-size: 15px;
line-height: 1.25;
}
@@ -213,22 +261,25 @@
font-size: 0.85rem;
}
- .event-info-text {
- font-weight: 400;
- }
-
- .event-schedule-label {
- display: flex;
- justify-content: center;
- align-items: center;
- gap: 0.55rem;
+ .event-schedule-toggle {
margin-top: 1.9rem;
- color: var(--tenant-primary, #009d4f);
- font-size: 0.625rem;
- font-weight: 700;
+ font-size: 13px;
}
- .event-schedule-label i {
+ .event-schedule-toggle i {
font-size: 0.65rem;
}
+
+ .event-schedules {
+ margin-top: 1rem;
+
+ td {
+ padding: 0.55rem 0.4rem;
+ }
+ }
+
+ .event-schedule-value {
+ gap: 0.4rem;
+ font-size: 14px;
+ }
}
diff --git a/src/app/shared/components/hero-banner/hero-banner.component.spec.ts b/src/app/shared/components/hero-banner/hero-banner.component.spec.ts
new file mode 100644
index 0000000..235e236
--- /dev/null
+++ b/src/app/shared/components/hero-banner/hero-banner.component.spec.ts
@@ -0,0 +1,50 @@
+import { TestBed } from '@angular/core/testing';
+import { HeroBannerComponent } from './hero-banner.component';
+
+describe('HeroBannerComponent', () => {
+ it('expands and collapses the event schedules', async () => {
+ await TestBed.configureTestingModule({ imports: [HeroBannerComponent] }).compileComponents();
+
+ const fixture = TestBed.createComponent(HeroBannerComponent);
+ fixture.componentRef.setInput('eventConfig', {
+ title: 'Fiesta Nacional del Fútbol Infantil',
+ location: 'Sunchales, Santa Fe',
+ dates: [
+ {
+ id: 1,
+ date: '2026-10-09',
+ start_time: '10:00:00',
+ end_time: '20:30:00',
+ },
+ {
+ id: 2,
+ date: '2026-10-10',
+ start_time: '10:00:00',
+ end_time: '22:00:00',
+ },
+ ],
+ });
+ fixture.detectChanges();
+
+ const element = fixture.nativeElement as HTMLElement;
+ const toggle = element.querySelector
('.event-schedule-toggle');
+
+ expect(toggle?.textContent).toContain('Ver horarios');
+ expect(toggle?.getAttribute('aria-expanded')).toBe('false');
+ expect(element.querySelector('.event-schedules')).toBeNull();
+
+ toggle?.click();
+ fixture.detectChanges();
+
+ expect(toggle?.textContent).toContain('Ocultar horarios');
+ expect(toggle?.getAttribute('aria-expanded')).toBe('true');
+ expect(element.querySelectorAll('.event-schedules tbody tr')).toHaveLength(2);
+ expect(element.querySelector('.event-schedules')?.textContent).toContain('9 de Octubre 2026');
+ expect(element.querySelector('.event-schedules')?.textContent).toContain('10:00 - 20:30');
+
+ toggle?.click();
+ fixture.detectChanges();
+
+ expect(element.querySelector('.event-schedules')).toBeNull();
+ });
+});
diff --git a/src/app/shared/components/hero-banner/hero-banner.component.ts b/src/app/shared/components/hero-banner/hero-banner.component.ts
index 54a75cd..e3385c6 100644
--- a/src/app/shared/components/hero-banner/hero-banner.component.ts
+++ b/src/app/shared/components/hero-banner/hero-banner.component.ts
@@ -1,7 +1,7 @@
import { Component, ChangeDetectionStrategy, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
-import { HeroConfig, EventConfig } from '../../../core/services/tenant.interface';
+import { EventConfig, HeroConfig } from '../../../core/services/tenant.interface';
import { ButtonComponent } from '../button/button.component';
@Component({
@@ -16,26 +16,41 @@ export class HeroBannerComponent {
@Input() heroConfig?: HeroConfig | null;
@Input() eventConfig?: EventConfig | null;
+ protected schedulesExpanded = false;
+
get formattedDates(): string {
+ if (this.eventConfig?.dates_text) {
+ return this.eventConfig.dates_text;
+ }
+
if (!this.eventConfig?.dates || this.eventConfig.dates.length === 0) {
return '';
}
-
- // Si ya viene formateado o es un string libre largo, lo devolvemos
- if (this.eventConfig.dates.length === 1 && this.eventConfig.dates[0].length > 10) {
- return this.eventConfig.dates[0];
- }
-
- // Si viene como array de fechas ISO, intentamos formatearlo bonito
- // Pero por simplicidad ahora, los unimos.
- // Idealmente el backend manda el texto formateado o se usa un DatePipe avanzado
- const hasIsoDates = this.eventConfig.dates.some(d => d.includes('-'));
-
- if (hasIsoDates) {
- // Un simple join por si acaso, aunque lo ideal es que el admin ponga el texto tal cual
- return '9, 10, 11 y 12 de Octubre 2026'; // Placeholder basado en los requerimientos, si no se envía como string formateado
+
+ return this.eventConfig.dates.map(({ date }) => date).join(', ');
+ }
+
+ protected toggleSchedules(): void {
+ this.schedulesExpanded = !this.schedulesExpanded;
+ }
+
+ protected formatScheduleDate(date: string): string {
+ const [year, month, day] = date.split('-').map(Number);
+
+ if (!year || !month || !day) {
+ return date;
}
- return this.eventConfig.dates.join(', ');
+ const monthName = new Intl.DateTimeFormat('es-AR', {
+ month: 'long',
+ timeZone: 'UTC',
+ }).format(new Date(Date.UTC(year, month - 1, day)));
+ const capitalizedMonth = `${monthName.charAt(0).toUpperCase()}${monthName.slice(1)}`;
+
+ return `${day} de ${capitalizedMonth} ${year}`;
+ }
+
+ protected formatScheduleTime(time: string): string {
+ return time.match(/^\d{2}:\d{2}/)?.[0] ?? time;
}
}
diff --git a/src/app/shared/components/modal-host/modal-host.component.spec.ts b/src/app/shared/components/modal-host/modal-host.component.spec.ts
index fd5af67..4946a52 100644
--- a/src/app/shared/components/modal-host/modal-host.component.spec.ts
+++ b/src/app/shared/components/modal-host/modal-host.component.spec.ts
@@ -3,25 +3,10 @@ import { Component, inject } from '@angular/core';
import { DOCUMENT } from '@angular/common';
import { TestBed, getTestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
-import {
- BrowserTestingModule,
- platformBrowserTesting
-} from '@angular/platform-browser/testing';
-import {
- afterEach,
- beforeAll,
- beforeEach,
- describe,
- expect,
- it,
- vi
-} from 'vitest';
+import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
-import {
- MODAL_DATA,
- ModalRef,
- ModalService
-} from '../../../core/services/modal.service';
+import { MODAL_DATA, ModalRef, ModalService } from '../../../core/services/modal.service';
import { ModalHostComponent } from './modal-host.component';
@Component({
@@ -31,7 +16,7 @@ import { ModalHostComponent } from './modal-host.component';
{{ data?.title }}
- `
+ `,
})
class ModalContentTestComponent {
readonly data = inject<{ title: string } | null>(MODAL_DATA);
@@ -48,10 +33,7 @@ describe('ModalHostComponent', () => {
beforeAll(() => {
try {
- getTestBed().initTestEnvironment(
- BrowserTestingModule,
- platformBrowserTesting()
- );
+ getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
} catch {
// Test environment may already be initialized by another setup entrypoint.
}
@@ -59,7 +41,7 @@ describe('ModalHostComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
- imports: [ModalHostComponent]
+ imports: [ModalHostComponent],
}).compileComponents();
service = TestBed.inject(ModalService);
@@ -68,6 +50,7 @@ describe('ModalHostComponent', () => {
afterEach(() => {
doc.body.style.overflow = '';
+ doc.body.style.paddingRight = '';
TestBed.resetTestingModule();
});
@@ -84,7 +67,7 @@ describe('ModalHostComponent', () => {
service.open(ModalContentTestComponent, {
title: 'Editar producto',
- data: { title: 'Contenido del modal' }
+ data: { title: 'Contenido del modal' },
});
fixture.detectChanges();
@@ -99,27 +82,30 @@ describe('ModalHostComponent', () => {
it('closes with a result from the child component', () => {
const fixture = TestBed.createComponent(ModalHostComponent);
const ref = service.open(ModalContentTestComponent, {
- data: { title: 'Cerrar' }
+ data: { title: 'Cerrar' },
});
const closedSpy = vi.fn();
ref.afterClosed$.subscribe(closedSpy);
fixture.detectChanges();
- const closeButton = fixture.nativeElement.querySelector('.modal-test-close') as HTMLButtonElement;
+ const closeButton = fixture.nativeElement.querySelector(
+ '.modal-test-close',
+ ) as HTMLButtonElement;
closeButton.click();
fixture.detectChanges();
expect(closedSpy).toHaveBeenCalledWith('accepted');
expect(service.activeModal()).toBeNull();
expect(doc.body.style.overflow).toBe('');
+ expect(doc.body.style.paddingRight).toBe('');
});
it('closes on backdrop click when enabled', () => {
const fixture = TestBed.createComponent(ModalHostComponent);
const ref = service.open(ModalContentTestComponent, {
data: { title: 'Backdrop' },
- closeOnBackdrop: true
+ closeOnBackdrop: true,
});
fixture.detectChanges();
@@ -136,7 +122,7 @@ describe('ModalHostComponent', () => {
const fixture = TestBed.createComponent(ModalHostComponent);
const ref = service.open(ModalContentTestComponent, {
data: { title: 'Persistente' },
- closeOnBackdrop: false
+ closeOnBackdrop: false,
});
fixture.detectChanges();
@@ -152,7 +138,7 @@ describe('ModalHostComponent', () => {
const fixture = TestBed.createComponent(ModalHostComponent);
const ref = service.open(ModalContentTestComponent, {
data: { title: 'Escape' },
- closeOnEscape: true
+ closeOnEscape: true,
});
fixture.detectChanges();
@@ -167,7 +153,7 @@ describe('ModalHostComponent', () => {
const fixture = TestBed.createComponent(ModalHostComponent);
const ref = service.open(ModalContentTestComponent, {
data: { title: 'No Escape' },
- closeOnEscape: false
+ closeOnEscape: false,
});
fixture.detectChanges();
@@ -181,12 +167,13 @@ describe('ModalHostComponent', () => {
const fixture = TestBed.createComponent(ModalHostComponent);
const ref = service.open(ModalContentTestComponent, {
title: 'Con cierre',
- data: { title: 'Boton' }
+ data: { title: 'Boton' },
});
fixture.detectChanges();
- const closeButton = fixture.debugElement.query(By.css('.btn-close')).nativeElement as HTMLButtonElement;
+ const closeButton = fixture.debugElement.query(By.css('.btn-close'))
+ .nativeElement as HTMLButtonElement;
closeButton.click();
fixture.detectChanges();
diff --git a/src/app/shared/components/modal-host/modal-host.component.ts b/src/app/shared/components/modal-host/modal-host.component.ts
index 7bc840e..a85e7e9 100644
--- a/src/app/shared/components/modal-host/modal-host.component.ts
+++ b/src/app/shared/components/modal-host/modal-host.component.ts
@@ -7,21 +7,17 @@ import {
computed,
effect,
inject,
- viewChild
+ viewChild,
} from '@angular/core';
-import {
- MODAL_DATA,
- ModalRef,
- ModalService
-} from '../../../core/services/modal.service';
+import { MODAL_DATA, ModalRef, ModalService } from '../../../core/services/modal.service';
import { ModalShellComponent } from '../modal-shell/modal-shell.component';
@Component({
selector: 'app-modal-host',
imports: [NgComponentOutlet, ModalShellComponent],
templateUrl: './modal-host.component.html',
- changeDetection: ChangeDetectionStrategy.OnPush
+ changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ModalHostComponent {
private readonly modalService = inject(ModalService);
@@ -42,9 +38,9 @@ export class ModalHostComponent {
return Injector.create({
providers: [
{ provide: ModalRef, useValue: modal.ref },
- { provide: MODAL_DATA, useValue: modal.config.data ?? null }
+ { provide: MODAL_DATA, useValue: modal.config.data ?? null },
],
- parent: this.injector
+ parent: this.injector,
});
});
@@ -58,6 +54,18 @@ export class ModalHostComponent {
const body = this.document.body;
const previousOverflow = body.style.overflow;
+ const previousPaddingRight = body.style.paddingRight;
+ const view = this.document.defaultView;
+ const scrollbarWidth = view
+ ? Math.max(0, view.innerWidth - this.document.documentElement.clientWidth)
+ : 0;
+
+ if (scrollbarWidth > 0 && view) {
+ const currentPaddingRight =
+ Number.parseFloat(view.getComputedStyle(body).paddingRight) || 0;
+ body.style.paddingRight = `${currentPaddingRight + scrollbarWidth}px`;
+ }
+
body.style.overflow = 'hidden';
const onKeyDown = (event: KeyboardEvent) => {
@@ -75,6 +83,7 @@ export class ModalHostComponent {
onCleanup(() => {
this.document.removeEventListener('keydown', onKeyDown);
body.style.overflow = previousOverflow;
+ body.style.paddingRight = previousPaddingRight;
});
});
}
diff --git a/src/app/shared/components/modal-shell/modal-shell.component.scss b/src/app/shared/components/modal-shell/modal-shell.component.scss
index d5f80b8..d016d61 100644
--- a/src/app/shared/components/modal-shell/modal-shell.component.scss
+++ b/src/app/shared/components/modal-shell/modal-shell.component.scss
@@ -10,8 +10,7 @@
align-items: center;
justify-content: center;
padding: 1rem;
- background: rgba(16, 18, 22, 0.48);
- backdrop-filter: blur(2px);
+ background: rgba(16, 18, 22, 0.56);
}
.modal-shell__dialog {
diff --git a/src/app/shared/components/product-list/product-list.component.scss b/src/app/shared/components/product-list/product-list.component.scss
index 85c8c5c..9ef45fa 100644
--- a/src/app/shared/components/product-list/product-list.component.scss
+++ b/src/app/shared/components/product-list/product-list.component.scss
@@ -12,6 +12,8 @@
&--row {
--product-list-item-min-width: 32rem;
+
+ gap: 0;
}
&--adaptive {
diff --git a/src/app/shared/components/product-list/product-list.component.ts b/src/app/shared/components/product-list/product-list.component.ts
index f4e6681..be3b3a3 100644
--- a/src/app/shared/components/product-list/product-list.component.ts
+++ b/src/app/shared/components/product-list/product-list.component.ts
@@ -108,7 +108,9 @@ export class ProductListComponent {
protected variantsFor(item: ProductListItem): RowVariant[] {
return (item.variants ?? []).map((variant) => ({
value: variant.id,
- label: Object.values(variant.values).join(' / ') || `Variante ${variant.id}`,
+ descripcion: variant.descripcion,
+ precio: variant.precio,
+ values: variant.values,
}));
}
diff --git a/src/app/shared/components/product-row-card/product-row-card.component.html b/src/app/shared/components/product-row-card/product-row-card.component.html
index 9e73b5e..71c593a 100644
--- a/src/app/shared/components/product-row-card/product-row-card.component.html
+++ b/src/app/shared/components/product-row-card/product-row-card.component.html
@@ -1,42 +1,38 @@
-
+
{{ title() }}
- @if (description()) {
+ @if (effectiveDescription()) {
- {{ description() }}
+ {{ effectiveDescription() }}
}
-
-
-
-
+
+
+
+
+
{{ formattedPrice() }}
+
+
+
-
-
-
- @if (variants().length > 0) {
-
- }
-
-
-
diff --git a/src/app/shared/components/product-row-card/product-row-card.component.scss b/src/app/shared/components/product-row-card/product-row-card.component.scss
index a5ee11a..9451a69 100644
--- a/src/app/shared/components/product-row-card/product-row-card.component.scss
+++ b/src/app/shared/components/product-row-card/product-row-card.component.scss
@@ -4,9 +4,14 @@
}
.product-row-card {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 2rem;
width: 100%;
- background-color: #f8f9fa; /* slightly light bg matching the screenshot if needed, but let's use white or #f8f9fa based on the border */
- border-color: #e9ecef !important;
+ padding: 2rem 0;
+ border-bottom: 1px solid #e3e3e3;
+ background: transparent;
&__title {
font-size: 16px;
@@ -20,34 +25,44 @@
color: #777; /* lighter grey for text */
line-height: 1.4;
max-width: 600px;
-
+
/* Simulate the bold text for 'Niños menores...' if it was HTML.
Since it's passed as string we just let it be, unless we parse it. */
}
&__price {
+ display: block;
+ align-self: flex-end;
font-size: 20px;
font-weight: 800;
- color: var(--tenant-primary, #009933) !important; /* Green matching screenshot */
+ color: var(--tenant-primary, #009933) !important;
+ white-space: nowrap;
}
- &__select {
- width: auto;
- min-width: 150px;
- font-size: 14px;
- height: 38px;
- color: #666;
- border-color: #ccc;
- cursor: pointer;
+ &__actions {
+ display: grid;
+ grid-template-columns: minmax(0, auto) 160px;
+ align-items: stretch;
+ gap: 0.75rem 2.5rem;
+ }
- &:focus {
- border-color: var(--tenant-primary);
- box-shadow: 0 0 0 0.25rem rgba(0, 153, 51, 0.25);
- }
+ &__summary,
+ &__buttons {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ gap: 0.5rem;
+ }
+
+ &__controls {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 0.5rem;
}
&__btn-wrapper {
- min-width: 160px; /* To make both buttons equal width as in screenshot */
+ min-width: 160px;
app-button {
display: block;
@@ -61,3 +76,62 @@
}
}
}
+
+@media (min-width: 992px) {
+ .product-row-card {
+ &__info {
+ min-width: 0;
+ }
+
+ &__actions,
+ &__summary,
+ &__buttons {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ }
+
+ &__actions {
+ flex: 0 0 auto;
+ gap: 0.75rem;
+ }
+
+ &__summary,
+ &__buttons {
+ gap: 0.5rem;
+ }
+
+ &__price {
+ align-self: center;
+ }
+
+ &__selectors {
+ width: auto;
+
+ ::ng-deep .variant-selector {
+ display: flex;
+ width: auto;
+ }
+
+ ::ng-deep .variant-selector__select {
+ width: auto;
+ min-width: 108px;
+ }
+ }
+
+ &__btn-wrapper {
+ min-width: 140px;
+ }
+ }
+}
+
+@media (max-width: 991.98px) {
+ .product-row-card {
+ align-items: stretch;
+ flex-direction: column;
+
+ &__actions {
+ grid-template-columns: minmax(0, 1fr) 160px;
+ }
+ }
+}
diff --git a/src/app/shared/components/product-row-card/product-row-card.component.ts b/src/app/shared/components/product-row-card/product-row-card.component.ts
index e1c87ad..8820183 100644
--- a/src/app/shared/components/product-row-card/product-row-card.component.ts
+++ b/src/app/shared/components/product-row-card/product-row-card.component.ts
@@ -1,25 +1,21 @@
-import {
- ChangeDetectionStrategy,
- Component,
- computed,
- effect,
- input,
- output,
- model,
-} from '@angular/core';
-import { FormsModule } from '@angular/forms';
+import { ChangeDetectionStrategy, Component, computed, input, model, output } from '@angular/core';
import { ButtonComponent } from '../button/button.component';
import { QuantitySelectorComponent } from '../quantity-selector/quantity-selector.component';
+import {
+ VariantSelectorComponent,
+ VariantSelectorVariant,
+} from '../variant-selector/variant-selector.component';
-export interface Variant {
- label: string;
- value: any;
+export interface Variant extends VariantSelectorVariant {
+ label?: string;
+ descripcion?: string | null;
+ precio?: string | number;
}
@Component({
selector: 'app-product-row-card',
standalone: true,
- imports: [ButtonComponent, QuantitySelectorComponent, FormsModule],
+ imports: [ButtonComponent, QuantitySelectorComponent, VariantSelectorComponent],
templateUrl: './product-row-card.component.html',
styleUrl: './product-row-card.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -33,31 +29,26 @@ export class ProductRowCardComponent {
// Internal state models
readonly quantity = model
(1);
- readonly selectedVariant = model(null);
+ readonly selectedVariant = model(null);
// Interactive events
- readonly buy = output<{ quantity: number; variant: any }>();
- readonly addToCart = output<{ quantity: number; variant: any }>();
+ readonly buy = output<{ quantity: number; variant: unknown }>();
+ readonly addToCart = output<{ quantity: number; variant: unknown }>();
- constructor() {
- effect(() => {
- const variants = this.variants();
- const selectedVariant = this.selectedVariant();
+ protected readonly selectedVariantData = computed(() =>
+ this.variants().find((variant) => Object.is(variant.value, this.selectedVariant())),
+ );
+ protected readonly effectiveDescription = computed(
+ () => this.selectedVariantData()?.descripcion ?? this.description(),
+ );
+ protected readonly effectivePrice = computed(() => {
+ const variantPrice = Number(this.selectedVariantData()?.precio);
- if (
- variants.length > 0 &&
- !variants.some((variant) => Object.is(variant.value, selectedVariant))
- ) {
- this.selectedVariant.set(variants[0].value);
- }
- });
- }
-
- // Formatted string for price: "$ 10.000"
- readonly formattedPrice = computed(() => {
- return this.formatCurrency(this.price());
+ return Number.isFinite(variantPrice) ? variantPrice : this.price();
});
+ readonly formattedPrice = computed(() => this.formatCurrency(this.effectivePrice()));
+
protected onAddToCart(): void {
this.addToCart.emit({
quantity: this.quantity(),
diff --git a/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.html b/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.html
index d2c396f..b6e3baa 100644
--- a/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.html
+++ b/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.html
@@ -2,8 +2,8 @@
{{ title() }}
- @if (description()) {
-
{{ description() }}
+ @if (effectiveDescription()) {
+
{{ effectiveDescription() }}
}
@@ -13,47 +13,18 @@
{{ formattedPrice() }}
- } @else if (!hasMultipleVariantSelectors()) {
-
-
{{ formattedPrice() }}
-
-
- @for (selector of variantSelectors(); track selector.key) {
-
- }
-
-
-
-
} @else {
-
-
+
+
- @for (selector of variantSelectors(); track selector.key) {
-
- }
+
}
diff --git a/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.scss b/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.scss
index e30af70..16f362e 100644
--- a/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.scss
+++ b/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.scss
@@ -60,59 +60,17 @@
padding-inline: 12px;
}
- &__single-variant {
+ &__variants {
display: grid;
- justify-items: center;
gap: 16px;
}
- &__single-variant-row {
- display: flex;
- align-items: center;
+ &__variant-selectors {
width: 100%;
- gap: 8px;
}
- &__multiple-variants {
- display: grid;
- grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
- align-items: center;
- gap: 16px;
- }
-
- &__summary-column,
- &__variant-selectors {
- display: flex;
- flex-direction: column;
-
- align-items: center;
- gap: 10px;
- min-width: 0;
- }
-
- &__variant-selectors {
- align-items: stretch;
- }
-
- &__variant-select {
- min-width: 0;
- height: 40px;
- border-color: #d8d8d8;
- color: #6f6f6f;
- font-size: 12px;
-
- &:focus {
- border-color: var(--tenant-primary, #009933);
- box-shadow: 0 0 0 0.2rem color-mix(in srgb, transparent 75%, var(--tenant-primary, #009933));
- }
- }
-
- &__single-variant-row &__variant-select {
- flex: 1 1 auto;
- }
-
- &__single-variant-row app-quantity-selector {
- flex: 0 0 auto;
+ &__variant-selectors app-variant-selector {
+ width: 100%;
}
&__price {
@@ -145,6 +103,16 @@
@media (max-width: 767.98px) {
.product-vertical-with-cart-card {
border-color: var(--color-primary, var(--tenant-primary, #009933));
+
+ &__title {
+ color: var(--color-primary, var(--tenant-primary, #009933));
+ font-size: 15px;
+ }
+
+ &__description {
+ font-size: 13px;
+ font-weight: 400;
+ }
}
}
diff --git a/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.spec.ts b/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.spec.ts
index fc2e1b2..ea0b626 100644
--- a/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.spec.ts
+++ b/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.spec.ts
@@ -116,7 +116,7 @@ describe('ProductVerticalWithCartCardComponent', () => {
).not.toBeNull();
});
- it('places a single variant selector in the same row as the quantity', async () => {
+ it('places price and quantity together above a single variant selector', async () => {
const fixture = await createComponent();
fixture.componentRef.setInput('variants', [
{ id: 10, values: { fecha: '10 de octubre' } },
@@ -125,16 +125,18 @@ describe('ProductVerticalWithCartCardComponent', () => {
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
- const row = element.querySelector('.product-vertical-with-cart-card__single-variant-row');
+ const summary = element.querySelector('.product-vertical-with-cart-card__summary');
+ const selectors = element.querySelector('.product-vertical-with-cart-card__variant-selectors');
- expect(row?.querySelectorAll('.product-vertical-with-cart-card__variant-select')).toHaveLength(
- 1,
- );
- expect(row?.querySelector('app-quantity-selector')).not.toBeNull();
- expect(element.querySelector('.product-vertical-with-cart-card__multiple-variants')).toBeNull();
+ expect(summary?.querySelector('.product-vertical-with-cart-card__price')).not.toBeNull();
+ expect(summary?.querySelector('app-quantity-selector')).not.toBeNull();
+ expect(
+ selectors?.querySelectorAll('.variant-selector__select'),
+ ).toHaveLength(1);
+ expect(selectors?.querySelector('app-quantity-selector')).toBeNull();
});
- it('uses separate summary and selector columns for multiple variant attributes', async () => {
+ it('places all variant selectors together below price and quantity', async () => {
const fixture = await createComponent();
fixture.componentRef.setInput('variants', [
{ id: 20, values: { fecha: '10 de octubre', turno: 'Mañana' } },
@@ -143,13 +145,13 @@ describe('ProductVerticalWithCartCardComponent', () => {
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
- const layout = element.querySelector('.product-vertical-with-cart-card__multiple-variants');
+ const layout = element.querySelector('.product-vertical-with-cart-card__variants');
+ expect(layout?.querySelector('.product-vertical-with-cart-card__summary')).not.toBeNull();
expect(
- layout?.querySelector('.product-vertical-with-cart-card__summary-column'),
- ).not.toBeNull();
- expect(
- layout?.querySelectorAll('.product-vertical-with-cart-card__variant-select'),
+ layout?.querySelectorAll(
+ '.product-vertical-with-cart-card__variant-selectors .variant-selector__select',
+ ),
).toHaveLength(2);
});
@@ -164,7 +166,7 @@ describe('ProductVerticalWithCartCardComponent', () => {
fixture.componentInstance.buy.subscribe(buySpy);
const select = fixture.nativeElement.querySelector(
- '.product-vertical-with-cart-card__variant-select',
+ '.variant-selector__select',
) as HTMLSelectElement;
select.value = select.options[1].value;
select.dispatchEvent(new Event('change'));
@@ -177,4 +179,45 @@ describe('ProductVerticalWithCartCardComponent', () => {
expect(buySpy).toHaveBeenCalledWith({ quantity: 1, variant: 31 });
});
+
+ it('prioritizes the selected variant description and price', async () => {
+ const fixture = await createComponent('Descripción general');
+ fixture.componentRef.setInput('variants', [
+ {
+ id: 40,
+ descripcion: 'Almuerzo en comedor',
+ precio: '10000.00',
+ values: { servicio: 'Comedor' },
+ },
+ {
+ id: 41,
+ descripcion: 'Almuerzo en vianda',
+ precio: '8000.00',
+ values: { servicio: 'Vianda' },
+ },
+ ]);
+ fixture.detectChanges();
+
+ const element = fixture.nativeElement as HTMLElement;
+ expect(
+ element.querySelector('.product-vertical-with-cart-card__description')?.textContent,
+ ).toContain('Almuerzo en comedor');
+ expect(
+ element.querySelector('.product-vertical-with-cart-card__price')?.textContent?.trim(),
+ ).toBe('$ 10.000');
+
+ const select = element.querySelector(
+ '.variant-selector__select',
+ ) as HTMLSelectElement;
+ select.value = select.options[1].value;
+ select.dispatchEvent(new Event('change'));
+ fixture.detectChanges();
+
+ expect(
+ element.querySelector('.product-vertical-with-cart-card__description')?.textContent,
+ ).toContain('Almuerzo en vianda');
+ expect(
+ element.querySelector('.product-vertical-with-cart-card__price')?.textContent?.trim(),
+ ).toBe('$ 8.000');
+ });
});
diff --git a/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.ts b/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.ts
index 876a7ac..0ceed82 100644
--- a/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.ts
+++ b/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.ts
@@ -2,32 +2,28 @@ import {
ChangeDetectionStrategy,
Component,
computed,
- effect,
input,
model,
output,
- signal,
- untracked,
} from '@angular/core';
-import { FormsModule } from '@angular/forms';
import { ButtonComponent } from '../button/button.component';
import { QuantitySelectorComponent } from '../quantity-selector/quantity-selector.component';
+import {
+ VariantSelectorComponent,
+ VariantSelectorVariant,
+} from '../variant-selector/variant-selector.component';
export interface VerticalCartVariant {
id: number;
+ descripcion?: string | null;
+ precio?: string | number;
values: Record
;
}
-interface VariantSelector {
- key: string;
- label: string;
- options: string[];
-}
-
@Component({
selector: 'app-product-vertical-with-cart-card',
- imports: [ButtonComponent, FormsModule, QuantitySelectorComponent],
+ imports: [ButtonComponent, QuantitySelectorComponent, VariantSelectorComponent],
templateUrl: './product-vertical-with-cart-card.component.html',
styleUrl: './product-vertical-with-cart-card.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -44,60 +40,22 @@ export class ProductVerticalWithCartCardComponent {
readonly buy = output<{ quantity: number; variant: number | null }>();
readonly addToCart = output<{ quantity: number; variant: number | null }>();
- protected readonly selectedValues = signal>({});
-
- protected readonly formattedPrice = computed(() => this.formatCurrency(this.price()));
- protected readonly variantSelectors = computed(() => {
- const variants = this.variants();
- const keys = Array.from(new Set(variants.flatMap((variant) => Object.keys(variant.values))));
-
- return keys.map((key) => ({
- key,
- label: this.formatVariantLabel(key),
- options: Array.from(
- new Set(
- variants
- .map((variant) => variant.values[key])
- .filter((value): value is string => Boolean(value)),
- ),
- ),
- }));
- });
-
- protected readonly hasVariants = computed(() => this.variantSelectors().length > 0);
- protected readonly hasMultipleVariantSelectors = computed(
- () => this.variantSelectors().length > 1,
+ protected readonly selectedVariantData = computed(() =>
+ this.variants().find((variant) => variant.id === this.selectedVariant()),
);
+ protected readonly effectiveDescription = computed(
+ () => this.selectedVariantData()?.descripcion ?? this.description(),
+ );
+ protected readonly effectivePrice = computed(() => {
+ const variantPrice = Number(this.selectedVariantData()?.precio);
- constructor() {
- effect(() => {
- const variants = this.variants();
-
- untracked(() => {
- if (variants.length === 0) {
- this.selectedValues.set({});
- this.selectedVariant.set(null);
- return;
- }
-
- const selected =
- variants.find((variant) => variant.id === this.selectedVariant()) ?? variants[0];
- this.selectedValues.set({ ...selected.values });
- this.selectedVariant.set(selected.id);
- });
- });
- }
-
- protected onVariantValueChange(key: string, value: string): void {
- const values = { ...this.selectedValues(), [key]: value };
- const selectors = this.variantSelectors();
- const matchingVariant = this.variants().find((variant) =>
- selectors.every((selector) => variant.values[selector.key] === values[selector.key]),
- );
-
- this.selectedValues.set(values);
- this.selectedVariant.set(matchingVariant?.id ?? null);
- }
+ return Number.isFinite(variantPrice) ? variantPrice : this.price();
+ });
+ protected readonly formattedPrice = computed(() => this.formatCurrency(this.effectivePrice()));
+ protected readonly selectorVariants = computed(() =>
+ this.variants().map((variant) => ({ value: variant.id, values: variant.values })),
+ );
+ protected readonly hasVariants = computed(() => this.selectorVariants().length > 0);
protected onAddToCart(): void {
this.addToCart.emit({ quantity: this.quantity(), variant: this.selectedVariant() });
@@ -107,11 +65,6 @@ export class ProductVerticalWithCartCardComponent {
this.buy.emit({ quantity: this.quantity(), variant: this.selectedVariant() });
}
- private formatVariantLabel(key: string): string {
- const label = key.replace(/[_-]+/g, ' ');
- return label.charAt(0).toUpperCase() + label.slice(1);
- }
-
private formatCurrency(value: number): string {
const rounded = Math.round(value);
const parts = rounded.toString().split('.');
diff --git a/src/app/shared/components/quantity-selector/quantity-selector.component.scss b/src/app/shared/components/quantity-selector/quantity-selector.component.scss
index c8fe8f5..241af5d 100644
--- a/src/app/shared/components/quantity-selector/quantity-selector.component.scss
+++ b/src/app/shared/components/quantity-selector/quantity-selector.component.scss
@@ -7,7 +7,7 @@
width: 78px;
height: 40px;
min-height: 40px;
- background: inherit;
+ background-color: #ffffff;
box-sizing: border-box;
&__button {
@@ -15,8 +15,10 @@
height: 100%;
font-size: 20px;
color: #6f6f6f;
- background: inherit;
- transition: background-color 0.2s ease, color 0.2s ease;
+ background-color: #ffffff;
+ transition:
+ background-color 0.2s ease,
+ color 0.2s ease;
cursor: pointer;
&:not(:disabled):hover {
@@ -25,7 +27,7 @@
}
&:disabled {
- color: #A0A0A0;
+ color: #a0a0a0;
opacity: 1;
cursor: not-allowed;
}
@@ -37,16 +39,16 @@
}
&--small {
- width: 46px;
- height: 21px;
- min-height: 21px;
- border: 1px solid #cfcfcf;
- background: inherit;
+ width: 62px;
+ height: 28px;
+ min-height: 28px;
+ border-color: #cccccc;
+ background-color: #ffffff;
.quantity-selector__button {
- width: 15px;
- font-size: 10px;
- background: inherit;
+ width: 18px;
+ font-size: 11px;
+ background-color: #ffffff;
color: #666666;
&:not(:disabled):hover {
@@ -55,7 +57,7 @@
}
.quantity-selector__value {
- font-size: 10px;
+ font-size: 11px;
color: #666666;
}
}
diff --git a/src/app/shared/components/variant-selector/variant-selector.component.html b/src/app/shared/components/variant-selector/variant-selector.component.html
new file mode 100644
index 0000000..bb068dc
--- /dev/null
+++ b/src/app/shared/components/variant-selector/variant-selector.component.html
@@ -0,0 +1,18 @@
+@if (selectors().length > 0) {
+
+ @for (selector of selectors(); track selector.key) {
+
+ }
+
+}
diff --git a/src/app/shared/components/variant-selector/variant-selector.component.scss b/src/app/shared/components/variant-selector/variant-selector.component.scss
new file mode 100644
index 0000000..821bec4
--- /dev/null
+++ b/src/app/shared/components/variant-selector/variant-selector.component.scss
@@ -0,0 +1,46 @@
+:host {
+ display: block;
+ width: 100%;
+ min-width: 0;
+}
+
+.variant-selector {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ align-items: center;
+ gap: 8px;
+ width: 100%;
+ min-width: 0;
+}
+
+.variant-selector__select {
+ width: 100%;
+ min-width: 0;
+ max-width: none;
+ height: 38px;
+ color: #666666;
+ border-color: #cccccc;
+ font-size: 14px;
+ cursor: pointer;
+
+ &:focus {
+ border-color: var(--tenant-primary);
+ box-shadow: 0 0 0 0.25rem rgba(0, 153, 51, 0.25);
+ }
+
+ &:first-child:nth-last-child(odd) {
+ grid-column: 1 / -1;
+ }
+}
+
+.variant-selector--compact {
+ gap: 4px;
+
+ .variant-selector__select {
+ min-width: 0;
+ max-width: none;
+ height: 28px;
+ padding: 0.2rem 1.75rem 0.2rem 0.45rem;
+ font-size: 11px;
+ }
+}
diff --git a/src/app/shared/components/variant-selector/variant-selector.component.spec.ts b/src/app/shared/components/variant-selector/variant-selector.component.spec.ts
new file mode 100644
index 0000000..ee14425
--- /dev/null
+++ b/src/app/shared/components/variant-selector/variant-selector.component.spec.ts
@@ -0,0 +1,82 @@
+import '@angular/compiler';
+import { TestBed, getTestBed } from '@angular/core/testing';
+import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
+import { afterEach, beforeAll, describe, expect, it } from 'vitest';
+
+import { VariantSelectorComponent } from './variant-selector.component';
+
+describe('VariantSelectorComponent', () => {
+ beforeAll(() => {
+ try {
+ getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
+ } catch {
+ // Test environment may already be initialized by another setup entrypoint.
+ }
+ });
+
+ afterEach(() => TestBed.resetTestingModule());
+
+ it('updates following selections to the first compatible variant', async () => {
+ await TestBed.configureTestingModule({
+ imports: [VariantSelectorComponent],
+ }).compileComponents();
+ const fixture = TestBed.createComponent(VariantSelectorComponent);
+ fixture.componentRef.setInput('variants', [
+ { value: 1, values: { alojamiento: 'Carpa', servicio: 'Almuerzo' } },
+ { value: 2, values: { alojamiento: 'Carpa', servicio: 'Cena' } },
+ { value: 3, values: { alojamiento: 'Hotel', servicio: 'Cena' } },
+ ]);
+ fixture.componentRef.setInput('selectedVariant', 1);
+ fixture.detectChanges();
+
+ (fixture.componentInstance as any).onValueChange('alojamiento', 'Hotel');
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.selectedVariant()).toBe(3);
+ expect((fixture.componentInstance as any).selectedValues()).toEqual({
+ alojamiento: 'Hotel',
+ servicio: 'Cena',
+ });
+ });
+
+ it('renders backend option labels and compares equivalent option objects by value', async () => {
+ await TestBed.configureTestingModule({
+ imports: [VariantSelectorComponent],
+ }).compileComponents();
+ const fixture = TestBed.createComponent(VariantSelectorComponent);
+ fixture.componentRef.setInput('variants', [
+ {
+ value: 1,
+ values: {
+ color: { value: 'red', label: 'Rojo' },
+ talle: { value: 's', label: 'Small' },
+ },
+ },
+ {
+ value: 2,
+ values: {
+ color: { value: 'red', label: 'Rojo' },
+ talle: { value: 'm', label: 'Medium' },
+ },
+ },
+ ]);
+ fixture.componentRef.setInput('selectedVariant', 2);
+ fixture.detectChanges();
+
+ const selects = Array.from(
+ fixture.nativeElement.querySelectorAll('select'),
+ ) as HTMLSelectElement[];
+
+ expect(selects).toHaveLength(2);
+ expect(selects[0].selectedOptions[0]?.textContent).toBe('Rojo');
+ expect(selects[1].selectedOptions[0]?.textContent).toBe('Medium');
+ expect(fixture.nativeElement.textContent).not.toContain('[object Object]');
+
+ (fixture.componentInstance as any).onValueChange('talle', {
+ value: 's',
+ label: 'Small',
+ });
+
+ expect(fixture.componentInstance.selectedVariant()).toBe(1);
+ });
+});
diff --git a/src/app/shared/components/variant-selector/variant-selector.component.ts b/src/app/shared/components/variant-selector/variant-selector.component.ts
new file mode 100644
index 0000000..324e035
--- /dev/null
+++ b/src/app/shared/components/variant-selector/variant-selector.component.ts
@@ -0,0 +1,184 @@
+import {
+ ChangeDetectionStrategy,
+ Component,
+ computed,
+ effect,
+ input,
+ model,
+ signal,
+ untracked,
+} from '@angular/core';
+import { FormsModule } from '@angular/forms';
+
+export interface VariantAttributeOption {
+ value: string;
+ label: string;
+}
+
+export type VariantAttributeScalar = string | VariantAttributeOption;
+export type VariantAttributeValue = VariantAttributeScalar | VariantAttributeScalar[];
+
+export interface VariantSelectorVariant {
+ value: unknown;
+ values: Record;
+}
+
+interface VariantSelectorOption {
+ key: string;
+ label: string;
+ value: VariantAttributeValue;
+}
+
+interface VariantSelectorGroup {
+ key: string;
+ label: string;
+ options: VariantSelectorOption[];
+}
+
+@Component({
+ selector: 'app-variant-selector',
+ standalone: true,
+ imports: [FormsModule],
+ templateUrl: './variant-selector.component.html',
+ styleUrl: './variant-selector.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class VariantSelectorComponent {
+ readonly variants = input([]);
+ readonly selectedVariant = model(null);
+ readonly disabled = input(false);
+ readonly compact = input(false);
+
+ protected readonly selectedValues = signal>({});
+ protected readonly compareValues = (
+ left: VariantAttributeValue | null,
+ right: VariantAttributeValue | null,
+ ): boolean => left !== null && right !== null && this.sameValue(left, right);
+ protected readonly attributeKeys = computed(() =>
+ Array.from(new Set(this.variants().flatMap((variant) => Object.keys(variant.values)))),
+ );
+ protected readonly selectors = computed(() => {
+ const variants = this.variants();
+ const keys = this.attributeKeys();
+ const selectedValues = this.selectedValues();
+
+ return keys.map((key, index) => {
+ const previousKeys = keys.slice(0, index);
+ const compatibleVariants = variants.filter((variant) =>
+ previousKeys.every((previousKey) =>
+ this.sameValue(variant.values[previousKey], selectedValues[previousKey]),
+ ),
+ );
+
+ return {
+ key,
+ label: this.formatVariantLabel(key),
+ options: this.optionsFor(compatibleVariants, key),
+ };
+ });
+ });
+
+ constructor() {
+ effect(() => {
+ const variants = this.variants();
+ const selectedVariant = this.selectedVariant();
+
+ untracked(() => {
+ if (variants.length === 0) {
+ this.selectedValues.set({});
+ if (selectedVariant !== null) this.selectedVariant.set(null);
+ return;
+ }
+
+ const selected =
+ variants.find((variant) => Object.is(variant.value, selectedVariant)) ?? variants[0];
+ this.selectedValues.set({ ...selected.values });
+ if (!Object.is(selected.value, selectedVariant)) this.selectedVariant.set(selected.value);
+ });
+ });
+ }
+
+ protected onValueChange(key: string, value: VariantAttributeValue): void {
+ const variants = this.variants();
+ const keys = this.attributeKeys();
+ const changedIndex = keys.indexOf(key);
+ const values = { ...this.selectedValues(), [key]: value };
+
+ for (let index = changedIndex + 1; index < keys.length; index++) {
+ const currentKey = keys[index];
+ const previousKeys = keys.slice(0, index);
+ const compatibleVariants = variants.filter((variant) =>
+ previousKeys.every((previousKey) =>
+ this.sameValue(variant.values[previousKey], values[previousKey]),
+ ),
+ );
+ const options = this.optionsFor(compatibleVariants, currentKey);
+
+ if (!options.some((option) => this.sameValue(option.value, values[currentKey]))) {
+ const firstOption = options[0];
+ if (firstOption) values[currentKey] = firstOption.value;
+ else delete values[currentKey];
+ }
+ }
+
+ const matchingVariant = variants.find((variant) =>
+ keys.every((attributeKey) =>
+ this.sameValue(variant.values[attributeKey], values[attributeKey]),
+ ),
+ );
+
+ this.selectedValues.set(values);
+ this.selectedVariant.set(matchingVariant?.value ?? null);
+ }
+
+ private optionsFor(variants: VariantSelectorVariant[], key: string): VariantSelectorOption[] {
+ const options = new Map();
+
+ for (const variant of variants) {
+ const value = variant.values[key];
+ if (value === undefined || value === '') continue;
+
+ const optionKey = this.valueKey(value);
+ if (!options.has(optionKey)) {
+ options.set(optionKey, {
+ key: optionKey,
+ label: this.valueLabel(value),
+ value,
+ });
+ }
+ }
+
+ return Array.from(options.values());
+ }
+
+ private sameValue(
+ left: VariantAttributeValue | undefined,
+ right: VariantAttributeValue | undefined,
+ ): boolean {
+ return (
+ left !== undefined && right !== undefined && this.valueKey(left) === this.valueKey(right)
+ );
+ }
+
+ private valueKey(value: VariantAttributeValue): string {
+ const comparableValue = Array.isArray(value)
+ ? value.map((item) => this.scalarValue(item))
+ : this.scalarValue(value);
+
+ return JSON.stringify(comparableValue);
+ }
+
+ private valueLabel(value: VariantAttributeValue): string {
+ const values = Array.isArray(value) ? value : [value];
+ return values.map((item) => (typeof item === 'string' ? item : item.label)).join(', ');
+ }
+
+ private scalarValue(value: VariantAttributeScalar): string {
+ return typeof value === 'string' ? value : value.value;
+ }
+
+ private formatVariantLabel(key: string): string {
+ const label = key.replace(/[_-]+/g, ' ');
+ return label.charAt(0).toUpperCase() + label.slice(1);
+ }
+}
diff --git a/src/environments/environment.homo.ts b/src/environments/environment.homo.ts
index abab87d..6b23224 100644
--- a/src/environments/environment.homo.ts
+++ b/src/environments/environment.homo.ts
@@ -1,6 +1,6 @@
export const environment = {
production: false,
nombre:"Homologación - activo",
- url:"https://backend.shopit.com.ar/api/",
+ url:"https://backend.qa.shopit.com.ar/api/",
urlDescarga:"url/"
};
diff --git a/src/styles.scss b/src/styles.scss
index 75a4605..63dd8cc 100644
--- a/src/styles.scss
+++ b/src/styles.scss
@@ -1,13 +1,14 @@
-@use "./fonts";
+@use './fonts';
-@import "@fortawesome/fontawesome-free/css/all.min.css";
+@import '@fortawesome/fontawesome-free/css/all.min.css';
-@import "./theme";
+@import './theme';
-@import "../node_modules/bootstrap/scss/bootstrap";
+@import '../node_modules/bootstrap/scss/bootstrap';
-:root, app-root {
- --bs-body-font-family: "Gotham", sans-serif;
+:root,
+app-root {
+ --bs-body-font-family: 'Gotham', sans-serif;
--color-primary: var(--tenant-primary);
--color-primary-rgb: var(--tenant-primary-rgb);
--color-secondary: var(--tenant-secondary);
@@ -57,3 +58,18 @@ label {
color: var(--color-danger, #dc3545);
}
}
+
+.table-striped {
+ --bs-table-bg: transparent;
+ --bs-table-color: #8a8a8a;
+ --bs-table-color-state: #8a8a8a;
+ --bs-table-color-type: #8a8a8a;
+ --bs-table-striped-bg: transparent;
+ --bs-table-striped-color: #8a8a8a;
+
+ color: #8a8a8a;
+
+ > tbody > tr:nth-of-type(odd) {
+ background-color: rgba(221, 221, 221, 0.25);
+ }
+}