diff --git a/src/app/core/layout/store-layout/store-footer/store-footer.component.scss b/src/app/core/layout/store-layout/store-footer/store-footer.component.scss index 0c4dbf2..768be52 100644 --- a/src/app/core/layout/store-layout/store-footer/store-footer.component.scss +++ b/src/app/core/layout/store-layout/store-footer/store-footer.component.scss @@ -7,21 +7,21 @@ } .store-layout__brand-slot { - width: min(100%, 12rem); + width: min(100%, 230px); min-width: 10rem; } .store-layout__brand-logo-shell { position: relative; - width: min(100%, 12rem); - height: 2.75rem; + width: min(100%, 230px); + height: 65px; } .store-layout__brand-logo { display: block; width: 100%; height: 100%; - max-height: 2.75rem; + max-height: 65px; object-fit: contain; object-position: left center; } @@ -67,7 +67,7 @@ .store-layout__footer-link { width: fit-content; - color: rgba(255, 255, 255, 0.72); + color: #ffffff; text-decoration: none; &:hover, @@ -98,6 +98,7 @@ @media (max-width: 767.98px) { .store-layout__footer-content { padding: 1.45rem 2.5rem 1.8rem !important; + font-size: 13px; } .store-layout__footer-grid { @@ -117,7 +118,6 @@ order: 1; width: auto; padding-bottom: 1.65rem; - font-size: 0.72rem; line-height: 1.25; &:nth-of-type(even) { @@ -145,21 +145,21 @@ } .store-layout__brand-slot { - width: min(100%, 7.75rem); + width: min(100%, 150px); min-width: 0; } .store-layout__brand-logo { - max-height: 2.5rem; + max-height: 55px; } .store-layout__brand-logo-shell { - width: min(100%, 7.75rem); - height: 2.5rem; + width: min(100%, 150px); + height: 55px; } .store-layout__brand-text { - font-size: 1.45rem; + font-size: 13px; } .store-layout__social-column { @@ -183,11 +183,10 @@ .store-layout__contact-details { justify-items: center; gap: 0.4rem !important; - font-size: 0.7rem; } .store-layout__copyright { - font-size: 0.6rem; + font-size: 11px; line-height: 1.3; } } diff --git a/src/app/core/layout/store-layout/store-header/store-header.component.html b/src/app/core/layout/store-layout/store-header/store-header.component.html index 54bc5ab..5e02710 100644 --- a/src/app/core/layout/store-layout/store-header/store-header.component.html +++ b/src/app/core/layout/store-layout/store-header/store-header.component.html @@ -19,46 +19,50 @@ -
-
-
- -
-
- - - @if (showSearchError()) { - - } + @if (displaySeachBar()) { +
+
-
-
+
+
+ + + @if (showSearchError()) { + + } +
+
+ } + +
@if (ticketsMenu(); as menu) { @if (isMobileMenuOpen()) { -
- + Mi cuenta -
+ @if (displayCategories()) { +
- @for (category of categories(); track category.id) { - - {{ category.nombre }} - + @for (category of categories(); track category.id) { + + {{ category.nombre }} + + } }
} @@ -135,28 +144,30 @@
-
-
-
- + @if (displayCategories()) { +
+
+
+ - + +
-
+ } diff --git a/src/app/core/layout/store-layout/store-header/store-header.component.scss b/src/app/core/layout/store-layout/store-header/store-header.component.scss index d1008cf..7aa474e 100644 --- a/src/app/core/layout/store-layout/store-header/store-header.component.scss +++ b/src/app/core/layout/store-layout/store-header/store-header.component.scss @@ -1,6 +1,6 @@ .store-layout__header { color: #ffffff; - background-color: var(--tenant-header-bg); + background-color: transparent; box-shadow: inset 0 -1px 0 rgba(255, 255, 255, 0.08); } @@ -158,8 +158,21 @@ max-width: 150px; } + .store-layout__actions-column { + flex: 1 1 auto; + width: auto; + min-width: 0; + } + + ::ng-deep .store-layout__tickets-btn-host { + flex: 1 1 0; + width: auto; + min-width: 0; + max-width: 50vw; + } + .store-layout__mobile-separator { - border-color: #DDDDDD; + border-color: #dddddd; opacity: 1; margin: 0 -1rem; } @@ -180,8 +193,9 @@ } .store-layout__actions-column { - flex: 0 0 auto; + flex: 1 1 auto; width: auto; + min-width: 0; margin-left: 0 !important; padding-left: 0.25rem; gap: 0.25rem !important; diff --git a/src/app/core/layout/store-layout/store-header/store-header.component.ts b/src/app/core/layout/store-layout/store-header/store-header.component.ts index b189253..12cd58e 100644 --- a/src/app/core/layout/store-layout/store-header/store-header.component.ts +++ b/src/app/core/layout/store-layout/store-header/store-header.component.ts @@ -35,6 +35,8 @@ export class StoreHeaderComponent { readonly accountNavigationMenus = input([]); readonly ticketsMenu = input(null); readonly categories = input([]); + readonly displayCategories = input(true); + readonly displaySeachBar = input(true); readonly cartClick = output(); readonly ticketsClick = output(); readonly loginClick = output(); diff --git a/src/app/core/layout/store-layout/store-layout.component.html b/src/app/core/layout/store-layout/store-layout.component.html index 763beb4..15f2c51 100644 --- a/src/app/core/layout/store-layout/store-layout.component.html +++ b/src/app/core/layout/store-layout/store-layout.component.html @@ -1,5 +1,6 @@
@if (isCartOpen()) { -
+
- Seguir comprando { expect(compiled.querySelector('app-store-footer .store-layout__footer')).not.toBeNull(); }); + it('hides the configured header elements when the tenant disables them', () => { + tenantState.set({ + ...tenant, + categories: [{ id: 1, nombre: 'Remeras', subcategories: [] }], + display_categories: false, + display_seach_bar: false, + }); + + const fixture = TestBed.createComponent(StoreLayoutComponent); + fixture.detectChanges(); + + const compiled = fixture.nativeElement as HTMLElement; + + expect(compiled.querySelector('#store-search-input')).toBeNull(); + expect(compiled.querySelector('.store-layout__category-trigger')).toBeNull(); + + const header = fixture.debugElement.query(By.directive(StoreHeaderComponent)); + (header.componentInstance as any).toggleMobileMenu(); + fixture.detectChanges(); + + expect(compiled.querySelectorAll('.store-layout__mobile-dropdown-item')).toHaveLength(1); + }); + it('renders the tenant branding and action icons', () => { const fixture = TestBed.createComponent(StoreLayoutComponent); fixture.detectChanges(); diff --git a/src/app/core/layout/store-layout/store-layout.component.ts b/src/app/core/layout/store-layout/store-layout.component.ts index 8336100..cb4ef29 100644 --- a/src/app/core/layout/store-layout/store-layout.component.ts +++ b/src/app/core/layout/store-layout/store-layout.component.ts @@ -6,7 +6,7 @@ import { StoreFooterComponent, StoreFooterSection } from './store-footer/store-f import { StoreHeaderComponent } from './store-header/store-header.component'; import { CartComponent, CartItemMock } from '../../../shared/components/cart/cart.component'; import { ButtonComponent } from '../../../shared/components/button/button.component'; -import { CartItem } from '../../services/cart/cart.interface'; +import { CartItem, CartItemVariantValue } from '../../services/cart/cart.interface'; import { AuthService } from '../../services/auth/auth.service'; import { findMenu } from '../../services/menu.utils'; import { CheckoutService } from '../../services/checkout.service'; @@ -69,6 +69,21 @@ export class StoreLayoutComponent implements OnInit { }); } + const variants = (item.product?.variants ?? []).map((variant) => ({ + value: variant.id, + values: variant.values, + })); + const selectedVariant = item.product?.variants?.find( + (variant) => variant.id === item.variant_id, + ); + + if (selectedVariant) { + attributes = Object.entries(selectedVariant.values).map(([label, value]) => ({ + label: this.formatAttributeLabel(label), + value: this.formatAttributeValue(value), + })); + } + return { cartItemId: item.id, imageUrl: item.product?.imagen ?? null, @@ -78,9 +93,21 @@ export class StoreLayoutComponent implements OnInit { discountPercentage: null, attributes, quantity: item.cantidad, + variantId: item.variant_id, + variants, }; } + private formatAttributeLabel(value: string): string { + const label = value.replace(/[_-]+/g, ' '); + return label.charAt(0).toUpperCase() + label.slice(1); + } + + private formatAttributeValue(value: CartItemVariantValue): string { + const values = Array.isArray(value) ? value : [value]; + return values.map((item) => (typeof item === 'string' ? item : item.label)).join(', '); + } + protected readonly currentYear = new Date().getFullYear(); protected readonly tenant = this.tenantService.tenant; protected readonly user = this.authService.user; diff --git a/src/app/core/services/cart/cart.interface.ts b/src/app/core/services/cart/cart.interface.ts index c774367..6846737 100644 --- a/src/app/core/services/cart/cart.interface.ts +++ b/src/app/core/services/cart/cart.interface.ts @@ -1,6 +1,24 @@ export interface CartItemProduct { nombre: string; imagen: string | null; + variants?: CartItemVariant[]; +} + +export interface CartItemVariantOption { + value: string; + label: string; +} + +export type CartItemVariantValue = + | string + | CartItemVariantOption + | Array; + +export interface CartItemVariant { + id: number; + precio: string; + stock_tecnico: number | null; + values: Record; } export interface CartItem { diff --git a/src/app/core/services/cart/cart.service.ts b/src/app/core/services/cart/cart.service.ts index 4a74bf2..750105f 100644 --- a/src/app/core/services/cart/cart.service.ts +++ b/src/app/core/services/cart/cart.service.ts @@ -50,9 +50,7 @@ export class CartService extends BaseApiService { ): Observable> { this.isUpdatingState.set(true); return this.http - .post< - ApiResponse - >( + .post>( `${this.tenantApiUrl}/cart/items`, { catalog_item_id: catalogItemId, variant_id: variantId, cantidad }, { @@ -71,21 +69,27 @@ export class CartService extends BaseApiService { ); } - updateItemQuantity( + updateItemQuantity(cartItemId: number, cantidad: number): Observable> { + return this.updateItem(cartItemId, { cantidad }); + } + + updateItemVariant( cartItemId: number, cantidad: number, + variantId: number, + ): Observable> { + return this.updateItem(cartItemId, { cantidad, variant_id: variantId }); + } + + private updateItem( + cartItemId: number, + payload: { cantidad: number; variant_id?: number }, ): Observable> { this.isUpdatingState.set(true); return this.http - .patch< - ApiResponse - >( - `${this.tenantApiUrl}/cart/items/${cartItemId}`, - { cantidad }, - { - withCredentials: true, - }, - ) + .patch>(`${this.tenantApiUrl}/cart/items/${cartItemId}`, payload, { + withCredentials: true, + }) .pipe( tap((response) => { this.cartState.set(response.data); @@ -101,9 +105,7 @@ export class CartService extends BaseApiService { removeItem(cartItemId: number): Observable> { this.isUpdatingState.set(true); return this.http - .delete< - ApiResponse - >(`${this.tenantApiUrl}/cart/items/${cartItemId}`, { + .delete>(`${this.tenantApiUrl}/cart/items/${cartItemId}`, { withCredentials: true, }) .pipe( diff --git a/src/app/core/services/catalog/catalog.interface.ts b/src/app/core/services/catalog/catalog.interface.ts index 56b9835..4bf3e55 100644 --- a/src/app/core/services/catalog/catalog.interface.ts +++ b/src/app/core/services/catalog/catalog.interface.ts @@ -1,7 +1,10 @@ import { ApiPaginatedResponse } from '../api-paginated-response.interface'; +export type CatalogItemType = 'product' | 'bundle'; + export interface Product { id: number; + type: CatalogItemType; category_id: number; brand_id: number | null; slug: string; @@ -15,19 +18,31 @@ export interface Product { export interface ProductAttributeOption { id: number; + validity_time_id?: number | null; value: string; label: string; sort_order: number; metadata: Record | null; } +export type ProductAttributeType = + | 'string' + | 'number' + | 'boolean' + | 'select' + | 'multiselect' + | 'color' + | 'image' + | 'event_date'; + export interface ProductAttribute { id: number; codigo: string; nombre: string; is_required: boolean; + allow_multi_select?: boolean; metadata_schema: Record | null; - type: string; + type: ProductAttributeType; options: ProductAttributeOption[]; } @@ -35,12 +50,17 @@ export type InventoryPolicy = 'tracked' | 'unlimited'; export interface CatalogItemVariant { id: number; + descripcion?: string | null; + precio?: string; + event_date_id?: number | null; + event_date_ids?: number[]; + event_dates?: string[]; stock_tecnico: number | null; minimum_use_date?: string | null; maximum_use_date?: string | null; effective_minimum_use_date?: string | null; effective_maximum_use_date?: string | null; - values: Record; + values: Record; } export interface SelectedCatalogItemVariant extends CatalogItemVariant { @@ -49,6 +69,8 @@ export interface SelectedCatalogItemVariant extends CatalogItemVariant { export interface CatalogItemDetail { id: number; + type: CatalogItemType; + purpose?: 'product' | 'entry'; category_id: number | null; brand_id: number | null; slug: string; @@ -58,6 +80,7 @@ export interface CatalogItemDetail { category: string | null; brand: string | null; inventory_policy: InventoryPolicy; + max_units_per_user?: number | null; has_tickets: boolean; minimum_use_date: string | null; maximum_use_date: string | null; @@ -73,12 +96,15 @@ export type CatalogGroupLayout = 'paginated' | 'simple' | 'simple_vertical' | 'c export interface CatalogFeaturedItemVariant { id: number; + descripcion?: string | null; + precio?: string; stock_tecnico: number | null; - values: Record; + values: Record; } export interface CatalogFeaturedItem { id: number; + type: CatalogItemType; nombre: string; descripcion?: string | null; precio: number | string; diff --git a/src/app/core/services/checkout.service.ts b/src/app/core/services/checkout.service.ts index 81d4cfc..9d7d996 100644 --- a/src/app/core/services/checkout.service.ts +++ b/src/app/core/services/checkout.service.ts @@ -61,6 +61,7 @@ export interface PurchaseDetailResponse extends PurchaseStatusResponse { user_id: number; created_at: string | null; payment_method: string | null; + expires_at: string | null; dni: string | null; transfer_payer_dni: string | null; telefono: string | null; diff --git a/src/app/core/services/modal.service.spec.ts b/src/app/core/services/modal.service.spec.ts index f258b47..b25464e 100644 --- a/src/app/core/services/modal.service.spec.ts +++ b/src/app/core/services/modal.service.spec.ts @@ -163,6 +163,8 @@ describe('ModalService', () => { const activeModal = service.activeModal(); const resultPromise = firstValueFrom(result$); + expect(activeModal?.config.size).toBe('sm'); + activeModal?.ref.dismiss('escape'); await expect(resultPromise).resolves.toBe(false); diff --git a/src/app/core/services/modal.service.ts b/src/app/core/services/modal.service.ts index 4abdcb1..3dfd1ff 100644 --- a/src/app/core/services/modal.service.ts +++ b/src/app/core/services/modal.service.ts @@ -166,7 +166,10 @@ export class ModalService { } openConfirmDeleteRef(config: ConfirmModalConfig): ModalRef { - return this.open(ConfirmDeleteModalComponent, this.buildConfirmModalConfig(config)); + return this.open( + ConfirmDeleteModalComponent, + this.buildConfirmModalConfig({ size: 'sm', ...config }), + ); } openSimple(config: SimpleModalConfig): Observable { diff --git a/src/app/core/services/tenant.interface.ts b/src/app/core/services/tenant.interface.ts index 51a87cf..52c93a6 100644 --- a/src/app/core/services/tenant.interface.ts +++ b/src/app/core/services/tenant.interface.ts @@ -9,18 +9,62 @@ export interface BankAccount { cvu: string; } +export interface WebsiteType { + codigo: string; + nombre: string; + primary_color: string | null; + secondary_color: string | null; + danger_color: string | null; + success_color: string | null; + site_logo: string | null; +} + export interface HeroConfig { - background_image?: string | null; + background_image_id?: string | null; title_html?: string | null; description_html?: string | null; button_text?: string | null; button_href?: string | null; } +export interface AdditionalInfoConfig { + description?: string | null; +} + +export interface EventDate { + id?: number; + date: string; + start_time: string; + end_time: string; +} + export interface EventConfig { + id?: number; title?: string | null; location?: string | null; - dates?: string[] | null; + dates_text?: string | null; + dates?: EventDate[] | null; + contact?: SocialMedia[]; +} + +export interface ActiveEventDate { + id: number; + date: string; + time_start: string; + time_end: string; +} + +export interface TenantEvent { + title: string; + location: string; + dates: ActiveEventDate[]; +} + +export interface WebsiteExtras { + carousel?: string[]; + heroConfig?: HeroConfig | null; + additionalInfoConfig?: AdditionalInfoConfig | null; + [extraName: string]: unknown; } export interface Menu { @@ -60,14 +104,18 @@ export interface Tenant { footer_bg_color: string; header_logo: string; footer_logo: string; + website_type_code?: string | null; + website_type?: WebsiteType | null; + event_date_text?: string | null; + extras?: WebsiteExtras; + event?: TenantEvent | null; selected_bank_account_id?: number | null; selected_bank_account?: BankAccount | null; - hero_config?: HeroConfig | null; - event_config?: EventConfig | null; search_product_layout?: 'row' | 'column_with_image' | 'column_with_cart'; search_group_layout?: 'paginated' | 'simple' | 'simple_vertical' | 'carousel'; search_items_per_page?: number; - main_carousel_images?: string[]; + display_categories?: boolean; + display_seach_bar?: boolean; social_media?: SocialMedia[]; menues?: Menu[]; categories: Category[]; diff --git a/src/app/core/services/validity-time.interface.ts b/src/app/core/services/validity-time.interface.ts new file mode 100644 index 0000000..37203ee --- /dev/null +++ b/src/app/core/services/validity-time.interface.ts @@ -0,0 +1,17 @@ +export interface TimeWindowValidityTime { + id: number; + type: 'time_window'; + is_valid: boolean; + start_time?: string; + end_time?: string; +} + +export interface FixedWindowValidityTime { + id: number; + type: 'fixed_window'; + is_valid: boolean; + fixed_starts_at?: string; + fixed_expires_at?: string; +} + +export type ValidityTime = TimeWindowValidityTime | FixedWindowValidityTime; diff --git a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.ts b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.ts index 09e3915..f133a64 100644 --- a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.ts +++ b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.ts @@ -204,8 +204,9 @@ export class ReutilizablesTestPageComponent { 'Acceso total al predio. No incluye acceso a estacionamiento. Niños menores de 5 años ingresan gratis.', price: 10000, variants: [ - { label: '10 de Octubre', value: '10-oct' }, - { label: '11 de Octubre', value: '11-oct' }, + { value: '10-oct-manana', values: { fecha: '10 de Octubre', turno: 'Mañana' } }, + { value: '10-oct-tarde', values: { fecha: '10 de Octubre', turno: 'Tarde' } }, + { value: '11-oct-tarde', values: { fecha: '11 de Octubre', turno: 'Tarde' } }, ], }; diff --git a/src/app/features/store/components/product-attribute-selector/product-attribute-selector.component.html b/src/app/features/store/components/product-attribute-selector/product-attribute-selector.component.html index ac7b1b3..5701661 100644 --- a/src/app/features/store/components/product-attribute-selector/product-attribute-selector.component.html +++ b/src/app/features/store/components/product-attribute-selector/product-attribute-selector.component.html @@ -1,5 +1,5 @@
- @for (attribute of attributes(); track attribute.id) { + @for (attribute of attributes(); track attribute.codigo) {
{{ attribute.nombre }}: @@ -10,12 +10,14 @@ type="button" class="attribute-selector__swatch" [class.attribute-selector__swatch--selected]="hasSelectedOption(attribute, option)" - [class.attribute-selector__swatch--disabled]="!availableOptions()[attribute.id][option.id]" + [class.attribute-selector__swatch--disabled]=" + !availableOptions()[attribute.codigo][option.id] + " [style.background-color]="getOptionSwatchColor(option)" [attr.aria-label]="option.label" [attr.aria-pressed]="hasSelectedOption(attribute, option)" [title]="option.label" - [disabled]="!availableOptions()[attribute.id][option.id]" + [disabled]="!availableOptions()[attribute.codigo][option.id]" (click)="selectAttributeOption(attribute, option)" > {{ option.label }} @@ -24,10 +26,14 @@
- - diff --git a/src/app/shared/components/cart-item/cart-item.component.scss b/src/app/shared/components/cart-item/cart-item.component.scss index 2a73769..3004042 100644 --- a/src/app/shared/components/cart-item/cart-item.component.scss +++ b/src/app/shared/components/cart-item/cart-item.component.scss @@ -11,7 +11,7 @@ :host(:first-child) .cart-item::before, :host .cart-item::after { - content: ""; + content: ''; position: absolute; right: var(--item-divider-inset); left: var(--item-divider-inset); @@ -119,6 +119,11 @@ gap: 0.125rem; } +.cart-item-variant-selector { + display: block; + width: 100%; +} + .cart-item-remove-btn { margin-left: 0.35rem; } diff --git a/src/app/shared/components/cart-item/cart-item.component.ts b/src/app/shared/components/cart-item/cart-item.component.ts index 7c752fe..72b2449 100644 --- a/src/app/shared/components/cart-item/cart-item.component.ts +++ b/src/app/shared/components/cart-item/cart-item.component.ts @@ -1,6 +1,10 @@ import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core'; import { QuantitySelectorComponent } from '../quantity-selector/quantity-selector.component'; import { IconButtonComponent } from '../icon-button/icon-button.component'; +import { + VariantSelectorComponent, + VariantSelectorVariant, +} from '../variant-selector/variant-selector.component'; export interface CartItemAttribute { label: string; @@ -10,10 +14,10 @@ export interface CartItemAttribute { @Component({ selector: 'app-cart-item', standalone: true, - imports: [QuantitySelectorComponent, IconButtonComponent], + imports: [QuantitySelectorComponent, IconButtonComponent, VariantSelectorComponent], templateUrl: './cart-item.component.html', styleUrl: './cart-item.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush + changeDetection: ChangeDetectionStrategy.OnPush, }) export class CartItemComponent { readonly imageUrl = input(null); @@ -22,6 +26,8 @@ export class CartItemComponent { readonly discountedPrice = input(0); readonly discountPercentage = input(null); readonly attributes = input([]); + readonly variants = input([]); + readonly selectedVariant = input(null); readonly quantity = input(1); readonly readonly = input(false); readonly quantityDisabled = input(false); @@ -31,6 +37,11 @@ export class CartItemComponent { readonly remove = output(); readonly increase = output(); readonly decrease = output(); + readonly variantChange = output(); + + protected readonly hasVariantSelectors = computed(() => + this.variants().some((variant) => Object.keys(variant.values).length > 0), + ); protected onQuantityChange(newQuantity: number): void { if (this.quantityDisabled()) return; @@ -49,12 +60,20 @@ export class CartItemComponent { this.decrease.emit(); } + protected onVariantChange(variant: unknown): void { + if (!this.quantityDisabled() && typeof variant === 'number') { + this.variantChange.emit(variant); + } + } + protected readonly formattedOriginalPrice = computed(() => { const price = this.originalPrice(); return price === null ? null : this.formatCurrency(price); }); - protected readonly formattedDiscountedPrice = computed(() => this.formatCurrency(this.discountedPrice())); + protected readonly formattedDiscountedPrice = computed(() => + this.formatCurrency(this.discountedPrice()), + ); private formatCurrency(value: number): string { const rounded = Math.round(value); diff --git a/src/app/shared/components/cart/cart.component.html b/src/app/shared/components/cart/cart.component.html index a04b6d1..469dd86 100644 --- a/src/app/shared/components/cart/cart.component.html +++ b/src/app/shared/components/cart/cart.component.html @@ -44,11 +44,14 @@ [discountedPrice]="item.discountedPrice" [discountPercentage]="item.discountPercentage" [attributes]="item.attributes" + [variants]="item.variants ?? []" + [selectedVariant]="getItemVariant(item)" [quantity]="getItemQuantity(item)" [readonly]="readonly()" [quantityDisabled]="editingDisabled() || (allowEditing() && !editing())" [showRemove]="allowRemove()" (quantityChange)="onItemQuantityChange(idx, $event)" + (variantChange)="onItemVariantChange(idx, $event)" (remove)="onItemRemove(idx)" /> } @empty { diff --git a/src/app/shared/components/cart/cart.component.spec.ts b/src/app/shared/components/cart/cart.component.spec.ts index d7a1d49..0909875 100644 --- a/src/app/shared/components/cart/cart.component.spec.ts +++ b/src/app/shared/components/cart/cart.component.spec.ts @@ -115,7 +115,7 @@ describe('CartComponent', () => { expect(openConfirmDelete).toHaveBeenCalledWith({ title: 'Eliminar producto', - content: 'Se eliminara "Producto de prueba" del carrito. Esta accion no se puede deshacer.', + content: 'Se eliminará “Producto de prueba” del carrito. Esta acción no se puede deshacer.', confirmLabel: 'Eliminar', cancelLabel: 'Cancelar', }); @@ -407,4 +407,57 @@ describe('CartComponent', () => { fixture.debugElement.query(By.css('app-cart-item')).componentInstance.quantityDisabled(), ).toBe(false); }); + + it('persists a variant selected from a cart row', async () => { + const updateItemVariant = vi.fn().mockReturnValue( + of({ + message: 'Variante actualizada.', + data: {}, + }), + ); + + await TestBed.configureTestingModule({ + imports: [CartComponent], + providers: [ + { + provide: CartService, + useValue: { + cart: signal(null).asReadonly(), + updateItemQuantity: vi.fn(), + updateItemVariant, + removeItem: vi.fn(), + }, + }, + { provide: ModalService, useValue: {} }, + { + provide: ToastService, + useValue: { success: vi.fn(), info: vi.fn(), danger: vi.fn() }, + }, + ], + }).compileComponents(); + + const fixture = TestBed.createComponent(CartComponent); + fixture.componentRef.setInput('items', [ + { + cartItemId: 10, + imageUrl: null, + product: 'Comida', + originalPrice: null, + discountedPrice: 1000, + discountPercentage: null, + attributes: [{ label: 'Servicio', value: 'Almuerzo' }], + quantity: 2, + variantId: 20, + variants: [ + { value: 20, values: { servicio: 'Almuerzo' } }, + { value: 21, values: { servicio: 'Cena' } }, + ], + }, + ]); + fixture.detectChanges(); + + fixture.debugElement.query(By.css('app-cart-item')).triggerEventHandler('variantChange', 21); + + expect(updateItemVariant).toHaveBeenCalledWith(10, 2, 21); + }); }); diff --git a/src/app/shared/components/cart/cart.component.ts b/src/app/shared/components/cart/cart.component.ts index 7a78e16..c08cae9 100644 --- a/src/app/shared/components/cart/cart.component.ts +++ b/src/app/shared/components/cart/cart.component.ts @@ -16,6 +16,7 @@ import { CartItemAttribute, CartItemComponent } from '../cart-item/cart-item.com import { ModalService } from '../../../core/services/modal.service'; import { CartService } from '../../../core/services/cart/cart.service'; import { ToastService } from '../../../core/services/toast.service'; +import { VariantSelectorVariant } from '../variant-selector/variant-selector.component'; export interface CartItemMock { cartItemId?: number; @@ -26,6 +27,8 @@ export interface CartItemMock { discountPercentage: number | null; attributes: CartItemAttribute[]; quantity: number; + variantId?: number | null; + variants?: VariantSelectorVariant[]; } @Component({ @@ -64,6 +67,7 @@ export class CartComponent { }>(); protected readonly quantityOverrides = signal>({}); + protected readonly variantOverrides = signal>({}); constructor() { this.quantityUpdates$ .pipe( @@ -104,6 +108,13 @@ export class CartComponent { return item.quantity; } + protected getItemVariant(item: CartItemMock): number | null { + if (item.cartItemId !== undefined && this.variantOverrides()[item.cartItemId] !== undefined) { + return this.variantOverrides()[item.cartItemId]; + } + return item.variantId ?? null; + } + private clearOverride(cartItemId: number): void { this.quantityOverrides.update((overrides) => { const copy = { ...overrides }; @@ -153,6 +164,36 @@ export class CartComponent { } } + protected onItemVariantChange(index: number, variantId: number): void { + const item = this.items()[index]; + const cartItemId = item?.cartItemId; + + if (!item || !cartItemId || variantId === this.getItemVariant(item)) return; + + this.variantOverrides.update((overrides) => ({ ...overrides, [cartItemId]: variantId })); + this.cartService + .updateItemVariant(cartItemId, this.getItemQuantity(item), variantId) + .subscribe({ + next: (response) => { + this.clearVariantOverride(cartItemId); + this.toastService.success(response.message || 'Variante actualizada.'); + }, + error: (error: HttpErrorResponse) => { + console.error('Error updating cart item variant', error); + this.clearVariantOverride(cartItemId); + this.toastService.danger(error.error?.message || 'No se pudo actualizar la variante.'); + }, + }); + } + + private clearVariantOverride(cartItemId: number): void { + this.variantOverrides.update((overrides) => { + const copy = { ...overrides }; + delete copy[cartItemId]; + return copy; + }); + } + protected onItemRemove(index: number): void { const target = this.resolveRemoveTarget(index); @@ -163,7 +204,7 @@ export class CartComponent { this.modalService .openConfirmDelete({ title: 'Eliminar producto', - content: `Se eliminara "${target.productName}" del carrito. Esta accion no se puede deshacer.`, + content: `Se eliminará “${target.productName}” del carrito. Esta acción no se puede deshacer.`, confirmLabel: 'Eliminar', cancelLabel: 'Cancelar', }) diff --git a/src/app/shared/components/confirm-delete-modal/confirm-delete-modal.component.scss b/src/app/shared/components/confirm-delete-modal/confirm-delete-modal.component.scss index 3e07d69..08d7de4 100644 --- a/src/app/shared/components/confirm-delete-modal/confirm-delete-modal.component.scss +++ b/src/app/shared/components/confirm-delete-modal/confirm-delete-modal.component.scss @@ -1,19 +1,25 @@ .confirm-modal { display: grid; - gap: 1.5rem; - justify-items: center; + width: 100%; + gap: 1.25rem; text-align: center; } .confirm-modal__content { margin: 0; - color: #666666; - line-height: 1.5; + color: #5f6368; + line-height: 1.55; } .confirm-modal__actions { - display: flex; - justify-content: center; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + width: 100%; gap: 0.75rem; - flex-wrap: wrap; +} + +@media (max-width: 380px) { + .confirm-modal__actions { + grid-template-columns: 1fr; + } } diff --git a/src/app/shared/components/hero-banner/hero-banner.component.html b/src/app/shared/components/hero-banner/hero-banner.component.html index 0846a7b..e3a20bd 100644 --- a/src/app/shared/components/hero-banner/hero-banner.component.html +++ b/src/app/shared/components/hero-banner/hero-banner.component.html @@ -1,15 +1,19 @@
-
+ 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() }} +
+ +
Comprar
-
- - -
- @if (variants().length > 0) { - - } - - -
Agregar al carrito
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 { -
-
+
+
{{ formattedPrice() }}
- @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); + } +}