38 Commits

Author SHA1 Message Date
369cfb0a40 feat: update header background color to transparent and modify API URL for QA environment 2026-08-11 16:50:47 -03:00
7b0c9831f1 feat: add validity_time_id to ProductAttributeOption and enhance TicketResponse with validity_times and validity_groups 2026-08-11 13:26:05 -03:00
ede2173a5a feat: update styles for small quantity selector to improve appearance and usability 2026-08-11 10:38:42 -03:00
5d760bfd80 feat: improve modal styling and enhance padding management in modal host component 2026-08-11 10:14:59 -03:00
e3599a48e4 feat: update layout and styles for product components and variant selectors 2026-08-11 10:14:39 -03:00
5d629e27b1 feat: add configurable display options for categories and search bar in store header 2026-08-11 09:55:57 -03:00
563e4634d1 feat: update styles across components for improved layout and responsiveness 2026-08-11 09:55:49 -03:00
c75787a59c feat: enhance hero banner component with event schedule toggle and styling improvements 2026-08-11 08:50:32 -03:00
f6f3a997f7 feat: refactor variant selection to use app-variant-selector for improved maintainability 2026-08-11 08:28:42 -03:00
019c9df23b feat: update modal and cart components for improved user feedback and layout adjustments 2026-08-11 08:24:17 -03:00
9ea85b7924 feat: enhance variant handling in selector and layout components for improved display and selection 2026-08-10 16:34:02 -03:00
0769bb8683 Revert "feat: enhance variant attribute handling across components for improved selection and display"
This reverts commit 56f602d876.
2026-08-10 16:17:09 -03:00
c645feba80 feat: simplify event date labels in product components for clearer display 2026-08-10 14:17:09 -03:00
56f602d876 feat: enhance variant attribute handling across components for improved selection and display 2026-08-10 12:25:58 -03:00
d6bab24e1d feat: implement variant selection in cart and product components for enhanced user experience 2026-08-10 12:08:41 -03:00
dd828d9a4e feat: add event_date_text to Tenant interface and update StoreHomePageComponent to display formatted event dates 2026-08-10 10:42:15 -03:00
7c2a098696 feat: enhance product variant handling and improve layout for better user interaction 2026-08-10 09:58:51 -03:00
1977df6765 feat: enhance product attributes and event date handling for improved user experience and data management 2026-08-07 14:16:02 -03:00
8d88e1bb13 Merge branch 'feature/responsive' into develop 2026-08-06 09:23:37 -03:00
50f986dd2f feat: refactor menu content section for improved structure and responsiveness across account and help pages 2026-08-06 09:22:24 -03:00
393aeec7a5 feat: add menu content section component for improved layout and responsiveness across help and FAQ pages 2026-08-06 09:15:34 -03:00
a012f94198 feat: implement menu page layout component and remove old sidebar components for improved structure and responsiveness 2026-08-06 09:06:19 -03:00
6b9087262a feat: improve responsiveness of checkout payment panel and help sidebar components 2026-08-06 08:53:23 -03:00
4b98df4058 feat: enhance checkout page layout for better responsiveness and adjust cart actions for mobile 2026-08-06 08:49:38 -03:00
2b3e054ccf feat: enhance footer layout for improved responsiveness and structure 2026-08-05 17:07:04 -03:00
1b238cb1bc feat: center align summary content and adjust variant selectors layout 2026-08-05 16:52:10 -03:00
c9f9e6cc3e feat: enhance product vertical card with variant selection and improve store header responsiveness 2026-08-05 16:48:34 -03:00
5da341dde2 feat: improve hero banner responsiveness and enhance event card layout 2026-08-05 16:16:46 -03:00
21786d7b34 feat: enhance store header layout for better responsiveness and add mobile menu functionality 2026-08-05 12:18:50 -03:00
8aea54959f fix: enforce padding and background styles for form control in recover password page 2026-08-05 11:09:30 -03:00
78970573a8 feat: add type property to Product and CatalogItemDetail interfaces; include event_id in PurchaseSummaryResponse and PurchaseDetailResponse 2026-08-05 08:49:52 -03:00
f289e9e2e4 feat: add expires_at to PurchaseDetailResponse and update checkout logic for pending payments 2026-08-03 17:04:07 -03:00
2487546a75 feat: add additionalInfoConfig to tenant interface and implement rendering in store home page 2026-08-03 15:08:33 -03:00
cc884e3cc0 feat: add description_html to tenant heroConfig and update tests for hero banner rendering 2026-08-03 15:08:13 -03:00
ffb586c348 feat: enhance product and tenant interfaces to support event dates, update product detail page to display event date selection 2026-08-03 14:24:42 -03:00
ec18aaf0c5 feat: update WebsiteType and EventConfig interfaces with new properties, enhance tenant creation in tests 2026-08-03 09:09:58 -03:00
941cb5c73c feat: add dates_text to EventConfig and update formattedDates method in HeroBannerComponent 2026-07-30 16:32:37 -03:00
934bd07105 feat: enhance tenant interface with website type and extras, update store home page to utilize new data structure 2026-07-29 15:46:02 -03:00
118 changed files with 3216 additions and 1157 deletions

View File

@@ -1,7 +1,7 @@
<footer class="store-layout__footer mt-auto text-light"> <footer class="store-layout__footer mt-auto text-light">
<div class="container-xl px-3 px-md-4 py-4 py-md-5"> <div class="container-xl px-3 px-md-4 py-4 py-md-5 store-layout__footer-content">
<div class="row g-4"> <div class="row g-4 store-layout__footer-grid">
<div class="col-12"> <div class="col-12 store-layout__brand-column">
<div <div
class="store-layout__brand-slot d-inline-flex align-items-center gap-3" class="store-layout__brand-slot d-inline-flex align-items-center gap-3"
data-testid="store-footer-brand-slot" data-testid="store-footer-brand-slot"
@@ -17,30 +17,29 @@
</div> </div>
</div> </div>
<div class="col-12 col-md-6 col-xl-5 d-grid gap-3 align-content-start"> <div class="col-12 col-md-6 col-xl-5 d-grid gap-3 align-content-start store-layout__contact">
<div class="d-grid gap-2"> <div class="d-grid gap-2 store-layout__contact-details">
<div class="d-flex align-items-center gap-2 "> <div class="d-flex align-items-center gap-2">
<i class="fa-solid fa-location-dot" aria-hidden="true"></i> <i class="fa-solid fa-location-dot" aria-hidden="true"></i>
<span>Av. San Lorenzo 1542, Rosario</span> <span>Av. San Lorenzo 1542, Rosario</span>
</div> </div>
<div class="d-flex align-items-center gap-2 "> <div class="d-flex align-items-center gap-2">
<i class="fa-solid fa-phone" aria-hidden="true"></i> <i class="fa-solid fa-phone" aria-hidden="true"></i>
<span>54 9 (0341) 6658247</span> <span>54 9 (0341) 6658247</span>
</div> </div>
</div> </div>
<p class="mb-0 small store-layout__muted-text">Copyright &copy; {{ currentYear }} - {{ storeName || 'Sonder' }}</p> <p class="mb-0 small store-layout__muted-text store-layout__copyright">
Copyright &copy; {{ currentYear }} - {{ storeName || 'Sonder' }}
</p>
</div> </div>
@for (section of footerSections; track section.heading) { @for (section of footerSections; track section.heading) {
<div class="col-12 col-md-6 col-xl-2 d-grid align-content-start"> <div class="col-12 col-md-6 col-xl-2 d-grid align-content-start store-layout__links-column">
<div class="d-grid gap-2"> <div class="d-grid gap-2">
@for (link of section.links; track link.label) { @for (link of section.links; track link.label) {
@if (link.routerLink) { @if (link.routerLink) {
<a <a class="store-layout__footer-link" [routerLink]="link.routerLink">
class="store-layout__footer-link"
[routerLink]="link.routerLink"
>
{{ link.label }} {{ link.label }}
</a> </a>
} @else if (link.action === 'logout') { } @else if (link.action === 'logout') {
@@ -59,8 +58,10 @@
</div> </div>
} }
<div class="col-12 col-md-6 col-xl-3 d-grid align-content-start"> <div class="store-layout__mobile-divider" aria-hidden="true"></div>
<div class="d-flex align-items-center gap-3">
<div class="col-12 col-md-6 col-xl-3 d-grid align-content-start store-layout__social-column">
<div class="d-flex align-items-center gap-3 store-layout__social-list">
@for (social of socialMedia; track social.code) { @for (social of socialMedia; track social.code) {
<app-social-media <app-social-media
class="text-white" class="text-white"

View File

@@ -2,22 +2,26 @@
background-color: var(--tenant-footer-bg); background-color: var(--tenant-footer-bg);
} }
.store-layout__mobile-divider {
display: none;
}
.store-layout__brand-slot { .store-layout__brand-slot {
width: min(100%, 12rem); width: min(100%, 230px);
min-width: 10rem; min-width: 10rem;
} }
.store-layout__brand-logo-shell { .store-layout__brand-logo-shell {
position: relative; position: relative;
width: min(100%, 12rem); width: min(100%, 230px);
height: 2.75rem; height: 65px;
} }
.store-layout__brand-logo { .store-layout__brand-logo {
display: block; display: block;
width: 100%; width: 100%;
height: 100%; height: 100%;
max-height: 2.75rem; max-height: 65px;
object-fit: contain; object-fit: contain;
object-position: left center; object-position: left center;
} }
@@ -63,7 +67,7 @@
.store-layout__footer-link { .store-layout__footer-link {
width: fit-content; width: fit-content;
color: rgba(255, 255, 255, 0.72); color: #ffffff;
text-decoration: none; text-decoration: none;
&:hover, &:hover,
@@ -92,11 +96,97 @@
} }
@media (max-width: 767.98px) { @media (max-width: 767.98px) {
.store-layout__footer-content {
padding: 1.45rem 2.5rem 1.8rem !important;
font-size: 13px;
}
.store-layout__footer-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0;
margin: 0;
}
.store-layout__footer-grid > * {
margin-top: 0;
padding-right: 0;
padding-left: 0;
}
.store-layout__links-column {
order: 1;
width: auto;
padding-bottom: 1.65rem;
line-height: 1.25;
&:nth-of-type(even) {
padding-left: 0.65rem;
}
> div {
gap: 0.5rem !important;
}
}
.store-layout__mobile-divider {
display: block;
order: 2;
grid-column: 1 / -1;
width: 100vw;
border-top: 1px solid rgba(255, 255, 255, 0.3);
}
.store-layout__brand-column,
.store-layout__social-column {
order: 3;
width: auto;
padding-top: 2rem;
}
.store-layout__brand-slot { .store-layout__brand-slot {
width: min(100%, 150px);
min-width: 0; min-width: 0;
} }
.store-layout__brand-logo {
max-height: 55px;
}
.store-layout__brand-logo-shell {
width: min(100%, 150px);
height: 55px;
}
.store-layout__brand-text { .store-layout__brand-text {
font-size: 1.45rem; font-size: 13px;
}
.store-layout__social-column {
align-items: center;
}
.store-layout__social-list {
justify-content: flex-end;
gap: 0 !important;
}
.store-layout__contact {
order: 4;
grid-column: 1 / -1;
width: auto;
gap: 1.25rem !important;
padding-top: 0.9rem;
text-align: center;
}
.store-layout__contact-details {
justify-items: center;
gap: 0.4rem !important;
}
.store-layout__copyright {
font-size: 11px;
line-height: 1.3;
} }
} }

View File

@@ -1,81 +1,87 @@
<header class="store-layout__header"> <header class="store-layout__header">
<div class="store-layout__header-main"> <div class="store-layout__header-main">
<div <div class="container-xl px-3 px-md-4 py-3 py-lg-4">
class="container-xl px-3 px-md-4 py-3 py-lg-4 d-flex flex-row flex-wrap align-items-center justify-content-between gap-3" <div class="row align-items-center gy-3 gy-md-0">
> <div class="col-auto col-md-auto order-1 store-layout__brand-column">
<a <a
routerLink="/" routerLink="/"
class="store-layout__brand-slot d-inline-flex align-items-center gap-3 flex-shrink-0 text-decoration-none" class="store-layout__brand-slot d-inline-flex align-items-center gap-3 text-decoration-none"
data-testid="store-header-brand-slot" data-testid="store-header-brand-slot"
aria-label="Logo del comercio" aria-label="Logo del comercio"
> >
@if (logoUrl()) { @if (logoUrl()) {
<img class="store-layout__brand-logo" [src]="logoUrl()" alt="Logo del comercio" /> <img class="store-layout__brand-logo" [src]="logoUrl()" alt="Logo del comercio" />
} @else { } @else {
<div class="store-layout__brand-logo-shell" aria-hidden="true"> <div class="store-layout__brand-logo-shell" aria-hidden="true">
<span class="store-layout__brand-logo-skeleton"></span> <span class="store-layout__brand-logo-skeleton"></span>
</div> </div>
} }
</a> </a>
<div
class="d-flex flex-row align-items-center gap-3 ms-auto flex-nowrap flex-grow-1 justify-content-between justify-content-md-end"
>
<div class="store-layout__search-wrapper">
<div class="input-group store-layout__search" role="search">
<label class="visually-hidden" for="store-search-input">Buscar productos</label>
<input
id="store-search-input"
type="search"
class="form-control store-layout__search-input border-end-0 rounded-start"
placeholder="Buscar productos"
autocomplete="off"
[attr.aria-describedby]="showSearchError() ? 'store-search-error' : null"
[attr.aria-invalid]="showSearchError()"
[minlength]="minSearchLength"
[formControl]="searchControl"
(input)="clearSearchError()"
(keydown.enter)="submitSearch($event)"
/>
<button
type="button"
class="btn store-layout__search-button border-start-0 rounded-end px-3"
aria-label="Buscar"
(click)="submitSearch()"
>
<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
</button>
</div>
@if (showSearchError()) {
<p id="store-search-error" class="store-layout__search-error mb-0" role="alert">
Ingresá al menos {{ minSearchLength }} caracteres para buscar.
</p>
}
</div> </div>
@if (ticketsMenu(); as menu) { @if (displaySeachBar()) {
<app-button <div class="col-12 px-0 d-md-none order-3">
variant="primary" <hr class="store-layout__mobile-separator" />
hostClass="flex-shrink-0" </div>
data-testid="store-header-tickets"
(click)="ticketsClick.emit()" <div class="col-12 col-md order-4 order-md-2">
> <div class="store-layout__search-wrapper ms-md-auto">
{{ menu.label }} <div class="input-group store-layout__search" role="search">
</app-button> <label class="visually-hidden" for="store-search-input">Buscar productos</label>
<input
id="store-search-input"
type="search"
class="form-control store-layout__search-input border-end-0 rounded-start"
placeholder="Buscar productos"
autocomplete="off"
[attr.aria-describedby]="showSearchError() ? 'store-search-error' : null"
[attr.aria-invalid]="showSearchError()"
[minlength]="minSearchLength"
[formControl]="searchControl"
(input)="clearSearchError()"
(keydown.enter)="submitSearch($event)"
/>
<button
type="button"
class="btn store-layout__search-button border-start-0 rounded-end px-3"
aria-label="Buscar"
(click)="submitSearch()"
>
<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
</button>
</div>
@if (showSearchError()) {
<p id="store-search-error" class="store-layout__search-error mb-0" role="alert">
Ingresá al menos {{ minSearchLength }} caracteres para buscar.
</p>
}
</div>
</div>
} }
<div <div
class="d-flex align-items-center justify-content-end gap-1" class="store-layout__actions-column col-auto col-md-auto order-2 order-md-3 ms-auto d-flex flex-row align-items-center justify-content-end gap-2 gap-md-3"
aria-label="Acciones de usuario"
> >
@if (ticketsMenu(); as menu) {
<app-button
variant="primary"
hostClass="store-layout__tickets-btn-host"
data-testid="store-header-tickets"
(click)="ticketsClick.emit()"
>
{{ menu.label }}
</app-button>
}
<app-cart-icon <app-cart-icon
[quantity]="cartQuantity()" [quantity]="cartQuantity()"
ariaLabel="Carrito de compras" ariaLabel="Carrito de compras"
title="Carrito" title="Carrito"
(click)="cartClick.emit()" (click)="cartClick.emit()"
/> />
<div class="store-layout__user-menu">
<div class="store-layout__user-menu d-none d-md-inline-flex position-relative">
<app-icon-button <app-icon-button
variant="user" variant="user"
ariaLabel="Mi cuenta" ariaLabel="Mi cuenta"
@@ -94,33 +100,74 @@
/> />
} }
</div> </div>
<div class="store-layout__mobile-menu d-inline-flex d-md-none position-relative">
<app-icon-button
variant="bars"
ariaLabel="Menu"
ariaControls="store-mobile-menu"
[ariaExpanded]="isMobileMenuOpen()"
title="Menu"
(clicked)="toggleMobileMenu()"
/>
@if (isMobileMenuOpen()) {
<div
id="store-mobile-menu"
class="position-absolute end-0 bg-white rounded shadow mt-2 py-2 store-layout__mobile-dropdown-menu"
>
<a
class="dropdown-item d-flex align-items-center gap-2 px-3 py-2 text-decoration-none store-layout__mobile-dropdown-item"
(click)="onMobileAccountClick()"
>
<i class="fa-regular fa-circle-user fa-lg"></i>
<span class="fw-medium">Mi cuenta</span>
</a>
@if (displayCategories()) {
<hr class="my-1 border-secondary opacity-25" />
@for (category of categories(); track category.id) {
<a
class="dropdown-item px-3 py-2 text-decoration-none store-layout__mobile-dropdown-item"
(click)="onCategorySelect(category)"
>
<span class="fw-medium">{{ category.nombre }}</span>
</a>
}
}
</div>
}
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="store-layout__categories"> @if (displayCategories()) {
<div class="container-xl px-3 px-md-4"> <div class="store-layout__categories d-none d-md-block">
<div class="store-layout__category-menu"> <div class="container-xl px-3 px-md-4">
<button <div class="store-layout__category-menu">
type="button" <button
class="btn btn-link d-inline-flex align-items-center gap-2 px-0 py-2 text-uppercase fw-bold text-decoration-none store-layout__category-trigger" type="button"
aria-label="Categorias" class="btn btn-link d-inline-flex align-items-center gap-2 px-0 py-2 text-uppercase fw-bold text-decoration-none store-layout__category-trigger"
aria-controls="store-category-dropdown" aria-label="Categorias"
[attr.aria-expanded]="isCategoryDropdownOpen()" aria-controls="store-category-dropdown"
(click)="toggleCategoryDropdown()" [attr.aria-expanded]="isCategoryDropdownOpen()"
> (click)="toggleCategoryDropdown()"
<span>Categorias</span> >
<i class="fa-solid fa-chevron-down" aria-hidden="true"></i> <span>Categorias</span>
</button> <i class="fa-solid fa-chevron-down" aria-hidden="true"></i>
</button>
<app-category-dropdown <app-category-dropdown
id="store-category-dropdown" id="store-category-dropdown"
[categories]="categories()" [categories]="categories()"
[open]="isCategoryDropdownOpen()" [open]="isCategoryDropdownOpen()"
(categorySelect)="onCategorySelect($event)" (categorySelect)="onCategorySelect($event)"
/> />
</div>
</div> </div>
</div> </div>
</div> }
</header> </header>

View File

@@ -1,6 +1,6 @@
.store-layout__header { .store-layout__header {
color: #ffffff; color: #ffffff;
background-color: var(--tenant-header-bg); background-color: transparent;
box-shadow: inset 0 -1px 0 rgba(255, 255, 255, 0.08); box-shadow: inset 0 -1px 0 rgba(255, 255, 255, 0.08);
} }
@@ -10,7 +10,10 @@
.store-layout__brand-slot { .store-layout__brand-slot {
min-width: 150px; min-width: 150px;
max-width:40px; }
.store-layout__brand-column {
max-width: 12rem;
} }
.store-layout__brand-logo-shell { .store-layout__brand-logo-shell {
@@ -42,10 +45,25 @@
} }
.store-layout__search-wrapper { .store-layout__search-wrapper {
width: min(100%, 260px); width: 100%;
min-width: 0; min-width: 0;
} }
@media (min-width: 768px) {
.store-layout__search-wrapper {
width: min(100%, 260px);
}
.store-layout__actions-column {
padding-left: 0;
}
::ng-deep .store-layout__tickets-btn-host {
flex: 0 0 clamp(8rem, 15vw, 13.5rem);
width: clamp(8rem, 15vw, 13.5rem);
}
}
.store-layout__search { .store-layout__search {
width: 100%; width: 100%;
} }
@@ -137,5 +155,70 @@
@media (max-width: 767.98px) { @media (max-width: 767.98px) {
.store-layout__brand-slot { .store-layout__brand-slot {
min-width: 0; min-width: 0;
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;
opacity: 1;
margin: 0 -1rem;
}
}
@media (max-width: 399.98px) {
.store-layout__brand-column {
flex: 1 1 0;
width: auto;
min-width: 0;
max-width: none;
padding-right: 0.25rem;
}
.store-layout__brand-slot {
width: 100%;
max-width: 6.5rem;
}
.store-layout__actions-column {
flex: 1 1 auto;
width: auto;
min-width: 0;
margin-left: 0 !important;
padding-left: 0.25rem;
gap: 0.25rem !important;
}
::ng-deep .store-layout__tickets-btn-host .btn {
padding-inline: 0.75rem;
font-size: 0.875rem;
}
}
.store-layout__mobile-dropdown-menu {
top: 100%;
min-width: 220px;
z-index: 1050;
}
.store-layout__mobile-dropdown-item {
cursor: pointer;
color: #8a8a8a;
&:hover,
&:focus-visible {
color: #666666;
} }
} }

View File

@@ -1,5 +1,5 @@
import { Component, ElementRef, HostListener, inject, input, output, signal } from '@angular/core'; import { Component, ElementRef, HostListener, inject, input, output, signal } from '@angular/core';
import { RouterLink } from '@angular/router'; import { Router, RouterLink } from '@angular/router';
import { AuthUser } from '../../../services/auth/auth.interfaces'; import { AuthUser } from '../../../services/auth/auth.interfaces';
import { CartIconComponent } from '../../../../shared/components/cart-icon/cart-icon.component'; import { CartIconComponent } from '../../../../shared/components/cart-icon/cart-icon.component';
import { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.component'; import { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.component';
@@ -26,6 +26,7 @@ import { CategoryDropdownComponent } from './category-dropdown/category-dropdown
}) })
export class StoreHeaderComponent { export class StoreHeaderComponent {
private readonly elementRef = inject(ElementRef<HTMLElement>); private readonly elementRef = inject(ElementRef<HTMLElement>);
private readonly router = inject(Router);
readonly logoUrl = input<string | null>(null); readonly logoUrl = input<string | null>(null);
readonly cartQuantity = input<number>(0); readonly cartQuantity = input<number>(0);
@@ -34,6 +35,8 @@ export class StoreHeaderComponent {
readonly accountNavigationMenus = input<readonly Menu[]>([]); readonly accountNavigationMenus = input<readonly Menu[]>([]);
readonly ticketsMenu = input<Menu | null>(null); readonly ticketsMenu = input<Menu | null>(null);
readonly categories = input<readonly Category[]>([]); readonly categories = input<readonly Category[]>([]);
readonly displayCategories = input(true);
readonly displaySeachBar = input(true);
readonly cartClick = output<void>(); readonly cartClick = output<void>();
readonly ticketsClick = output<void>(); readonly ticketsClick = output<void>();
readonly loginClick = output<void>(); readonly loginClick = output<void>();
@@ -43,13 +46,14 @@ export class StoreHeaderComponent {
protected readonly isUserDropdownOpen = signal(false); protected readonly isUserDropdownOpen = signal(false);
protected readonly isCategoryDropdownOpen = signal(false); protected readonly isCategoryDropdownOpen = signal(false);
protected readonly isMobileMenuOpen = signal(false);
protected readonly minSearchLength = 3; protected readonly minSearchLength = 3;
protected readonly showSearchError = signal(false); protected readonly showSearchError = signal(false);
protected readonly searchControl = new FormControl('', { nonNullable: true }); protected readonly searchControl = new FormControl('', { nonNullable: true });
@HostListener('document:click', ['$event']) @HostListener('document:click', ['$event'])
protected onDocumentClick(event: MouseEvent): void { protected onDocumentClick(event: MouseEvent): void {
if (!this.isUserDropdownOpen() && !this.isCategoryDropdownOpen()) { if (!this.isUserDropdownOpen() && !this.isCategoryDropdownOpen() && !this.isMobileMenuOpen()) {
return; return;
} }
@@ -57,6 +61,7 @@ export class StoreHeaderComponent {
if (target instanceof Node && !this.elementRef.nativeElement.contains(target)) { if (target instanceof Node && !this.elementRef.nativeElement.contains(target)) {
this.isUserDropdownOpen.set(false); this.isUserDropdownOpen.set(false);
this.isCategoryDropdownOpen.set(false); this.isCategoryDropdownOpen.set(false);
this.isMobileMenuOpen.set(false);
} }
} }
@@ -64,6 +69,7 @@ export class StoreHeaderComponent {
protected onEscapeKey(): void { protected onEscapeKey(): void {
this.isUserDropdownOpen.set(false); this.isUserDropdownOpen.set(false);
this.isCategoryDropdownOpen.set(false); this.isCategoryDropdownOpen.set(false);
this.isMobileMenuOpen.set(false);
} }
protected toggleUserDropdown(): void { protected toggleUserDropdown(): void {
@@ -109,6 +115,25 @@ export class StoreHeaderComponent {
protected onCategorySelect(category: Category): void { protected onCategorySelect(category: Category): void {
this.isCategoryDropdownOpen.set(false); this.isCategoryDropdownOpen.set(false);
this.isMobileMenuOpen.set(false);
this.categorySelect.emit(category); this.categorySelect.emit(category);
} }
protected toggleMobileMenu(): void {
this.isMobileMenuOpen.update((isOpen) => !isOpen);
}
protected onMobileAccountClick(): void {
this.isMobileMenuOpen.set(false);
if (!this.isAuthenticated()) {
this.loginClick.emit();
} else {
const menus = this.accountNavigationMenus();
if (menus.length > 0) {
this.router.navigate([menus[0].route]);
} else {
this.router.navigate(['/cuenta']);
}
}
}
} }

View File

@@ -1,5 +1,6 @@
<div class="store-layout d-flex flex-column flex-grow-1"> <div class="store-layout d-flex flex-column flex-grow-1">
<app-store-header <app-store-header
ngSkipHydration
[cartQuantity]="cartQuantity()" [cartQuantity]="cartQuantity()"
[logoUrl]="tenant()?.header_logo ?? null" [logoUrl]="tenant()?.header_logo ?? null"
[user]="user()" [user]="user()"
@@ -7,6 +8,8 @@
[accountNavigationMenus]="accountNavigationMenus()" [accountNavigationMenus]="accountNavigationMenus()"
[ticketsMenu]="ticketsMenu() ?? null" [ticketsMenu]="ticketsMenu() ?? null"
[categories]="tenant()?.categories ?? []" [categories]="tenant()?.categories ?? []"
[displayCategories]="tenant()?.display_categories ?? true"
[displaySeachBar]="tenant()?.display_seach_bar ?? true"
(cartClick)="isCartOpen.set(!isCartOpen())" (cartClick)="isCartOpen.set(!isCartOpen())"
(ticketsClick)="onTicketsClick()" (ticketsClick)="onTicketsClick()"
(loginClick)="onLoginClick()" (loginClick)="onLoginClick()"
@@ -16,10 +19,7 @@
/> />
@if (isCartOpen()) { @if (isCartOpen()) {
<div <div class="store-layout__cart-overlay" (click)="isCartOpen.set(false)"></div>
class="store-layout__cart-overlay"
(click)="isCartOpen.set(false)"
></div>
<div class="store-layout__cart-dropdown card border-0 shadow-lg"> <div class="store-layout__cart-dropdown card border-0 shadow-lg">
<app-cart <app-cart
[showClose]="true" [showClose]="true"
@@ -30,10 +30,7 @@
[backgroundColor]="'#ffffff'" [backgroundColor]="'#ffffff'"
(closed)="isCartOpen.set(false)" (closed)="isCartOpen.set(false)"
> >
<app-button <app-button variant="secondary" class="flex-grow-1" (click)="isCartOpen.set(false)"
variant="secondary"
class="flex-grow-1"
(click)="isCartOpen.set(false)"
>Seguir comprando</app-button >Seguir comprando</app-button
> >
<app-button <app-button

View File

@@ -23,7 +23,8 @@
position: absolute; position: absolute;
top: 76px; top: 76px;
right: calc((100% - 1320px) / 2 + 1.5rem); right: calc((100% - 1320px) / 2 + 1.5rem);
width: 100%;
max-width: 400px;
max-height: calc(100vh - 120px); max-height: calc(100vh - 120px);
z-index: 1050; z-index: 1050;
background-color: #ffffff; background-color: #ffffff;
@@ -40,6 +41,7 @@
right: 1rem; right: 1rem;
left: 1rem; left: 1rem;
width: auto; width: auto;
max-width: none;
} }
} }

View File

@@ -204,6 +204,29 @@ describe('StoreLayoutComponent', () => {
expect(compiled.querySelector('app-store-footer .store-layout__footer')).not.toBeNull(); 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', () => { it('renders the tenant branding and action icons', () => {
const fixture = TestBed.createComponent(StoreLayoutComponent); const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges(); fixture.detectChanges();

View File

@@ -6,7 +6,7 @@ import { StoreFooterComponent, StoreFooterSection } from './store-footer/store-f
import { StoreHeaderComponent } from './store-header/store-header.component'; import { StoreHeaderComponent } from './store-header/store-header.component';
import { CartComponent, CartItemMock } from '../../../shared/components/cart/cart.component'; import { CartComponent, CartItemMock } from '../../../shared/components/cart/cart.component';
import { ButtonComponent } from '../../../shared/components/button/button.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 { AuthService } from '../../services/auth/auth.service';
import { findMenu } from '../../services/menu.utils'; import { findMenu } from '../../services/menu.utils';
import { CheckoutService } from '../../services/checkout.service'; 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 { return {
cartItemId: item.id, cartItemId: item.id,
imageUrl: item.product?.imagen ?? null, imageUrl: item.product?.imagen ?? null,
@@ -78,9 +93,21 @@ export class StoreLayoutComponent implements OnInit {
discountPercentage: null, discountPercentage: null,
attributes, attributes,
quantity: item.cantidad, 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 currentYear = new Date().getFullYear();
protected readonly tenant = this.tenantService.tenant; protected readonly tenant = this.tenantService.tenant;
protected readonly user = this.authService.user; protected readonly user = this.authService.user;

View File

@@ -1,6 +1,24 @@
export interface CartItemProduct { export interface CartItemProduct {
nombre: string; nombre: string;
imagen: string | null; imagen: string | null;
variants?: CartItemVariant[];
}
export interface CartItemVariantOption {
value: string;
label: string;
}
export type CartItemVariantValue =
| string
| CartItemVariantOption
| Array<string | CartItemVariantOption>;
export interface CartItemVariant {
id: number;
precio: string;
stock_tecnico: number | null;
values: Record<string, CartItemVariantValue>;
} }
export interface CartItem { export interface CartItem {

View File

@@ -50,9 +50,7 @@ export class CartService extends BaseApiService {
): Observable<ApiResponse<Cart>> { ): Observable<ApiResponse<Cart>> {
this.isUpdatingState.set(true); this.isUpdatingState.set(true);
return this.http return this.http
.post< .post<ApiResponse<Cart>>(
ApiResponse<Cart>
>(
`${this.tenantApiUrl}/cart/items`, `${this.tenantApiUrl}/cart/items`,
{ catalog_item_id: catalogItemId, variant_id: variantId, cantidad }, { catalog_item_id: catalogItemId, variant_id: variantId, cantidad },
{ {
@@ -71,21 +69,27 @@ export class CartService extends BaseApiService {
); );
} }
updateItemQuantity( updateItemQuantity(cartItemId: number, cantidad: number): Observable<ApiResponse<Cart>> {
return this.updateItem(cartItemId, { cantidad });
}
updateItemVariant(
cartItemId: number, cartItemId: number,
cantidad: number, cantidad: number,
variantId: number,
): Observable<ApiResponse<Cart>> {
return this.updateItem(cartItemId, { cantidad, variant_id: variantId });
}
private updateItem(
cartItemId: number,
payload: { cantidad: number; variant_id?: number },
): Observable<ApiResponse<Cart>> { ): Observable<ApiResponse<Cart>> {
this.isUpdatingState.set(true); this.isUpdatingState.set(true);
return this.http return this.http
.patch< .patch<ApiResponse<Cart>>(`${this.tenantApiUrl}/cart/items/${cartItemId}`, payload, {
ApiResponse<Cart> withCredentials: true,
>( })
`${this.tenantApiUrl}/cart/items/${cartItemId}`,
{ cantidad },
{
withCredentials: true,
},
)
.pipe( .pipe(
tap((response) => { tap((response) => {
this.cartState.set(response.data); this.cartState.set(response.data);
@@ -101,9 +105,7 @@ export class CartService extends BaseApiService {
removeItem(cartItemId: number): Observable<ApiResponse<Cart>> { removeItem(cartItemId: number): Observable<ApiResponse<Cart>> {
this.isUpdatingState.set(true); this.isUpdatingState.set(true);
return this.http return this.http
.delete< .delete<ApiResponse<Cart>>(`${this.tenantApiUrl}/cart/items/${cartItemId}`, {
ApiResponse<Cart>
>(`${this.tenantApiUrl}/cart/items/${cartItemId}`, {
withCredentials: true, withCredentials: true,
}) })
.pipe( .pipe(

View File

@@ -1,7 +1,10 @@
import { ApiPaginatedResponse } from '../api-paginated-response.interface'; import { ApiPaginatedResponse } from '../api-paginated-response.interface';
export type CatalogItemType = 'product' | 'bundle';
export interface Product { export interface Product {
id: number; id: number;
type: CatalogItemType;
category_id: number; category_id: number;
brand_id: number | null; brand_id: number | null;
slug: string; slug: string;
@@ -15,19 +18,31 @@ export interface Product {
export interface ProductAttributeOption { export interface ProductAttributeOption {
id: number; id: number;
validity_time_id?: number | null;
value: string; value: string;
label: string; label: string;
sort_order: number; sort_order: number;
metadata: Record<string, unknown> | null; metadata: Record<string, unknown> | null;
} }
export type ProductAttributeType =
| 'string'
| 'number'
| 'boolean'
| 'select'
| 'multiselect'
| 'color'
| 'image'
| 'event_date';
export interface ProductAttribute { export interface ProductAttribute {
id: number; id: number;
codigo: string; codigo: string;
nombre: string; nombre: string;
is_required: boolean; is_required: boolean;
allow_multi_select?: boolean;
metadata_schema: Record<string, unknown> | null; metadata_schema: Record<string, unknown> | null;
type: string; type: ProductAttributeType;
options: ProductAttributeOption[]; options: ProductAttributeOption[];
} }
@@ -35,12 +50,17 @@ export type InventoryPolicy = 'tracked' | 'unlimited';
export interface CatalogItemVariant { export interface CatalogItemVariant {
id: number; id: number;
descripcion?: string | null;
precio?: string;
event_date_id?: number | null;
event_date_ids?: number[];
event_dates?: string[];
stock_tecnico: number | null; stock_tecnico: number | null;
minimum_use_date?: string | null; minimum_use_date?: string | null;
maximum_use_date?: string | null; maximum_use_date?: string | null;
effective_minimum_use_date?: string | null; effective_minimum_use_date?: string | null;
effective_maximum_use_date?: string | null; effective_maximum_use_date?: string | null;
values: Record<string, string>; values: Record<string, string | string[]>;
} }
export interface SelectedCatalogItemVariant extends CatalogItemVariant { export interface SelectedCatalogItemVariant extends CatalogItemVariant {
@@ -49,6 +69,8 @@ export interface SelectedCatalogItemVariant extends CatalogItemVariant {
export interface CatalogItemDetail { export interface CatalogItemDetail {
id: number; id: number;
type: CatalogItemType;
purpose?: 'product' | 'entry';
category_id: number | null; category_id: number | null;
brand_id: number | null; brand_id: number | null;
slug: string; slug: string;
@@ -58,6 +80,7 @@ export interface CatalogItemDetail {
category: string | null; category: string | null;
brand: string | null; brand: string | null;
inventory_policy: InventoryPolicy; inventory_policy: InventoryPolicy;
max_units_per_user?: number | null;
has_tickets: boolean; has_tickets: boolean;
minimum_use_date: string | null; minimum_use_date: string | null;
maximum_use_date: string | null; maximum_use_date: string | null;
@@ -73,12 +96,15 @@ export type CatalogGroupLayout = 'paginated' | 'simple' | 'simple_vertical' | 'c
export interface CatalogFeaturedItemVariant { export interface CatalogFeaturedItemVariant {
id: number; id: number;
descripcion?: string | null;
precio?: string;
stock_tecnico: number | null; stock_tecnico: number | null;
values: Record<string, string>; values: Record<string, string | string[]>;
} }
export interface CatalogFeaturedItem { export interface CatalogFeaturedItem {
id: number; id: number;
type: CatalogItemType;
nombre: string; nombre: string;
descripcion?: string | null; descripcion?: string | null;
precio: number | string; precio: number | string;

View File

@@ -61,6 +61,7 @@ export interface PurchaseDetailResponse extends PurchaseStatusResponse {
user_id: number; user_id: number;
created_at: string | null; created_at: string | null;
payment_method: string | null; payment_method: string | null;
expires_at: string | null;
dni: string | null; dni: string | null;
transfer_payer_dni: string | null; transfer_payer_dni: string | null;
telefono: string | null; telefono: string | null;

View File

@@ -163,6 +163,8 @@ describe('ModalService', () => {
const activeModal = service.activeModal(); const activeModal = service.activeModal();
const resultPromise = firstValueFrom(result$); const resultPromise = firstValueFrom(result$);
expect(activeModal?.config.size).toBe('sm');
activeModal?.ref.dismiss('escape'); activeModal?.ref.dismiss('escape');
await expect(resultPromise).resolves.toBe(false); await expect(resultPromise).resolves.toBe(false);

View File

@@ -166,7 +166,10 @@ export class ModalService {
} }
openConfirmDeleteRef(config: ConfirmModalConfig): ModalRef<boolean> { openConfirmDeleteRef(config: ConfirmModalConfig): ModalRef<boolean> {
return this.open(ConfirmDeleteModalComponent, this.buildConfirmModalConfig(config)); return this.open(
ConfirmDeleteModalComponent,
this.buildConfirmModalConfig({ size: 'sm', ...config }),
);
} }
openSimple(config: SimpleModalConfig): Observable<void> { openSimple(config: SimpleModalConfig): Observable<void> {

View File

@@ -9,18 +9,62 @@ export interface BankAccount {
cvu: string; 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 { export interface HeroConfig {
background_image?: string | null; background_image_id?: string | null;
title_html?: string | null; title_html?: string | null;
description_html?: string | null; description_html?: string | null;
button_text?: string | null; button_text?: string | null;
button_href?: 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 { export interface EventConfig {
id?: number;
title?: string | null; title?: string | null;
location?: 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 { export interface Menu {
@@ -60,14 +104,18 @@ export interface Tenant {
footer_bg_color: string; footer_bg_color: string;
header_logo: string; header_logo: string;
footer_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_id?: number | null;
selected_bank_account?: BankAccount | 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_product_layout?: 'row' | 'column_with_image' | 'column_with_cart';
search_group_layout?: 'paginated' | 'simple' | 'simple_vertical' | 'carousel'; search_group_layout?: 'paginated' | 'simple' | 'simple_vertical' | 'carousel';
search_items_per_page?: number; search_items_per_page?: number;
main_carousel_images?: string[]; display_categories?: boolean;
display_seach_bar?: boolean;
social_media?: SocialMedia[]; social_media?: SocialMedia[];
menues?: Menu[]; menues?: Menu[];
categories: Category[]; categories: Category[];

View File

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

View File

@@ -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.', 'Acceso total al predio. No incluye acceso a estacionamiento. Niños menores de 5 años ingresan gratis.',
price: 10000, price: 10000,
variants: [ variants: [
{ label: '10 de Octubre', value: '10-oct' }, { value: '10-oct-manana', values: { fecha: '10 de Octubre', turno: 'Mañana' } },
{ label: '11 de Octubre', value: '11-oct' }, { value: '10-oct-tarde', values: { fecha: '10 de Octubre', turno: 'Tarde' } },
{ value: '11-oct-tarde', values: { fecha: '11 de Octubre', turno: 'Tarde' } },
], ],
}; };

View File

@@ -0,0 +1,15 @@
<section class="menu-content-section" [attr.aria-labelledby]="titleId()">
<header class="menu-content-section__header">
<div class="menu-content-section__prefix">
<ng-content select="[menuContentPrefix]" />
</div>
<h2 class="menu-content-section__title" [id]="titleId()">
{{ title() | uppercase }}
</h2>
</header>
<div class="menu-content-section__body">
<ng-content />
</div>
</section>

View File

@@ -0,0 +1,53 @@
:host {
display: block;
width: 100%;
min-width: 0;
}
.menu-content-section {
width: 100%;
}
.menu-content-section__header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.25rem;
}
.menu-content-section__prefix:empty {
display: none;
}
.menu-content-section__title {
margin: 0;
color: #888888;
font-size: 1rem;
font-weight: 600;
line-height: 1.3;
}
.menu-content-section__body {
min-width: 0;
}
@media (max-width: 767.98px) {
.menu-content-section {
margin-inline: auto;
}
.menu-content-section__header {
position: relative;
justify-content: center;
}
.menu-content-section__prefix:not(:empty) {
position: absolute;
left: 0;
}
.menu-content-section__title {
width: 100%;
text-align: center;
}
}

View File

@@ -0,0 +1,58 @@
import { Component, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { describe, expect, it } from 'vitest';
import { Tenant } from '../../../../core/services/tenant.interface';
import { TenantService } from '../../../../core/services/tenant.service';
import { MenuContentSectionComponent } from './menu-content-section.component';
@Component({
imports: [MenuContentSectionComponent],
template: `
<app-menu-content-section menuCode="help.contact" titleId="contact-title">
<a menuContentPrefix href="/ayuda">Volver</a>
<p>Contenido proyectado</p>
</app-menu-content-section>
`,
})
class TestHostComponent {}
describe('MenuContentSectionComponent', () => {
it('renders an accessible title and projects its content', async () => {
const tenant = signal({
menues: [
{
id: 1,
code: 'help.contact',
label: 'Contacto del comercio',
parent_menu_code: 'help',
content_type: 'static',
route: '/ayuda/contacto',
submenues: [],
},
],
} as unknown as Tenant);
await TestBed.configureTestingModule({
imports: [TestHostComponent],
providers: [{ provide: TenantService, useValue: { tenant: tenant.asReadonly() } }],
}).compileComponents();
const fixture = TestBed.createComponent(TestHostComponent);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const section = element.querySelector('section');
expect(section?.getAttribute('aria-labelledby')).toBe('contact-title');
expect(element.querySelector('#contact-title')?.textContent?.trim()).toBe(
'CONTACTO DEL COMERCIO',
);
expect(element.querySelector('.menu-content-section__prefix a')?.textContent).toContain(
'Volver',
);
expect(element.querySelector('.menu-content-section__body')?.textContent).toContain(
'Contenido proyectado',
);
});
});

View File

@@ -0,0 +1,22 @@
import { UpperCasePipe } from '@angular/common';
import { Component, computed, inject, input } from '@angular/core';
import { findMenu } from '../../../../core/services/menu.utils';
import { TenantService } from '../../../../core/services/tenant.service';
@Component({
selector: 'app-menu-content-section',
imports: [UpperCasePipe],
templateUrl: './menu-content-section.component.html',
styleUrl: './menu-content-section.component.scss',
})
export class MenuContentSectionComponent {
private readonly tenantService = inject(TenantService);
readonly menuCode = input.required<string>();
readonly titleId = input.required<string>();
protected readonly title = computed(
() => findMenu(this.tenantService.tenant()?.menues ?? [], this.menuCode())?.label ?? '',
);
}

View File

@@ -0,0 +1,22 @@
<div class="menu-page-layout">
<aside class="menu-page-layout__sidebar">
<h1 class="menu-page-layout__title">{{ menu()?.label }}</h1>
<div class="menu-page-layout__divider"></div>
<nav class="menu-page-layout__nav" [attr.aria-label]="navigationLabel">
@for (submenu of submenues(); track submenu.code) {
<a
class="menu-page-layout__link"
[routerLink]="submenu.route"
routerLinkActive="menu-page-layout__link--active"
>
{{ submenu.label }}
</a>
}
</nav>
</aside>
<main class="menu-page-layout__content">
<router-outlet />
</main>
</div>

View File

@@ -0,0 +1,88 @@
:host {
display: block;
width: 100%;
}
.menu-page-layout {
display: grid;
grid-template-columns: minmax(190px, 250px) minmax(0, 1fr);
gap: clamp(3rem, 8vw, 6.25rem);
width: 100%;
&__sidebar,
&__content {
min-width: 0;
}
&__sidebar {
display: flex;
flex-direction: column;
}
&__title {
margin: 0 0 0.75rem;
color: #1a1a1a;
font-size: 1rem;
font-weight: 700;
line-height: 1.25;
text-transform: uppercase;
}
&__divider {
height: 1px;
margin-bottom: 1.25rem;
background: #dedede;
}
&__nav {
display: flex;
flex-direction: column;
gap: 1rem;
}
&__link {
color: #888888;
font-size: 0.875rem;
font-weight: 500;
line-height: 1.35;
text-decoration: none;
transition: color 150ms ease;
&:hover,
&:focus-visible {
color: #5a5a5a;
}
&:focus-visible {
border-radius: 2px;
outline: 2px solid var(--color-primary);
outline-offset: 3px;
}
&--active {
color: var(--color-primary);
font-weight: 600;
}
}
}
@media (max-width: 767.98px) {
.menu-page-layout {
grid-template-columns: 1fr;
gap: 2rem;
&__title {
text-align: center;
}
&__nav {
flex-flow: row wrap;
justify-content: center;
gap: 0.75rem 1.25rem;
}
&__content {
width: 100%;
}
}
}

View File

@@ -0,0 +1,136 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, provideRouter } from '@angular/router';
import { describe, expect, it } from 'vitest';
import { Tenant } from '../../../../core/services/tenant.interface';
import { TenantService } from '../../../../core/services/tenant.service';
import { MenuPageLayoutComponent } from './menu-page-layout.component';
function createTenant(): Tenant {
return {
id: 1,
codigo: 'test',
nombre: 'Test',
dominio: 'localhost',
primary_color: '#000000',
secondary_color: '#000000',
danger_color: '#000000',
success_color: '#000000',
header_bg_color: '#ffffff',
footer_bg_color: '#ffffff',
header_logo: '',
footer_logo: '',
categories: [],
menues: [
{
id: 1,
code: 'account',
label: 'Mi cuenta',
parent_menu_code: null,
content_type: 'dynamic',
route: '/mi-cuenta',
submenues: [
{
id: 2,
code: 'account.profile',
label: 'Datos personales',
parent_menu_code: 'account',
content_type: 'dynamic',
route: '/mi-cuenta/datos-personales',
submenues: [],
},
{
id: 3,
code: 'account.purchases',
label: 'Mis compras',
parent_menu_code: 'account',
content_type: 'dynamic',
route: '/mi-cuenta/compras',
submenues: [],
},
],
},
{
id: 4,
code: 'help',
label: 'Centro de ayuda',
parent_menu_code: null,
content_type: 'dynamic',
route: '/ayuda',
submenues: [
{
id: 5,
code: 'help.faq',
label: 'Consultas habituales',
parent_menu_code: 'help',
content_type: 'dynamic',
route: '/ayuda/preguntas-frecuentes',
submenues: [],
},
],
},
],
};
}
async function renderLayout(menuCode: string, navigationLabel: string): Promise<HTMLElement> {
const tenant = signal(createTenant());
await TestBed.configureTestingModule({
imports: [MenuPageLayoutComponent],
providers: [
provideRouter([]),
{
provide: ActivatedRoute,
useValue: {
snapshot: {
data: { menuCode, navigationLabel },
},
},
},
{
provide: TenantService,
useValue: { tenant: tenant.asReadonly() },
},
],
}).compileComponents();
const fixture = TestBed.createComponent(MenuPageLayoutComponent);
fixture.detectChanges();
return fixture.nativeElement as HTMLElement;
}
describe('MenuPageLayoutComponent', () => {
it('renders the configured account menu and its navigation label', async () => {
const element = await renderLayout('account', 'Secciones de mi cuenta');
const links = Array.from(
element.querySelectorAll<HTMLAnchorElement>('.menu-page-layout__link'),
);
expect(element.querySelector('.menu-page-layout__title')?.textContent?.trim()).toBe(
'Mi cuenta',
);
expect(element.querySelector('nav')?.getAttribute('aria-label')).toBe('Secciones de mi cuenta');
expect(links.map((link) => link.textContent?.trim())).toEqual([
'Datos personales',
'Mis compras',
]);
expect(links.map((link) => link.getAttribute('href'))).toEqual([
'/mi-cuenta/datos-personales',
'/mi-cuenta/compras',
]);
});
it('uses the same component for the help menu', async () => {
const element = await renderLayout('help', 'Secciones de ayuda');
expect(element.querySelector('.menu-page-layout__title')?.textContent?.trim()).toBe(
'Centro de ayuda',
);
expect(element.querySelector('.menu-page-layout__link')?.textContent?.trim()).toBe(
'Consultas habituales',
);
});
});

View File

@@ -0,0 +1,26 @@
import { Component, computed, inject } from '@angular/core';
import { ActivatedRoute, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { findMenu } from '../../../../core/services/menu.utils';
import { TenantService } from '../../../../core/services/tenant.service';
@Component({
selector: 'app-menu-page-layout',
imports: [RouterLink, RouterLinkActive, RouterOutlet],
templateUrl: './menu-page-layout.component.html',
styleUrl: './menu-page-layout.component.scss',
})
export class MenuPageLayoutComponent {
private readonly route = inject(ActivatedRoute);
private readonly tenantService = inject(TenantService);
protected readonly navigationLabel =
(this.route.snapshot.data['navigationLabel'] as string | undefined) ?? 'Secciones';
private readonly menuCode = this.route.snapshot.data['menuCode'] as string | undefined;
protected readonly menu = computed(() =>
findMenu(this.tenantService.tenant()?.menues ?? [], this.menuCode ?? ''),
);
protected readonly submenues = computed(() => this.menu()?.submenues ?? []);
}

View File

@@ -1,5 +1,5 @@
<div class="attribute-selector"> <div class="attribute-selector">
@for (attribute of attributes(); track attribute.id) { @for (attribute of attributes(); track attribute.codigo) {
<div class="attribute-selector__row"> <div class="attribute-selector__row">
<span class="attribute-selector__label">{{ attribute.nombre }}:</span> <span class="attribute-selector__label">{{ attribute.nombre }}:</span>
@@ -10,12 +10,14 @@
type="button" type="button"
class="attribute-selector__swatch" class="attribute-selector__swatch"
[class.attribute-selector__swatch--selected]="hasSelectedOption(attribute, option)" [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)" [style.background-color]="getOptionSwatchColor(option)"
[attr.aria-label]="option.label" [attr.aria-label]="option.label"
[attr.aria-pressed]="hasSelectedOption(attribute, option)" [attr.aria-pressed]="hasSelectedOption(attribute, option)"
[title]="option.label" [title]="option.label"
[disabled]="!availableOptions()[attribute.id][option.id]" [disabled]="!availableOptions()[attribute.codigo][option.id]"
(click)="selectAttributeOption(attribute, option)" (click)="selectAttributeOption(attribute, option)"
> >
<span class="visually-hidden">{{ option.label }}</span> <span class="visually-hidden">{{ option.label }}</span>
@@ -24,10 +26,14 @@
<button <button
type="button" type="button"
class="attribute-selector__text-option" class="attribute-selector__text-option"
[class.attribute-selector__text-option--selected]="hasSelectedOption(attribute, option)" [class.attribute-selector__text-option--selected]="
[class.attribute-selector__text-option--disabled]="!availableOptions()[attribute.id][option.id]" hasSelectedOption(attribute, option)
"
[class.attribute-selector__text-option--disabled]="
!availableOptions()[attribute.codigo][option.id]
"
[attr.aria-pressed]="hasSelectedOption(attribute, option)" [attr.aria-pressed]="hasSelectedOption(attribute, option)"
[disabled]="!availableOptions()[attribute.id][option.id]" [disabled]="!availableOptions()[attribute.codigo][option.id]"
(click)="selectAttributeOption(attribute, option)" (click)="selectAttributeOption(attribute, option)"
> >
{{ option.label }} {{ option.label }}

View File

@@ -68,4 +68,46 @@ describe('ProductAttributeSelectorComponent', () => {
expect(buttons[0].disabled).toBe(true); expect(buttons[0].disabled).toBe(true);
expect(buttons[1].disabled).toBe(false); expect(buttons[1].disabled).toBe(false);
}); });
it('allows multiple event dates and emits the exact matching variant', () => {
const fixture = TestBed.createComponent(ProductAttributeSelectorComponent);
const emittedIds: Array<number | null> = [];
fixture.componentInstance.variantChange.subscribe((variant) =>
emittedIds.push(variant?.id ?? null),
);
fixture.componentRef.setInput('attributes', [
{
id: 2,
codigo: 'event_date',
nombre: 'Fecha',
is_required: true,
allow_multi_select: true,
metadata_schema: null,
type: 'event_date',
options: [
{ id: 101, value: '1', label: '09/10/2026', sort_order: 0, metadata: null },
{ id: 102, value: '2', label: '10/10/2026', sort_order: 1, metadata: null },
],
} satisfies ProductAttribute,
]);
fixture.componentRef.setInput('inventoryPolicy', 'unlimited');
fixture.componentRef.setInput('variants', [
{ id: 1, stock_tecnico: null, values: { event_date: '1' } },
{ id: 2, stock_tecnico: null, values: { event_date: '2' } },
{ id: 3, stock_tecnico: null, values: { event_date: ['1', '2'] } },
]);
fixture.detectChanges();
const buttons = Array.from(
fixture.nativeElement.querySelectorAll('.attribute-selector__text-option'),
) as HTMLButtonElement[];
buttons[0].click();
fixture.detectChanges();
buttons[1].click();
fixture.detectChanges();
expect(buttons[0].getAttribute('aria-pressed')).toBe('true');
expect(buttons[1].getAttribute('aria-pressed')).toBe('true');
expect(emittedIds).toContain(3);
});
}); });

View File

@@ -32,48 +32,65 @@ export class ProductAttributeSelectorComponent {
public variantChange = output<CatalogItemVariant | null>(); public variantChange = output<CatalogItemVariant | null>();
protected readonly selectedAttributeOptions = signal<Record<number, number>>({}); protected readonly selectedAttributeOptions = signal<Record<string, number[]>>({});
protected readonly availableOptions = computed(() => { protected readonly availableOptions = computed(() => {
const selections = this.selectedAttributeOptions(); const selections = this.selectedAttributeOptions();
const variants = this.variants(); const variants = this.variants();
const attributes = this.attributes(); const attributes = this.attributes();
const availability: Record<number, Record<number, boolean>> = {}; const availability: Record<string, Record<number, boolean>> = {};
for (const attribute of attributes) { for (const attribute of attributes) {
availability[attribute.id] = {}; availability[attribute.codigo] = {};
for (const option of attribute.options) { for (const option of attribute.options) {
const optionNormalized = this.normalizeText(option.value || option.label); const optionNormalized = this.normalizeText(option.value || option.label);
const selectedForAttribute = selections[attribute.codigo] ?? [];
if (
!attribute.allow_multi_select &&
selectedForAttribute.length >= 1 &&
!selectedForAttribute.includes(option.id)
) {
availability[attribute.codigo][option.id] = false;
continue;
}
const isAvailable = variants.some((variant) => { const isAvailable = variants.some((variant) => {
if (!this.isVariantAvailable(variant)) return false; if (!this.isVariantAvailable(variant)) return false;
const variantAttrValue = this.getDefaultVariantAttributeValue(attribute, variant.values); const variantAttrValues = this.getVariantAttributeValues(attribute, variant.values);
if (!variantAttrValue || this.normalizeText(variantAttrValue) !== optionNormalized) { const desiredOptionIds = selectedForAttribute.includes(option.id)
? selectedForAttribute
: [...selectedForAttribute, option.id];
const desiredValues = desiredOptionIds
.map((id) => attribute.options.find((candidate) => candidate.id === id))
.filter((candidate): candidate is ProductAttributeOption => candidate !== undefined)
.map((candidate) => this.normalizeText(candidate.value || candidate.label));
if (
!variantAttrValues.includes(optionNormalized) ||
!desiredValues.every((value) => variantAttrValues.includes(value))
) {
return false; return false;
} }
for (const otherAttr of attributes) { for (const otherAttr of attributes) {
if (otherAttr.id === attribute.id) continue; if (otherAttr.codigo === attribute.codigo) continue;
const selectedOptionId = selections[otherAttr.id]; const selectedOptionIds = selections[otherAttr.codigo] ?? [];
if (selectedOptionId !== undefined) { const selectedValues = selectedOptionIds
const selectedOption = otherAttr.options.find((o) => o.id === selectedOptionId); .map((id) => otherAttr.options.find((candidate) => candidate.id === id))
if (selectedOption) { .filter((candidate): candidate is ProductAttributeOption => candidate !== undefined)
const selectedNormalized = this.normalizeText( .map((candidate) => this.normalizeText(candidate.value || candidate.label));
selectedOption.value || selectedOption.label, const variantValues = this.getVariantAttributeValues(otherAttr, variant.values);
); if (!selectedValues.every((value) => variantValues.includes(value))) {
const vAttrValue = this.getDefaultVariantAttributeValue(otherAttr, variant.values); return false;
if (!vAttrValue || this.normalizeText(vAttrValue) !== selectedNormalized) {
return false;
}
}
} }
} }
return true; return true;
}); });
availability[attribute.id][option.id] = isAvailable; availability[attribute.codigo][option.id] = isAvailable;
} }
} }
@@ -109,17 +126,28 @@ export class ProductAttributeSelectorComponent {
attribute: ProductAttribute, attribute: ProductAttribute,
option: ProductAttributeOption, option: ProductAttributeOption,
): boolean { ): boolean {
return this.selectedAttributeOptions()[attribute.id] === option.id; return (this.selectedAttributeOptions()[attribute.codigo] ?? []).includes(option.id);
} }
protected selectAttributeOption( protected selectAttributeOption(
attribute: ProductAttribute, attribute: ProductAttribute,
option: ProductAttributeOption, option: ProductAttributeOption,
): void { ): void {
this.selectedAttributeOptions.update((current) => ({ this.selectedAttributeOptions.update((current) => {
...current, const selected = current[attribute.codigo] ?? [];
[attribute.id]: option.id, const isMultiple = attribute.allow_multi_select ?? false;
})); let next: number[];
if (!isMultiple) {
next = [option.id];
} else if (selected.includes(option.id)) {
next = selected.filter((id) => id !== option.id);
} else {
next = [...selected, option.id];
}
return { ...current, [attribute.codigo]: next };
});
} }
protected isColorOption(option: ProductAttributeOption): boolean { protected isColorOption(option: ProductAttributeOption): boolean {
@@ -134,51 +162,44 @@ export class ProductAttributeSelectorComponent {
attributes: ProductAttribute[], attributes: ProductAttribute[],
defaultVariant: CatalogItemVariant | null, defaultVariant: CatalogItemVariant | null,
): void { ): void {
const selections: Record<number, number> = {}; const selections: Record<string, number[]> = {};
for (const attribute of attributes) { for (const attribute of attributes) {
const optionId = this.findDefaultOptionId(attribute, defaultVariant); const optionIds = this.findDefaultOptionIds(attribute, defaultVariant);
if (optionId !== null) { if (optionIds.length > 0) {
selections[attribute.id] = optionId; selections[attribute.codigo] = optionIds;
} }
} }
this.selectedAttributeOptions.set(selections); this.selectedAttributeOptions.set(selections);
} }
private findDefaultOptionId( private findDefaultOptionIds(
attribute: ProductAttribute, attribute: ProductAttribute,
variant: CatalogItemVariant | null, variant: CatalogItemVariant | null,
): number | null { ): number[] {
if (!variant) { if (!variant) {
return null; return [];
} }
const defaultValue = this.getDefaultVariantAttributeValue(attribute, variant.values); const defaultValues = this.getVariantAttributeValues(attribute, variant.values);
if (!defaultValue) { if (defaultValues.length === 0) {
return null; return [];
} }
const normalizedValue = this.normalizeText(defaultValue); return attribute.options
const matchByValue = attribute.options.find( .filter(
(option) => this.normalizeText(option.value) === normalizedValue, (option) =>
); defaultValues.includes(this.normalizeText(option.value)) ||
defaultValues.includes(this.normalizeText(option.label)),
if (matchByValue) { )
return matchByValue.id; .map((option) => option.id);
}
const matchByLabel = attribute.options.find(
(option) => this.normalizeText(option.label) === normalizedValue,
);
return matchByLabel?.id ?? null;
} }
private getDefaultVariantAttributeValue( private getVariantAttributeValues(
attribute: ProductAttribute, attribute: ProductAttribute,
variantAttributes: Record<string, string>, variantAttributes: Record<string, string | string[]>,
): string | null { ): string[] {
const normalizedCodigo = this.normalizeText(attribute.codigo); const normalizedCodigo = this.normalizeText(attribute.codigo);
const normalizedNombre = this.normalizeText(attribute.nombre); const normalizedNombre = this.normalizeText(attribute.nombre);
@@ -186,11 +207,11 @@ export class ProductAttributeSelectorComponent {
const normalizedKey = this.normalizeText(key); const normalizedKey = this.normalizeText(key);
if (normalizedKey === normalizedCodigo || normalizedKey === normalizedNombre) { if (normalizedKey === normalizedCodigo || normalizedKey === normalizedNombre) {
return value; return (Array.isArray(value) ? value : [value]).map((item) => this.normalizeText(item));
} }
} }
return null; return [];
} }
private normalizeText(value: string | null | undefined): string { private normalizeText(value: string | null | undefined): string {
@@ -234,7 +255,7 @@ export class ProductAttributeSelectorComponent {
} }
private emitMatchingVariant( private emitMatchingVariant(
selections: Record<number, number>, selections: Record<string, number[]>,
variants: CatalogItemVariant[], variants: CatalogItemVariant[],
attributes: ProductAttribute[], attributes: ProductAttribute[],
): void { ): void {
@@ -243,18 +264,22 @@ export class ProductAttributeSelectorComponent {
return; return;
} }
const selectedValuesById: Record<number, string> = {}; const selectedValuesByCode: Record<string, string[]> = {};
let allSelected = true; let allSelected = true;
for (const attr of attributes) { for (const attr of attributes) {
const selectedOptionId = selections[attr.id]; const selectedOptionIds = selections[attr.codigo] ?? [];
if (selectedOptionId === undefined) { if (
selectedOptionIds.length === 0 ||
(!attr.allow_multi_select && selectedOptionIds.length !== 1)
) {
allSelected = false; allSelected = false;
break; break;
} }
const option = attr.options.find((o) => o.id === selectedOptionId); selectedValuesByCode[attr.codigo] = selectedOptionIds
if (option) { .map((id) => attr.options.find((option) => option.id === id))
selectedValuesById[attr.id] = this.normalizeText(option.value || option.label); .filter((option): option is ProductAttributeOption => option !== undefined)
} .map((option) => this.normalizeText(option.value || option.label))
.sort();
} }
if (!allSelected) { if (!allSelected) {
@@ -264,10 +289,12 @@ export class ProductAttributeSelectorComponent {
const matchingVariant = variants.find((variant) => { const matchingVariant = variants.find((variant) => {
return attributes.every((attr) => { return attributes.every((attr) => {
const selectedValue = selectedValuesById[attr.id]; const selectedValues = selectedValuesByCode[attr.codigo];
const variantValue = this.getDefaultVariantAttributeValue(attr, variant.values); const variantValues = this.getVariantAttributeValues(attr, variant.values).sort();
if (!variantValue) return false; return (
return this.normalizeText(variantValue) === selectedValue; selectedValues.length === variantValues.length &&
selectedValues.every((value, index) => value === variantValues[index])
);
}); });
}); });

View File

@@ -1,11 +0,0 @@
<div class="sidebar-container">
<h2 class="sidebar-title">{{ menu()?.label }}</h2>
<div class="divider"></div>
<nav class="sidebar-nav" aria-label="Secciones de mi cuenta">
@for (menu of submenues(); track menu.code) {
<a [routerLink]="menu.route" routerLinkActive="active" class="nav-link">
{{ menu.label }}
</a>
}
</nav>
</div>

View File

@@ -1,35 +0,0 @@
.sidebar-container {
display: flex;
flex-direction: column;
}
.sidebar-title {
font-size: 16px;
font-weight: bold;
color: #1a1a1a;
margin-bottom: 12px;
text-transform: uppercase;
}
.divider {
height: 1px;
background-color: #e0e0e0;
margin-bottom: 20px;
}
.sidebar-nav {
display: flex;
flex-direction: column;
gap: 16px;
}
.nav-link {
text-decoration: none;
color: #888;
font-size: 14px;
font-weight: 500;
transition: color 0.2s;
}
.nav-link:hover {
color: #5a5a5a;
}
.nav-link.active {
color: var(--color-primary); /* Blue color matching the image */
font-weight: 600;
}

View File

@@ -1,85 +0,0 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { describe, expect, it } from 'vitest';
import { Tenant } from '../../../../../../core/services/tenant.interface';
import { TenantService } from '../../../../../../core/services/tenant.service';
import { AccountSidebar } from './account-sidebar';
describe('AccountSidebar', () => {
it('renders only the account submenus configured for the tenant', async () => {
const tenant = signal<Tenant>({
id: 1,
codigo: 'test',
nombre: 'Test',
dominio: 'localhost',
primary_color: '#000000',
secondary_color: '#000000',
danger_color: '#000000',
success_color: '#000000',
header_bg_color: '#ffffff',
footer_bg_color: '#ffffff',
header_logo: '',
footer_logo: '',
categories: [],
menues: [
{
id: 1,
code: 'account',
label: 'Mi cuenta',
parent_menu_code: null,
content_type: 'dynamic',
route: '/mi-cuenta',
submenues: [
{
id: 2,
code: 'account.profile',
label: 'Datos personales',
parent_menu_code: 'account',
content_type: 'dynamic',
route: '/mi-cuenta/datos-personales',
submenues: [],
},
{
id: 3,
code: 'account.purchases',
label: 'Mis compras',
parent_menu_code: 'account',
content_type: 'dynamic',
route: '/mi-cuenta/compras',
submenues: [],
},
],
},
],
});
await TestBed.configureTestingModule({
imports: [AccountSidebar],
providers: [
provideRouter([]),
{
provide: TenantService,
useValue: { tenant: tenant.asReadonly() },
},
],
}).compileComponents();
const fixture = TestBed.createComponent(AccountSidebar);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const links = Array.from(element.querySelectorAll<HTMLAnchorElement>('.nav-link'));
expect(element.querySelector('.sidebar-title')?.textContent?.trim()).toBe('Mi cuenta');
expect(links.map((link) => link.textContent?.trim())).toEqual([
'Datos personales',
'Mis compras',
]);
expect(links.map((link) => link.getAttribute('href'))).toEqual([
'/mi-cuenta/datos-personales',
'/mi-cuenta/compras',
]);
});
});

View File

@@ -1,21 +0,0 @@
import { Component, computed, inject } from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router';
import { findMenu } from '../../../../../../core/services/menu.utils';
import { TenantService } from '../../../../../../core/services/tenant.service';
@Component({
selector: 'app-account-sidebar',
standalone: true,
imports: [RouterLink, RouterLinkActive],
templateUrl: './account-sidebar.html',
styleUrl: './account-sidebar.scss',
})
export class AccountSidebar {
private readonly tenantService = inject(TenantService);
protected readonly menu = computed(() =>
findMenu(this.tenantService.tenant()?.menues ?? [], 'account'),
);
protected readonly submenues = computed(() => this.menu()?.submenues ?? []);
}

View File

@@ -13,3 +13,13 @@
flex: 1; flex: 1;
display: block; display: block;
} }
@media (max-width: 767.98px) {
.form-actions {
flex-direction: column;
}
.action-btn {
width: 100%;
}
}

View File

@@ -1,4 +1,7 @@
<div class="page-container"> <app-menu-content-section
<h2 class="page-title">DATOS PERSONALES</h2> class="account-page"
<app-profile-form></app-profile-form> menuCode="account.profile"
</div> titleId="profile-page-title"
>
<app-profile-form />
</app-menu-content-section>

View File

@@ -1,13 +1,10 @@
.page-container { :host {
display: flex; display: block;
flex-direction: column; width: 100%;
}
.account-page {
display: block;
width: 100%; width: 100%;
max-width: 600px; max-width: 600px;
} }
.page-title {
font-size: 15px;
font-weight: bold;
color: #A0A0A0;
margin-bottom: 24px;
}

View File

@@ -1,10 +1,11 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { MenuContentSectionComponent } from '../../../../components/menu-content-section/menu-content-section.component';
import { ProfileForm } from '../../components/profile-form/profile-form'; import { ProfileForm } from '../../components/profile-form/profile-form';
@Component({ @Component({
selector: 'app-profile-page', selector: 'app-profile-page',
standalone: true, standalone: true,
imports: [ProfileForm], imports: [ProfileForm, MenuContentSectionComponent],
templateUrl: './profile-page.html', templateUrl: './profile-page.html',
styleUrl: './profile-page.scss', styleUrl: './profile-page.scss',
}) })

View File

@@ -1,26 +1,35 @@
<div class="page-container"> <app-menu-content-section
<div class="d-flex align-items-center mb-4"> class="account-page"
<a routerLink="../" class="text-decoration-none " > menuCode="account.purchases"
<i class="bi bi-arrow-left fs-5"></i> titleId="purchase-detail-page-title"
</a> >
<h2 class="page-title m-0">MIS COMPRAS</h2> <a
</div> menuContentPrefix
routerLink="../"
class="purchase-back-link text-decoration-none"
aria-label="Volver a mis compras"
>
<i class="bi bi-arrow-left fs-5" aria-hidden="true"></i>
</a>
@if (isLoading()) { @if (isLoading()) {
<p class="purchase-loading">Cargando detalle de compra...</p> <p class="purchase-loading">Cargando detalle de compra...</p>
} @else if (purchase(); as purchase) { } @else if (purchase(); as purchase) {
<div class="d-flex justify-content-between align-items-center mb-4 pb-3" style="border-bottom: 1px solid #dddddd;"> <div
class="d-flex justify-content-between align-items-center mb-4 pb-3"
style="border-bottom: 1px solid #dddddd"
>
<div class="purchase-info"> <div class="purchase-info">
<span class="purchase-id">Compra {{ purchase.id }}.</span> <span class="purchase-id">Compra {{ purchase.id }}.</span>
<span class="purchase-date">Fecha de compra: {{ purchase.date }}</span> <span class="purchase-date">Fecha de compra: {{ purchase.date }}</span>
</div> </div>
<div class="purchase-total "> <div class="purchase-total">
<span class="purchase-total-label">Total:</span> <span class="purchase-total-label">Total:</span>
<span class="purchase-total-value">${{ purchase.total }}</span> <span class="purchase-total-value">${{ purchase.total }}</span>
</div> </div>
</div> </div>
<p style="font-size: 12px; color: #666666; margin:0;">Productos:</p> <p style="font-size: 12px; color: #666666; margin: 0">Productos:</p>
<div class="d-flex flex-column"> <div class="d-flex flex-column">
@for (item of purchase.items; track item.id) { @for (item of purchase.items; track item.id) {
@@ -30,4 +39,4 @@
} }
</div> </div>
} }
</div> </app-menu-content-section>

View File

@@ -1,14 +1,21 @@
.page-container { :host {
display: flex; display: block;
flex-direction: column; width: 100%;
}
.account-page {
display: block;
width: 100%; width: 100%;
max-width: 600px; max-width: 600px;
} }
.page-title { .purchase-back-link {
font-size: 15px; color: #888888;
font-weight: bold;
color: #A0A0A0; &:hover,
&:focus-visible {
color: var(--color-primary);
}
} }
.purchase-info { .purchase-info {

View File

@@ -8,6 +8,7 @@ import {
} from '../../../../../../core/services/checkout.service'; } from '../../../../../../core/services/checkout.service';
import { TenantService } from '../../../../../../core/services/tenant.service'; import { TenantService } from '../../../../../../core/services/tenant.service';
import { ToastService } from '../../../../../../core/services/toast.service'; import { ToastService } from '../../../../../../core/services/toast.service';
import { MenuContentSectionComponent } from '../../../../components/menu-content-section/menu-content-section.component';
import { PurchaseItem, PurchaseItemViewModel } from '../../components/purchase-item/purchase-item'; import { PurchaseItem, PurchaseItemViewModel } from '../../components/purchase-item/purchase-item';
type PurchaseDetailViewModel = { type PurchaseDetailViewModel = {
@@ -20,7 +21,7 @@ type PurchaseDetailViewModel = {
@Component({ @Component({
selector: 'app-purchase-detail-page', selector: 'app-purchase-detail-page',
standalone: true, standalone: true,
imports: [CommonModule, RouterLink, PurchaseItem], imports: [CommonModule, RouterLink, PurchaseItem, MenuContentSectionComponent],
templateUrl: './purchase-detail-page.html', templateUrl: './purchase-detail-page.html',
styleUrl: './purchase-detail-page.scss', styleUrl: './purchase-detail-page.scss',
}) })

View File

@@ -1,4 +1,7 @@
<div class="page-container"> <app-menu-content-section
<h2 class="page-title">MIS COMPRAS</h2> class="account-page"
<app-purchase-list></app-purchase-list> menuCode="account.purchases"
</div> titleId="purchases-page-title"
>
<app-purchase-list />
</app-menu-content-section>

View File

@@ -1,12 +1,10 @@
.page-container { :host {
display: flex; display: block;
flex-direction: column; width: 100%;
}
.account-page {
display: block;
width: 100%; width: 100%;
max-width: 600px; max-width: 600px;
} }
.page-title {
font-size: 15px;
font-weight: bold;
color: #A0A0A0;
}

View File

@@ -1,10 +1,11 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { MenuContentSectionComponent } from '../../../../components/menu-content-section/menu-content-section.component';
import { PurchaseList } from '../../components/purchase-list/purchase-list'; import { PurchaseList } from '../../components/purchase-list/purchase-list';
@Component({ @Component({
selector: 'app-purchases-page', selector: 'app-purchases-page',
standalone: true, standalone: true,
imports: [PurchaseList], imports: [PurchaseList, MenuContentSectionComponent],
templateUrl: './purchases-page.html', templateUrl: './purchases-page.html',
styleUrl: './purchases-page.scss', styleUrl: './purchases-page.scss',
}) })

View File

@@ -74,6 +74,7 @@
.ticket__actions { .ticket__actions {
display: flex; display: flex;
grid-column: 4;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
} }
@@ -91,6 +92,10 @@
color: #8a8a8a; color: #8a8a8a;
} }
.ticket--disabled .ticket__actions {
grid-column: 3;
}
:host ::ng-deep .ticket--disabled button.ticket__qr-button:disabled { :host ::ng-deep .ticket--disabled button.ticket__qr-button:disabled {
--bs-btn-disabled-bg: #8a8a8a; --bs-btn-disabled-bg: #8a8a8a;
--bs-btn-disabled-border-color: #8a8a8a; --bs-btn-disabled-border-color: #8a8a8a;

View File

@@ -3,6 +3,16 @@ import { firstValueFrom } from 'rxjs';
import { BaseApiService } from '../../../../../../core/services/base-api.service'; import { BaseApiService } from '../../../../../../core/services/base-api.service';
import { TenantService } from '../../../../../../core/services/tenant.service'; import { TenantService } from '../../../../../../core/services/tenant.service';
import { ValidityTime } from '../../../../../../core/services/validity-time.interface';
export interface TicketValidityGroupResponse {
id: number;
validity_times: ValidityTime[];
starts_at: string | null;
expires_at: string | null;
is_valid: boolean;
is_expired: boolean;
}
export interface TicketResponse { export interface TicketResponse {
id: number; id: number;
@@ -12,6 +22,8 @@ export interface TicketResponse {
description: string | null; description: string | null;
source_catalog_item_id: number | null; source_catalog_item_id: number | null;
source_variant_id: number | null; source_variant_id: number | null;
validity_times?: ValidityTime[];
validity_groups?: TicketValidityGroupResponse[];
starts_at: string | null; starts_at: string | null;
expires_at: string | null; expires_at: string | null;
used_at: string | null; used_at: string | null;

View File

@@ -1,6 +1,8 @@
<section class="tickets-page"> <app-menu-content-section
<h1 class="tickets-page__title">MIS TICKETS</h1> class="tickets-page"
menuCode="account.tickets"
titleId="tickets-page-title"
>
<div class="tickets-list" [attr.aria-busy]="isLoading() || isGeneratingPdf()"> <div class="tickets-list" [attr.aria-busy]="isLoading() || isGeneratingPdf()">
@if (isGeneratingPdf()) { @if (isGeneratingPdf()) {
<div class="tickets-list__generating" role="status" aria-live="polite"> <div class="tickets-list__generating" role="status" aria-live="polite">
@@ -106,4 +108,4 @@
<p class="tickets-list__empty">Aún no tenés tickets disponibles.</p> <p class="tickets-list__empty">Aún no tenés tickets disponibles.</p>
} }
</div> </div>
</section> </app-menu-content-section>

View File

@@ -1,15 +1,9 @@
.tickets-page { .tickets-page {
display: block;
width: 100%; width: 100%;
max-width: 720px; max-width: 720px;
} }
.tickets-page__title {
margin: 0 0 20px;
color: #a0a0a0;
font-size: 15px;
font-weight: 700;
}
.tickets-list { .tickets-list {
position: relative; position: relative;
} }
@@ -206,10 +200,6 @@
} }
@media (max-width: 767.98px) { @media (max-width: 767.98px) {
.tickets-page__title {
margin-top: 24px;
}
.tickets-list__skeleton { .tickets-list__skeleton {
grid-template-columns: auto minmax(0, 1fr) auto; grid-template-columns: auto minmax(0, 1fr) auto;
gap: 10px; gap: 10px;

View File

@@ -1,16 +1,16 @@
import { signal } from '@angular/core';
import { By } from '@angular/platform-browser'; import { By } from '@angular/platform-browser';
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { TenantService } from '../../../../../../core/services/tenant.service';
import { ToastService } from '../../../../../../core/services/toast.service'; import { ToastService } from '../../../../../../core/services/toast.service';
import { ModalService } from '../../../../../../core/services/modal.service'; import { ModalService } from '../../../../../../core/services/modal.service';
import { TicketComponent } from './components/ticket/ticket.component'; import { TicketComponent } from './components/ticket/ticket.component';
import { TicketResponse, TicketService } from './ticket.service'; import { TicketResponse, TicketService } from './ticket.service';
import { TicketsPage } from './tickets-page'; import { TicketsPage } from './tickets-page';
function withCustomLoading<T extends object>( function withCustomLoading<T extends object>(service: T): T & { withCustomLoading: () => T } {
service: T,
): T & { withCustomLoading: () => T } {
return Object.assign(service, { return Object.assign(service, {
withCustomLoading: () => service, withCustomLoading: () => service,
}); });
@@ -34,6 +34,31 @@ const ticket = (overrides: Partial<TicketResponse>): TicketResponse => ({
}); });
describe('TicketsPage', () => { describe('TicketsPage', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
{
provide: TenantService,
useValue: {
tenant: signal({
menues: [
{
id: 4,
code: 'account.tickets',
label: 'Entradas disponibles',
parent_menu_code: 'account',
content_type: 'dynamic',
route: '/mi-cuenta/tickets',
submenues: [],
},
],
}).asReadonly(),
},
},
],
});
});
it('shares the generated PDF as a file without text that Windows could prioritize', async () => { it('shares the generated PDF as a file without text that Windows could prioritize', async () => {
const share = vi.fn().mockResolvedValue(undefined); const share = vi.fn().mockResolvedValue(undefined);
const canShare = vi.fn().mockReturnValue(true); const canShare = vi.fn().mockReturnValue(true);
@@ -92,11 +117,11 @@ describe('TicketsPage', () => {
const createObjectUrl = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:ticket'); const createObjectUrl = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:ticket');
const revokeObjectUrl = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined); const revokeObjectUrl = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined);
let downloadedFilename = ''; let downloadedFilename = '';
const linkClick = vi const linkClick = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (
.spyOn(HTMLAnchorElement.prototype, 'click') this: HTMLAnchorElement,
.mockImplementation(function (this: HTMLAnchorElement) { ) {
downloadedFilename = this.download; downloadedFilename = this.download;
}); });
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [TicketsPage], imports: [TicketsPage],

View File

@@ -3,12 +3,13 @@ import { Component, computed, inject, OnInit, signal } from '@angular/core';
import { ModalService } from '../../../../../../core/services/modal.service'; import { ModalService } from '../../../../../../core/services/modal.service';
import { ToastService } from '../../../../../../core/services/toast.service'; import { ToastService } from '../../../../../../core/services/toast.service';
import { IconButtonComponent } from '../../../../../../shared/components/icon-button/icon-button.component'; import { IconButtonComponent } from '../../../../../../shared/components/icon-button/icon-button.component';
import { MenuContentSectionComponent } from '../../../../components/menu-content-section/menu-content-section.component';
import { TicketComponent } from './components/ticket/ticket.component'; import { TicketComponent } from './components/ticket/ticket.component';
import { TicketResponse, TicketService } from './ticket.service'; import { TicketResponse, TicketService } from './ticket.service';
@Component({ @Component({
selector: 'app-tickets-page', selector: 'app-tickets-page',
imports: [IconButtonComponent, TicketComponent], imports: [IconButtonComponent, TicketComponent, MenuContentSectionComponent],
providers: [TicketService], providers: [TicketService],
templateUrl: './tickets-page.html', templateUrl: './tickets-page.html',
styleUrl: './tickets-page.scss', styleUrl: './tickets-page.scss',

View File

@@ -1,6 +0,0 @@
<div class="sidebar-layout">
<app-account-sidebar class="account-sidebar"></app-account-sidebar>
<div class="account-content">
<router-outlet></router-outlet>
</div>
</div>

View File

@@ -1,17 +0,0 @@
.sidebar-layout {
display: flex;
margin: 0 auto;
gap: 100px;
min-height: calc(100vh - 100px);
}
.account-sidebar {
width: 250px;
flex-shrink: 0;
}
.account-content {
flex: 1;
}
label {
font-size: 13px !important;
}

View File

@@ -1,12 +0,0 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { AccountSidebar } from '../components/account-sidebar/account-sidebar';
@Component({
selector: 'app-sidebar-layout',
standalone: true,
imports: [RouterOutlet, AccountSidebar],
templateUrl: './sidebar-layout.html',
styleUrl: './sidebar-layout.scss',
})
export class SidebarLayout {}

View File

@@ -20,6 +20,6 @@
display: block; display: block;
width: 100%; width: 100%;
flex: 1 1 290px; flex: 1 1 290px;
max-width: 290px;
} }
} }

View File

@@ -6,6 +6,15 @@
@media (max-width: 900px) { @media (max-width: 900px) {
grid-template-columns: 1fr; grid-template-columns: 1fr;
.checkout-page__cart-col {
order: 1;
}
.checkout-page__stepper-col,
.checkout-page__editing-notice {
order: 2;
}
} }
&__stepper-col { &__stepper-col {

View File

@@ -60,7 +60,7 @@ describe('CheckoutPageComponent payment validation', () => {
qr_data: { qr_code: 'qr-value' }, qr_data: { qr_code: 'qr-value' },
}), }),
getPurchase: vi.fn().mockResolvedValue({ status: 'pending_payment' }), getPurchase: vi.fn().mockResolvedValue({ status: 'pending_payment' }),
submitPurchaseForReview: vi.fn().mockResolvedValue({ status: 'in_review' }), submitPurchaseForReview: vi.fn().mockResolvedValue({ status: 'pending_payment' }),
withCustomLoading: vi.fn(), withCustomLoading: vi.fn(),
}; };
checkoutServiceStub.withCustomLoading.mockReturnValue(checkoutServiceStub); checkoutServiceStub.withCustomLoading.mockReturnValue(checkoutServiceStub);
@@ -199,20 +199,20 @@ describe('CheckoutPageComponent payment validation', () => {
component.selectedPaymentMethod.set('transfer'); component.selectedPaymentMethod.set('transfer');
await component.onComplete(); await component.onComplete();
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(1); expect(checkoutServiceStub.submitPurchaseForReview).toHaveBeenCalledTimes(1);
expect(component.transferValidationStatus()).toBe('pending'); expect(component.transferValidationStatus()).toBe('pending');
expect(cartServiceStub.clearCart).not.toHaveBeenCalled(); expect(cartServiceStub.clearCart).not.toHaveBeenCalled();
expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25]); expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25]);
await vi.advanceTimersByTimeAsync(30_000); await vi.advanceTimersByTimeAsync(30_000);
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(1); expect(checkoutServiceStub.submitPurchaseForReview).toHaveBeenCalledTimes(1);
await component.onComplete(); await component.onComplete();
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(1); expect(checkoutServiceStub.submitPurchaseForReview).toHaveBeenCalledTimes(1);
}); });
it('navigates after a transfer is confirmed as paid', async () => { it('navigates after a transfer is confirmed as paid', async () => {
checkoutServiceStub.getPurchase.mockResolvedValue({ status: 'paid' }); checkoutServiceStub.submitPurchaseForReview.mockResolvedValue({ status: 'paid' });
const { component } = createComponent(); const { component } = createComponent();
component.selectedPaymentMethod.set('transfer'); component.selectedPaymentMethod.set('transfer');
@@ -375,7 +375,7 @@ describe('CheckoutPageComponent payment validation', () => {
expect(component.qrPaymentStatus()).toBe('waiting'); expect(component.qrPaymentStatus()).toBe('waiting');
}); });
it.each(['in_review', 'paid', 'cancelled', 'rejected', 'expired'])( it.each(['paid', 'cancelled', 'rejected', 'expired'])(
'redirects a %s purchase to its status page', 'redirects a %s purchase to its status page',
async (status) => { async (status) => {
routeQueryParamMap = convertToParamMap({ purchase: 25 }); routeQueryParamMap = convertToParamMap({ purchase: 25 });
@@ -395,6 +395,25 @@ describe('CheckoutPageComponent payment validation', () => {
}, },
); );
it('redirects a submitted pending payment purchase to its status page', async () => {
routeQueryParamMap = convertToParamMap({ purchase: 25 });
checkoutServiceStub.getPurchase.mockResolvedValue({
id: 25,
status: 'pending_payment',
payment_method: 'transfer',
expires_at: null,
transfer_payer_dni: null,
items: [],
subtotal: '100.00',
total: '100.00',
});
createComponent();
await Promise.resolve();
expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25]);
});
it('updates a purchase item while editing and refreshes checkout totals', async () => { it('updates a purchase item while editing and refreshes checkout totals', async () => {
const updatedPurchase = { const updatedPurchase = {
id: 25, id: 25,

View File

@@ -437,7 +437,11 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
.withCustomLoading() .withCustomLoading()
.submitPurchaseForReview(tenant.codigo, purchaseId); .submitPurchaseForReview(tenant.codigo, purchaseId);
if (purchase.status === 'paid') { if (purchase.status === 'paid' || purchase.status === 'pending_payment') {
if (purchase.status === 'pending_payment') {
this.transferValidationStatus.set('pending');
}
this.navigateToPurchaseStatus(purchaseId); this.navigateToPurchaseStatus(purchaseId);
return; return;
} }
@@ -576,7 +580,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
.getPurchase(tenant.codigo, purchaseId); .getPurchase(tenant.codigo, purchaseId);
if ( if (
purchase.status === 'in_review' || (purchase.status === 'pending_payment' && purchase.expires_at === null) ||
purchase.status === 'paid' || purchase.status === 'paid' ||
purchase.status === 'cancelled' || purchase.status === 'cancelled' ||
purchase.status === 'rejected' || purchase.status === 'rejected' ||

View File

@@ -34,7 +34,7 @@
</div> </div>
</section> </section>
<section class="payment-panel d-flex justify-content-end" aria-live="polite"> <section class="payment-panel" aria-live="polite">
@if (isGeneratingIntent()) { @if (isGeneratingIntent()) {
<div class="payment-panel__card" style="text-align: center; padding: 2rem"> <div class="payment-panel__card" style="text-align: center; padding: 2rem">
<h3 class="payment-panel__title">Cargando información de pago...</h3> <h3 class="payment-panel__title">Cargando información de pago...</h3>

View File

@@ -32,6 +32,11 @@
.payment-methods { .payment-methods {
min-width: 0; min-width: 0;
@media (max-width: 760px) {
width: min(100%, 300px);
justify-self: center;
}
&__title { &__title {
margin: 0 0 1.75rem; margin: 0 0 1.75rem;
color: #8a8a8a; color: #8a8a8a;
@@ -109,7 +114,22 @@
} }
.payment-panel { .payment-panel {
display: flex;
justify-content: flex-end;
min-width: 0; min-width: 0;
> app-checkout-payment-qr,
> app-checkout-payment-transfer,
> app-checkout-payment-telepagos {
display: block;
width: 100%;
max-width: 320px;
}
@media (max-width: 760px) {
justify-content: center;
}
&__card { &__card {
width: 100%; width: 100%;
max-width: 320px; max-width: 320px;

View File

@@ -1,16 +0,0 @@
<aside class="help-sidebar">
<h1 class="help-sidebar__title">{{ menu()?.label }}</h1>
<div class="help-sidebar__divider"></div>
<nav class="help-sidebar__nav" aria-label="Secciones de ayuda">
@for (menu of submenues(); track menu.code) {
<a
class="help-sidebar__link"
[routerLink]="menu.route"
routerLinkActive="help-sidebar__link--active"
>
{{ menu.label }}
</a>
}
</nav>
</aside>

View File

@@ -1,57 +0,0 @@
.help-sidebar {
display: flex;
flex-direction: column;
}
.help-sidebar__title {
margin: 0 0 0.75rem;
color: #1a1a1a;
font-size: 1rem;
font-weight: 700;
line-height: 1.25;
text-transform: uppercase;
}
.help-sidebar__divider {
height: 1px;
margin-bottom: 1.25rem;
background: #dedede;
}
.help-sidebar__nav {
display: flex;
flex-direction: column;
gap: 1rem;
}
.help-sidebar__link {
color: #888888;
font-size: 0.875rem;
font-weight: 500;
line-height: 1.35;
text-decoration: none;
transition: color 150ms ease;
&:hover,
&:focus-visible {
color: #5a5a5a;
}
&:focus-visible {
border-radius: 2px;
outline: 2px solid var(--color-primary);
outline-offset: 3px;
}
}
.help-sidebar__link--active {
color: var(--color-primary);
font-weight: 600;
}
@media (max-width: 767.98px) {
.help-sidebar__nav {
flex-flow: row wrap;
gap: 0.75rem 1.25rem;
}
}

View File

@@ -1,72 +0,0 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { describe, expect, it } from 'vitest';
import { Tenant } from '../../../../../../core/services/tenant.interface';
import { TenantService } from '../../../../../../core/services/tenant.service';
import { HelpSidebar } from './help-sidebar';
describe('HelpSidebar', () => {
it('renders the parent and submenu labels supplied by the tenant', async () => {
const tenant = signal<Tenant>({
id: 1,
codigo: 'test',
nombre: 'Test',
dominio: 'localhost',
primary_color: '#000000',
secondary_color: '#000000',
danger_color: '#000000',
success_color: '#000000',
header_bg_color: '#ffffff',
footer_bg_color: '#ffffff',
header_logo: '',
footer_logo: '',
categories: [],
menues: [
{
id: 1,
code: 'help',
label: 'Centro de ayuda',
parent_menu_code: null,
content_type: 'dynamic',
route: '/ayuda',
submenues: [
{
id: 2,
code: 'help.faq',
label: 'Consultas habituales',
parent_menu_code: 'help',
content_type: 'dynamic',
route: '/ayuda/preguntas-frecuentes',
submenues: [],
},
],
},
],
});
await TestBed.configureTestingModule({
imports: [HelpSidebar],
providers: [
provideRouter([]),
{
provide: TenantService,
useValue: { tenant: tenant.asReadonly() },
},
],
}).compileComponents();
const fixture = TestBed.createComponent(HelpSidebar);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('.help-sidebar__title')?.textContent?.trim()).toBe(
'Centro de ayuda',
);
expect(element.querySelector('.help-sidebar__link')?.textContent?.trim()).toBe(
'Consultas habituales',
);
});
});

View File

@@ -1,20 +0,0 @@
import { Component, computed, inject } from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router';
import { findMenu } from '../../../../../../core/services/menu.utils';
import { TenantService } from '../../../../../../core/services/tenant.service';
@Component({
selector: 'app-help-sidebar',
imports: [RouterLink, RouterLinkActive],
templateUrl: './help-sidebar.html',
styleUrl: './help-sidebar.scss',
})
export class HelpSidebar {
private readonly tenantService = inject(TenantService);
protected readonly menu = computed(() =>
findMenu(this.tenantService.tenant()?.menues ?? [], 'help'),
);
protected readonly submenues = computed(() => this.menu()?.submenues ?? []);
}

View File

@@ -1,53 +1,53 @@
<section class="contact-page" aria-labelledby="contact-page-title"> <app-menu-content-section menuCode="help.contact" titleId="contact-page-title">
<div class="contact-page__details"> <div class="contact-page">
<h2 id="contact-page-title" class="contact-page__title">CONTACTO</h2> <div class="contact-page__details">
@if (contact().whatsappUrl && contact().whatsappLabel) {
<a
class="contact-page__contact-link"
[href]="contact().whatsappUrl"
target="_blank"
rel="noopener noreferrer"
>
<i class="fa-brands fa-whatsapp" aria-hidden="true"></i>
<span>{{ contact().whatsappLabel }}</span>
</a>
}
@if (contact().whatsappUrl && contact().whatsappLabel) { @if (contact().phone; as phone) {
<a <a class="contact-page__contact-link" [href]="phoneUrl(phone)">
class="contact-page__contact-link" <i class="fa-solid fa-phone" aria-hidden="true"></i>
[href]="contact().whatsappUrl" <span>{{ phone }}</span>
target="_blank" </a>
rel="noopener noreferrer" }
>
<i class="fa-brands fa-whatsapp" aria-hidden="true"></i>
<span>{{ contact().whatsappLabel }}</span>
</a>
}
@if (contact().phone; as phone) { @for (location of contact().locations; track location.id) {
<a class="contact-page__contact-link" [href]="phoneUrl(phone)"> <article class="contact-page__location">
<i class="fa-solid fa-phone" aria-hidden="true"></i> <h3 class="contact-page__location-title">
<span>{{ phone }}</span> <i class="fa-solid fa-location-dot" aria-hidden="true"></i>
</a> <span>{{ location.label }}</span>
} </h3>
@for (location of contact().locations; track location.id) { <ul class="contact-page__addresses">
<article class="contact-page__location"> @for (address of location.addresses; track address.id) {
<h3 class="contact-page__location-title"> <li>{{ address.address }}</li>
<i class="fa-solid fa-location-dot" aria-hidden="true"></i> }
<span>{{ location.label }}</span> </ul>
</h3> </article>
}
<ul class="contact-page__addresses"> @if (!contact().whatsappUrl && !contact().phone && contact().locations.length === 0) {
@for (address of location.addresses; track address.id) { <p class="contact-page__empty">No hay datos de contacto disponibles.</p>
<li>{{ address.address }}</li> }
} </div>
</ul>
</article>
}
@if (!contact().whatsappUrl && !contact().phone && contact().locations.length === 0) { @if (contact().mapLocations.length > 0) {
<p class="contact-page__empty">No hay datos de contacto disponibles.</p> <app-location-map
class="contact-page__map"
[locations]="contact().mapLocations"
[markerColor]="markerColor()"
mapStyle="minimal-light"
ariaLabel="Mapa de sucursales y puntos de atención"
/>
} }
</div> </div>
</app-menu-content-section>
@if (contact().mapLocations.length > 0) {
<app-location-map
class="contact-page__map"
[locations]="contact().mapLocations"
[markerColor]="markerColor()"
mapStyle="minimal-light"
ariaLabel="Mapa de sucursales y puntos de atención"
/>
}
</section>

View File

@@ -20,14 +20,6 @@
align-items: flex-start; align-items: flex-start;
} }
.contact-page__title {
margin: 0 0 1.25rem;
color: inherit;
font-size: 1rem;
font-weight: 400;
line-height: 1.3;
}
.contact-page__contact-link { .contact-page__contact-link {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -122,6 +114,26 @@
} }
} }
@media (max-width: 767.98px) {
.contact-page__details {
align-items: center;
text-align: center;
}
.contact-page__location {
width: 100%;
}
.contact-page__location-title {
justify-content: center;
}
.contact-page__addresses {
padding-left: 0;
list-style-position: inside;
}
}
@media (max-width: 575.98px) { @media (max-width: 575.98px) {
.contact-page__map::ng-deep .location-map { .contact-page__map::ng-deep .location-map {
min-height: 16rem; min-height: 16rem;

View File

@@ -2,6 +2,7 @@ import { Component, computed, inject } from '@angular/core';
import { findMenu } from '../../../../../../core/services/menu.utils'; import { findMenu } from '../../../../../../core/services/menu.utils';
import { TenantService } from '../../../../../../core/services/tenant.service'; import { TenantService } from '../../../../../../core/services/tenant.service';
import { MenuContentSectionComponent } from '../../../../components/menu-content-section/menu-content-section.component';
import { import {
LocationMapComponent, LocationMapComponent,
MapLocation, MapLocation,
@@ -30,7 +31,7 @@ type ContactContent = {
@Component({ @Component({
selector: 'app-contact-page', selector: 'app-contact-page',
imports: [LocationMapComponent], imports: [LocationMapComponent, MenuContentSectionComponent],
templateUrl: './contact-page.html', templateUrl: './contact-page.html',
styleUrl: './contact-page.scss', styleUrl: './contact-page.scss',
}) })

View File

@@ -1,14 +1,8 @@
<section class="faq-page" aria-labelledby="faq-page-title"> <app-menu-content-section class="faq-page" menuCode="help.faq" titleId="faq-page-title">
<h2 id="faq-page-title" class="faq-page__title">PREGUNTAS FRECUENTES</h2>
@if (faqs().length > 0) { @if (faqs().length > 0) {
<app-accordion [flush]="true"> <app-accordion [flush]="true">
@for (faq of faqs(); track $index; let last = $last) { @for (faq of faqs(); track $index; let last = $last) {
<app-accordion-item <app-accordion-item [title]="faq.question" [headingLevel]="3" [open]="last">
[title]="faq.question"
[headingLevel]="3"
[open]="last"
>
<p>{{ faq.answer }}</p> <p>{{ faq.answer }}</p>
</app-accordion-item> </app-accordion-item>
} }
@@ -16,4 +10,4 @@
} @else { } @else {
<p class="faq-page__empty">No hay preguntas frecuentes disponibles.</p> <p class="faq-page__empty">No hay preguntas frecuentes disponibles.</p>
} }
</section> </app-menu-content-section>

View File

@@ -4,18 +4,11 @@
} }
.faq-page { .faq-page {
display: block;
width: 100%; width: 100%;
max-width: 760px; max-width: 760px;
} }
.faq-page__title {
margin: 0 0 0.5rem;
color: #a0a0a0;
font-size: 0.9375rem;
font-weight: 700;
line-height: 1.3;
}
.faq-page p { .faq-page p {
margin: 0; margin: 0;
} }

View File

@@ -4,6 +4,7 @@ import { findMenu } from '../../../../../../core/services/menu.utils';
import { TenantService } from '../../../../../../core/services/tenant.service'; import { TenantService } from '../../../../../../core/services/tenant.service';
import { AccordionItemComponent } from '../../../../../../shared/components/accordion/accordion-item.component'; import { AccordionItemComponent } from '../../../../../../shared/components/accordion/accordion-item.component';
import { AccordionComponent } from '../../../../../../shared/components/accordion/accordion.component'; import { AccordionComponent } from '../../../../../../shared/components/accordion/accordion.component';
import { MenuContentSectionComponent } from '../../../../components/menu-content-section/menu-content-section.component';
type Faq = { type Faq = {
question: string; question: string;
@@ -12,7 +13,7 @@ type Faq = {
@Component({ @Component({
selector: 'app-faq-page', selector: 'app-faq-page',
imports: [AccordionComponent, AccordionItemComponent], imports: [AccordionComponent, AccordionItemComponent, MenuContentSectionComponent],
templateUrl: './faq-page.html', templateUrl: './faq-page.html',
styleUrl: './faq-page.scss', styleUrl: './faq-page.scss',
}) })

View File

@@ -1,7 +0,0 @@
<div class="help-layout">
<app-help-sidebar class="help-layout__sidebar" />
<div class="help-layout__content">
<router-outlet />
</div>
</div>

View File

@@ -1,25 +0,0 @@
:host {
display: block;
}
.help-layout {
display: grid;
grid-template-columns: minmax(190px, 250px) minmax(0, 1fr);
gap: clamp(3rem, 8vw, 6.25rem);
width: 100%;
}
.help-layout__sidebar {
min-width: 0;
}
.help-layout__content {
min-width: 0;
}
@media (max-width: 767.98px) {
.help-layout {
grid-template-columns: 1fr;
gap: 2rem;
}
}

View File

@@ -1,12 +0,0 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { HelpSidebar } from '../components/help-sidebar/help-sidebar';
@Component({
selector: 'app-help-sidebar-layout',
imports: [RouterOutlet, HelpSidebar],
templateUrl: './help-sidebar-layout.html',
styleUrl: './help-sidebar-layout.scss',
})
export class HelpSidebarLayout {}

View File

@@ -26,7 +26,7 @@
<div class="product-detail__price-group"> <div class="product-detail__price-group">
<span class="product-detail__price-current"> <span class="product-detail__price-current">
{{ getFormattedPrice(prod.precio) }} {{ getFormattedPrice(effectivePrice()) }}
</span> </span>
@if (oldPrice(); as oldPrice) { @if (oldPrice(); as oldPrice) {
@@ -99,7 +99,7 @@
[class.product-detail__description-body--expanded]="descriptionExpanded()" [class.product-detail__description-body--expanded]="descriptionExpanded()"
[style.max-height.px]="descriptionExpanded() ? descriptionMaxHeight() || null : null" [style.max-height.px]="descriptionExpanded() ? descriptionMaxHeight() || null : null"
> >
{{ prod.descripcion }} {{ effectiveDescription() }}
</div> </div>
@if (showDescriptionToggle()) { @if (showDescriptionToggle()) {

View File

@@ -22,6 +22,7 @@ import {
describe('ProductDetailPageComponent', () => { describe('ProductDetailPageComponent', () => {
const mockProduct: CatalogItemDetail = { const mockProduct: CatalogItemDetail = {
id: 1, id: 1,
type: 'product',
category_id: 10, category_id: 10,
brand_id: null, brand_id: null,
slug: 'auriculares-bluetooth', slug: 'auriculares-bluetooth',
@@ -327,6 +328,70 @@ describe('ProductDetailPageComponent', () => {
expect((swatches[0] as HTMLElement).style.backgroundColor).not.toBe(''); expect((swatches[0] as HTMLElement).style.backgroundColor).not.toBe('');
}); });
it('renders event dates as a dynamic attribute and resolves their variants', async () => {
const detailProduct: CatalogItemDetail = {
...mockProduct,
purpose: 'entry',
has_tickets: true,
variants: [
{ id: 101, event_date_id: 20, stock_tecnico: 10, values: { event_date: '20' } },
{ id: 102, event_date_id: 21, stock_tecnico: 10, values: { event_date: '21' } },
],
attributes: [
{
id: 99,
codigo: 'event_date',
nombre: 'Fecha',
is_required: true,
metadata_schema: null,
type: 'event_date',
options: [
{
id: 20,
value: '20',
label: '09/10/2026',
sort_order: 0,
metadata: null,
},
{
id: 21,
value: '21',
label: '10/10/2026',
sort_order: 1,
metadata: null,
},
],
},
],
selected_variant: {
id: 101,
event_date_id: 20,
stock_tecnico: 10,
images: [],
values: {},
},
};
resolveProduct(detailProduct);
catalogServiceStub.getCatalogItem.mockReturnValue(
of({ ...detailProduct, selected_variant: { ...detailProduct.selected_variant!, id: 102 } }),
);
await configureTestingModule();
const fixture = TestBed.createComponent(ProductDetailPageComponent);
fixture.detectChanges();
const options = fixture.nativeElement.querySelectorAll(
'.attribute-selector__text-option',
) as NodeListOf<HTMLButtonElement>;
expect(options).toHaveLength(2);
expect(options[0].classList.contains('attribute-selector__text-option--selected')).toBe(true);
options[1].click();
fixture.detectChanges();
expect(catalogServiceStub.getCatalogItem).toHaveBeenCalledWith(1, 102);
});
it('hides the attributes block and its extra divider when no attributes are present', async () => { it('hides the attributes block and its extra divider when no attributes are present', async () => {
await configureTestingModule(); await configureTestingModule();
const fixture = TestBed.createComponent(ProductDetailPageComponent); const fixture = TestBed.createComponent(ProductDetailPageComponent);
@@ -564,6 +629,39 @@ describe('ProductDetailPageComponent', () => {
expect(fixture.componentInstance['quantity']()).toBe(2); expect(fixture.componentInstance['quantity']()).toBe(2);
}); });
it('caps an unlimited variant at the per-user purchase limit', async () => {
const unlimitedVariant = {
id: 322,
stock_tecnico: null,
values: {},
};
resolveProduct({
...mockProduct,
inventory_policy: 'unlimited',
max_units_per_user: 2,
selected_variant: {
id: 322,
stock_tecnico: null,
images: [],
values: { event_date: '20' },
},
variants: [unlimitedVariant],
});
await configureTestingModule();
const fixture = TestBed.createComponent(ProductDetailPageComponent);
fixture.detectChanges();
const increaseButton = fixture.nativeElement.querySelector(
'app-quantity-selector button:last-child',
) as HTMLButtonElement;
increaseButton.click();
fixture.detectChanges();
expect(fixture.componentInstance['quantity']()).toBe(2);
expect(increaseButton.disabled).toBe(true);
});
it('disables purchase actions for tracked variants without stock', async () => { it('disables purchase actions for tracked variants without stock', async () => {
const trackedVariant = { const trackedVariant = {
id: 654, id: 654,

View File

@@ -86,13 +86,28 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
protected readonly creatingDirectPurchase = signal(false); protected readonly creatingDirectPurchase = signal(false);
protected readonly error = signal<string | null>(null); protected readonly error = signal<string | null>(null);
protected readonly selectedVariant = signal<CatalogItemVariant | null>(null); protected readonly selectedVariant = signal<CatalogItemVariant | null>(null);
protected readonly effectiveDescription = computed(
() => this.selectedVariant()?.descripcion ?? this.product()?.descripcion ?? '',
);
protected readonly effectivePrice = computed(
() => this.selectedVariant()?.precio ?? this.product()?.precio,
);
protected readonly quantity = signal(1); protected readonly quantity = signal(1);
protected readonly selectedVariantMax = computed<number | null>(() => { protected readonly selectedVariantMax = computed<number | null>(() => {
const prod = this.product(); const prod = this.product();
const variant = this.selectedVariant(); const variant = this.selectedVariant();
if (variant) return variant.stock_tecnico; if (!prod) return 0;
if (prod && prod.variants.length === 0) return prod.stock_tecnico ?? null;
return 0; const stockLimit = variant
? variant.stock_tecnico
: prod.variants.length === 0
? (prod.stock_tecnico ?? null)
: 0;
const userLimit = prod.max_units_per_user ?? null;
if (stockLimit === null) return userLimit;
if (userLimit === null) return stockLimit;
return Math.min(stockLimit, userLimit);
}); });
protected readonly selectedVariantAvailable = computed(() => { protected readonly selectedVariantAvailable = computed(() => {
const prod = this.product(); const prod = this.product();
@@ -100,6 +115,7 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
const variant = this.selectedVariant(); const variant = this.selectedVariant();
if (variant) return this.isVariantAvailable(variant, prod); if (variant) return this.isVariantAvailable(variant, prod);
if (prod.purpose === 'entry') return false;
if (prod.variants.length > 0) return false; if (prod.variants.length > 0) return false;
return prod.inventory_policy === 'unlimited' || (prod.stock_tecnico ?? 0) > 0; return prod.inventory_policy === 'unlimited' || (prod.stock_tecnico ?? 0) > 0;
@@ -123,6 +139,7 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
this.carouselHost(); this.carouselHost();
this.descriptionBody(); this.descriptionBody();
this.product(); this.product();
this.selectedVariant();
this.descriptionExpanded(); this.descriptionExpanded();
if (this.isBrowser) { if (this.isBrowser) {
@@ -159,26 +176,26 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
.withCustomLoading() .withCustomLoading()
.getCatalogItem(productId, variantId) .getCatalogItem(productId, variantId)
.subscribe({ .subscribe({
next: (prod) => { next: (prod) => {
this.applyProduct(prod, false); this.applyProduct(prod, false);
this.variantLoading.set(false); this.variantLoading.set(false);
}, },
error: (err: HttpErrorResponse) => { error: (err: HttpErrorResponse) => {
const errorMessage = err.error?.message || 'No pudimos cargar la variante seleccionada.'; const errorMessage = err.error?.message || 'No pudimos cargar la variante seleccionada.';
this.toastService.danger(errorMessage); this.toastService.danger(errorMessage);
this.variantLoading.set(false); this.variantLoading.set(false);
this.product.update((currentProduct) => { this.product.update((currentProduct) => {
if (!currentProduct) return null; if (!currentProduct) return null;
return { return {
...currentProduct, ...currentProduct,
variants: currentProduct.variants.filter((variant) => variant.id !== variantId), variants: currentProduct.variants.filter((variant) => variant.id !== variantId),
}; };
}); });
this.attributeSelector()?.reset(); this.attributeSelector()?.reset();
}, },
}); });
} }
private applyResolvedData(resolvedData: ProductDetailResolvedData): void { private applyResolvedData(resolvedData: ProductDetailResolvedData): void {
@@ -230,8 +247,11 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
} }
this.selectedVariant.set(variant); this.selectedVariant.set(variant);
if (variant && variant.stock_tecnico !== null && this.quantity() > variant.stock_tecnico) { this.descriptionExpanded.set(false);
this.quantity.set(Math.max(1, variant.stock_tecnico)); this.descriptionHasOverflow.set(false);
const maximum = this.selectedVariantMax();
if (maximum !== null && this.quantity() > maximum) {
this.quantity.set(Math.max(1, maximum));
} }
const currentProduct = this.product(); const currentProduct = this.product();
@@ -253,17 +273,17 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
.withCustomLoading() .withCustomLoading()
.addItem(currentProduct.id, variant?.id ?? null, this.quantity()) .addItem(currentProduct.id, variant?.id ?? null, this.quantity())
.subscribe({ .subscribe({
next: (res) => { next: (res) => {
const msg = res.message || 'Producto agregado al carrito'; const msg = res.message || 'Producto agregado al carrito';
this.toastService.success(msg); this.toastService.success(msg);
this.addingToCart.set(false); this.addingToCart.set(false);
}, },
error: (err: HttpErrorResponse) => { error: (err: HttpErrorResponse) => {
const errorMessage = err.error?.message || 'No se pudo agregar el producto al carrito.'; const errorMessage = err.error?.message || 'No se pudo agregar el producto al carrito.';
this.toastService.danger(errorMessage); this.toastService.danger(errorMessage);
this.addingToCart.set(false); this.addingToCart.set(false);
}, },
}); });
} }
protected async buyNow(): Promise<void> { protected async buyNow(): Promise<void> {

View File

@@ -18,9 +18,10 @@
:host ::ng-deep .recover-password-code-page__inputs .form-control { :host ::ng-deep .recover-password-code-page__inputs .form-control {
min-height: 52px; min-height: 52px;
padding: 0.5rem; padding: 0.5rem !important;
text-align: center; text-align: center;
font-size: 20px; font-size: 20px;
background-image: none !important;
} }
:host ::ng-deep .recover-password-code-page__resend-action { :host ::ng-deep .recover-password-code-page__resend-action {

View File

@@ -1,19 +1,16 @@
@if (tenant()?.main_carousel_images; as mainCarouselImages) { @if (mainCarouselImages(); as images) {
@if (mainCarouselImages.length) { @if (images.length) {
<app-main-carousel <app-main-carousel
class="store-home__main-carousel" class="store-home__main-carousel"
[images]="mainCarouselImages" [images]="images"
ariaLabel="Imágenes destacadas de la tienda" ariaLabel="Imágenes destacadas de la tienda"
(firstImageReady)="mainCarouselReady.set(true)" (firstImageReady)="mainCarouselReady.set(true)"
/> />
} }
} }
@if (tenant()?.hero_config || tenant()?.event_config) { @if (heroConfig() || eventConfig()) {
<app-hero-banner <app-hero-banner [heroConfig]="heroConfig()" [eventConfig]="eventConfig()" />
[heroConfig]="tenant()?.hero_config"
[eventConfig]="tenant()?.event_config"
></app-hero-banner>
} }
@if (error()) { @if (error()) {
@@ -40,3 +37,9 @@
</app-store-section> </app-store-section>
} }
} }
@if (additionalInfo(); as content) {
<app-store-section class="store-home__additional-info" title="Información adicional">
<div class="store-home__additional-info-content" [innerHTML]="content"></div>
</app-store-section>
}

View File

@@ -11,6 +11,32 @@ app-store-section + app-store-section {
margin-top: clamp(3rem, 6vw, 5rem); margin-top: clamp(3rem, 6vw, 5rem);
} }
:host > .store-home__additional-info:not(:first-child) {
margin-top: clamp(3rem, 6vw, 5rem);
}
.store-home__additional-info-content {
width: 100%;
text-align: center;
}
::ng-deep .store-home__additional-info-content img {
display: block;
margin-right: auto;
margin-left: auto;
}
::ng-deep .store-home__additional-info-content > :last-child {
margin-bottom: 0;
}
@media (max-width: 767.98px) {
:host > app-hero-banner:first-child {
display: block;
margin-top: -3rem;
}
}
.store-home__alert-error { .store-home__alert-error {
margin: 0; margin: 0;
border-color: rgba(var(--tenant-danger-rgb, 220, 53, 69), 0.18); border-color: rgba(var(--tenant-danger-rgb, 220, 53, 69), 0.18);

View File

@@ -16,6 +16,7 @@ import { Tenant } from '../../../../core/services/tenant.interface';
import { TenantService } from '../../../../core/services/tenant.service'; import { TenantService } from '../../../../core/services/tenant.service';
import { ToastService } from '../../../../core/services/toast.service'; import { ToastService } from '../../../../core/services/toast.service';
import { ProductListComponent } from '../../../../shared/components/product-list/product-list.component'; import { ProductListComponent } from '../../../../shared/components/product-list/product-list.component';
import { HeroBannerComponent } from '../../../../shared/components/hero-banner/hero-banner.component';
import { StoreHomePageComponent } from './store-home-page.component'; import { StoreHomePageComponent } from './store-home-page.component';
import { import {
STORE_HOME_PRODUCTS_ERROR_MESSAGE, STORE_HOME_PRODUCTS_ERROR_MESSAGE,
@@ -88,7 +89,7 @@ const pageOneItems: CatalogFeaturedItem[] = [
}, },
]; ];
function createTenant(mainCarouselImages?: string[]): Tenant { function createTenant(extras: Tenant['extras'] = {}): Tenant {
return { return {
id: 1, id: 1,
codigo: 'acme', codigo: 'acme',
@@ -102,8 +103,18 @@ function createTenant(mainCarouselImages?: string[]): Tenant {
footer_bg_color: '#ffffff', footer_bg_color: '#ffffff',
header_logo: '/header.png', header_logo: '/header.png',
footer_logo: '/footer.png', footer_logo: '/footer.png',
website_type_code: 'shopit',
website_type: {
codigo: 'shopit',
nombre: 'ShopIt',
primary_color: null,
secondary_color: null,
danger_color: null,
success_color: null,
site_logo: null,
},
extras,
categories: [], categories: [],
main_carousel_images: mainCarouselImages,
}; };
} }
@@ -146,11 +157,8 @@ describe('StoreHomePageComponent', () => {
); );
}); });
it('renders the tenant main carousel images at the beginning of the home page', async () => { it('renders the carousel URLs received in tenant extras', async () => {
const mainCarouselImages = [ const carousel = ['https://example.com/carousel-1.webp', 'https://example.com/carousel-2.webp'];
'https://example.com/carousel-1.webp',
'https://example.com/carousel-2.webp',
];
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [StoreHomePageComponent], imports: [StoreHomePageComponent],
@@ -162,7 +170,7 @@ describe('StoreHomePageComponent', () => {
}, },
{ {
provide: TenantService, provide: TenantService,
useValue: createTenantServiceStub(createTenant(mainCarouselImages)), useValue: createTenantServiceStub(createTenant({ carousel })),
}, },
], ],
}).compileComponents(); }).compileComponents();
@@ -171,25 +179,60 @@ describe('StoreHomePageComponent', () => {
fixture.detectChanges(); fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement; const element = fixture.nativeElement as HTMLElement;
const carousel = element.querySelector('app-main-carousel'); const carouselElement = element.querySelector('app-main-carousel');
const images = carousel?.querySelectorAll('.main-carousel__image'); const images = carouselElement?.querySelectorAll('.main-carousel__image');
expect(element.firstElementChild).toBe(carousel); expect(element.firstElementChild).toBe(carouselElement);
expect(images).toHaveLength(2); expect(images).toHaveLength(2);
expect(images?.[0].getAttribute('src')).toBe(mainCarouselImages[0]); expect(images?.[0].getAttribute('src')).toBe(carousel[0]);
expect(images?.[1].getAttribute('src')).toBeNull();
expect(element.querySelectorAll('app-product-column-with-image img')).toHaveLength(0);
images?.[0].dispatchEvent(new Event('load'));
fixture.detectChanges();
expect(images?.[1].getAttribute('src')).toBe(mainCarouselImages[1]);
expect(element.querySelectorAll('app-product-column-with-image img')).toHaveLength(1);
fixture.destroy();
}); });
it('does not render the main carousel when the tenant has no images', async () => { it('renders the hero extra and the active tenant event', async () => {
const tenant = createTenant({
heroConfig: {
title_html: '<h1>Fiesta Fútbol Infantil</h1>',
description_html: '<p>Viví una jornada inolvidable de fútbol infantil.</p>',
background_image_id: 'https://example.com/hero.jpg',
},
});
tenant.event = {
title: 'Fiesta Fútbol Infantil',
location: 'Rosario, Santa Fe',
dates: [
{
id: 20,
date: '2026-12-05',
time_start: '09:00:00',
time_end: '18:00:00',
},
{
id: 21,
date: '2026-12-06',
time_start: '09:00:00',
time_end: '18:00:00',
},
],
};
tenant.event_date_text = '5 y 6 de Diciembre 2026';
tenant.website_type_code = 'onticket';
tenant.social_media = [
{
code: 'whatsapp',
icon: 'fa-brands fa-whatsapp',
name: 'WhatsApp',
url: 'https://wa.me/5493410000000',
},
];
tenant.website_type = {
codigo: 'onticket',
nombre: 'OnTicket',
primary_color: null,
secondary_color: null,
danger_color: null,
success_color: null,
site_logo: null,
};
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [StoreHomePageComponent], imports: [StoreHomePageComponent],
providers: [ providers: [
@@ -200,7 +243,7 @@ describe('StoreHomePageComponent', () => {
}, },
{ {
provide: TenantService, provide: TenantService,
useValue: createTenantServiceStub(createTenant([])), useValue: createTenantServiceStub(tenant),
}, },
], ],
}).compileComponents(); }).compileComponents();
@@ -208,7 +251,127 @@ describe('StoreHomePageComponent', () => {
const fixture = TestBed.createComponent(StoreHomePageComponent); const fixture = TestBed.createComponent(StoreHomePageComponent);
fixture.detectChanges(); fixture.detectChanges();
expect((fixture.nativeElement as HTMLElement).querySelector('app-main-carousel')).toBeNull(); const hero = (fixture.nativeElement as HTMLElement).querySelector(
'app-hero-banner .hero-banner',
) as HTMLElement;
expect(hero).not.toBeNull();
expect(hero.style.backgroundImage).toContain('https://example.com/hero.jpg');
expect(hero.textContent).toContain('Fiesta Fútbol Infantil');
expect(hero.textContent).toContain('Viví una jornada inolvidable de fútbol infantil.');
expect(hero.textContent).toContain('Rosario, Santa Fe');
expect(hero.textContent).toContain('5 y 6 de Diciembre 2026');
const heroComponent = fixture.debugElement.query(By.directive(HeroBannerComponent))
.componentInstance as HeroBannerComponent;
expect(heroComponent.eventConfig?.contact).toEqual(tenant.social_media);
});
it('renders additional information as an HTML section', async () => {
const description =
'<p>Consultá los <strong>términos del evento</strong>.</p><ul><li>Ingreso con DNI</li></ul>';
await TestBed.configureTestingModule({
imports: [StoreHomePageComponent],
providers: [
provideActivatedRoute({ response: createCatalog(), error: null }),
{
provide: CatalogService,
useValue: { getCatalog: vi.fn(), getFeaturedGroupItems: vi.fn() },
},
{
provide: TenantService,
useValue: createTenantServiceStub(
createTenant({ additionalInfoConfig: { description } }),
),
},
],
}).compileComponents();
const fixture = TestBed.createComponent(StoreHomePageComponent);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const section = element.querySelector('.store-home__additional-info');
const sections = element.querySelectorAll(':scope > app-store-section');
expect(sections[sections.length - 1]).toBe(section);
expect(section?.querySelector('.store-section__title')?.textContent?.trim()).toBe(
'Información adicional',
);
expect(section?.querySelector('strong')?.textContent).toBe('términos del evento');
expect(section?.querySelector('li')?.textContent).toBe('Ingreso con DNI');
});
it('does not render the additional information section without content', async () => {
await TestBed.configureTestingModule({
imports: [StoreHomePageComponent],
providers: [
provideActivatedRoute({ response: createCatalog(), error: null }),
{
provide: CatalogService,
useValue: { getCatalog: vi.fn(), getFeaturedGroupItems: vi.fn() },
},
{
provide: TenantService,
useValue: createTenantServiceStub(
createTenant({ additionalInfoConfig: { description: ' ' } }),
),
},
],
}).compileComponents();
const fixture = TestBed.createComponent(StoreHomePageComponent);
fixture.detectChanges();
expect(
(fixture.nativeElement as HTMLElement).querySelector('.store-home__additional-info'),
).toBeNull();
});
it('renders only the active event data when the tenant does not have the hero extra', async () => {
const tenant = createTenant();
tenant.event = {
title: 'Fiesta Fútbol Infantil',
location: 'Sunchales, Santa Fe',
dates: [
{
id: 25,
date: '2026-10-09',
time_start: '09:00:00',
time_end: '18:00:00',
},
],
};
await TestBed.configureTestingModule({
imports: [StoreHomePageComponent],
providers: [
provideActivatedRoute({ response: createCatalog(), error: null }),
{
provide: CatalogService,
useValue: { getCatalog: vi.fn(), getFeaturedGroupItems: vi.fn() },
},
{
provide: TenantService,
useValue: createTenantServiceStub(tenant),
},
],
}).compileComponents();
const fixture = TestBed.createComponent(StoreHomePageComponent);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const heroComponent = fixture.debugElement.query(By.directive(HeroBannerComponent))
.componentInstance as HeroBannerComponent;
expect(heroComponent.heroConfig).toBeNull();
expect(element.querySelector('.hero-banner')).toBeNull();
expect(element.querySelector('.hero-banner-container')).toBeNull();
expect(element.querySelector('.hero-content')).toBeNull();
expect(element.querySelector('.event-card')).not.toBeNull();
expect(element.textContent).toContain('Fiesta Fútbol Infantil');
expect(element.textContent).toContain('Sunchales, Santa Fe');
expect(element.textContent).toContain('2026-10-09');
}); });
it('requests another page for the selected featured group', async () => { it('requests another page for the selected featured group', async () => {
@@ -323,9 +486,9 @@ describe('StoreHomePageComponent', () => {
it('adds a product-list cart event to the cart', async () => { it('adds a product-list cart event to the cart', async () => {
const catalogServiceStub = { getCatalog: vi.fn(), getFeaturedGroupItems: vi.fn() }; const catalogServiceStub = { getCatalog: vi.fn(), getFeaturedGroupItems: vi.fn() };
const cartServiceStub = { const cartServiceStub = {
addItem: vi.fn().mockReturnValue( addItem: vi
of({ data: {}, message: 'Producto agregado correctamente' }), .fn()
), .mockReturnValue(of({ data: {}, message: 'Producto agregado correctamente' })),
}; };
const toastServiceStub = { success: vi.fn(), danger: vi.fn() }; const toastServiceStub = { success: vi.fn(), danger: vi.fn() };

View File

@@ -60,9 +60,35 @@ export class StoreHomePageComponent implements OnInit, OnDestroy {
protected readonly error = signal<string | null>(null); protected readonly error = signal<string | null>(null);
protected readonly mainCarouselReady = signal(false); protected readonly mainCarouselReady = signal(false);
protected readonly creatingDirectPurchase = signal(false); protected readonly creatingDirectPurchase = signal(false);
protected readonly hasMainCarouselImages = computed( protected readonly mainCarouselImages = computed(() => this.tenant()?.extras?.carousel ?? []);
() => (this.tenant()?.main_carousel_images?.length ?? 0) > 0, protected readonly heroConfig = computed(() => this.tenant()?.extras?.heroConfig ?? null);
protected readonly additionalInfo = computed(
() => this.tenant()?.extras?.additionalInfoConfig?.description?.trim() || null,
); );
protected readonly eventConfig = computed(() => {
const tenant = this.tenant();
const contact = tenant?.social_media ?? [];
const event = tenant?.event;
if (event) {
return {
id: tenant.id,
title: event.title,
location: event.location,
dates_text: tenant.event_date_text,
dates: event.dates.map((eventDate) => ({
id: eventDate.id,
date: eventDate.date,
start_time: eventDate.time_start,
end_time: eventDate.time_end,
})),
contact,
};
}
return null;
});
protected readonly hasMainCarouselImages = computed(() => this.mainCarouselImages().length > 0);
private catalogRequestSubscription: Subscription | null = null; private catalogRequestSubscription: Subscription | null = null;
private readonly groupRequestSubscriptions = new Map<number, Subscription>(); private readonly groupRequestSubscriptions = new Map<number, Subscription>();
@@ -109,20 +135,20 @@ export class StoreHomePageComponent implements OnInit, OnDestroy {
.withCustomLoading() .withCustomLoading()
.getFeaturedGroupItems(groupId, { page }) .getFeaturedGroupItems(groupId, { page })
.subscribe({ .subscribe({
next: (items) => { next: (items) => {
this.catalog.update((groups) => this.catalog.update((groups) =>
groups.map((candidate) => groups.map((candidate) =>
candidate.id === groupId ? { ...candidate, items } : candidate, candidate.id === groupId ? { ...candidate, items } : candidate,
), ),
); );
this.error.set(null); this.error.set(null);
}, },
error: () => { error: () => {
this.error.set(STORE_HOME_PRODUCTS_ERROR_MESSAGE); this.error.set(STORE_HOME_PRODUCTS_ERROR_MESSAGE);
this.setGroupLoading(groupId, false); this.setGroupLoading(groupId, false);
}, },
complete: () => this.setGroupLoading(groupId, false), complete: () => this.setGroupLoading(groupId, false),
}); });
this.groupRequestSubscriptions.set(groupId, subscription); this.groupRequestSubscriptions.set(groupId, subscription);
} }
@@ -189,14 +215,14 @@ export class StoreHomePageComponent implements OnInit, OnDestroy {
.withCustomLoading() .withCustomLoading()
.getCatalog() .getCatalog()
.subscribe({ .subscribe({
next: (catalog) => this.catalog.set(catalog), next: (catalog) => this.catalog.set(catalog),
error: () => { error: () => {
this.catalog.set([]); this.catalog.set([]);
this.loading.set(false); this.loading.set(false);
this.error.set(STORE_HOME_PRODUCTS_ERROR_MESSAGE); this.error.set(STORE_HOME_PRODUCTS_ERROR_MESSAGE);
}, },
complete: () => this.loading.set(false), complete: () => this.loading.set(false),
}); });
} }
private applyResolvedData(resolvedData: StoreHomeCatalogResolvedData): void { private applyResolvedData(resolvedData: StoreHomeCatalogResolvedData): void {

View File

@@ -116,9 +116,13 @@ export const routes: Routes = [
{ {
path: 'ayuda', path: 'ayuda',
canActivate: [hasMenuGuard('help')], canActivate: [hasMenuGuard('help')],
data: {
menuCode: 'help',
navigationLabel: 'Secciones de ayuda',
},
loadComponent: () => loadComponent: () =>
import('./pages/help-page/sidebar-layout/help-sidebar-layout').then( import('./components/menu-page-layout/menu-page-layout.component').then(
(m) => m.HelpSidebarLayout, (m) => m.MenuPageLayoutComponent,
), ),
children: [ children: [
{ {
@@ -152,8 +156,14 @@ export const routes: Routes = [
{ {
path: 'mi-cuenta', path: 'mi-cuenta',
canActivate: [authGuard, hasMenuGuard('account')], canActivate: [authGuard, hasMenuGuard('account')],
data: {
menuCode: 'account',
navigationLabel: 'Secciones de mi cuenta',
},
loadComponent: () => loadComponent: () =>
import('./pages/account-page/sidebar-layout/sidebar-layout').then((m) => m.SidebarLayout), import('./components/menu-page-layout/menu-page-layout.component').then(
(m) => m.MenuPageLayoutComponent,
),
children: [ children: [
{ {
path: 'datos-personales', path: 'datos-personales',

View File

@@ -1,8 +1,4 @@
:host { :host {
display: inline-block;
}
:host(.product-detail__cta) {
display: block; display: block;
width: 100%; width: 100%;
} }
@@ -11,10 +7,6 @@
min-height: 40px; min-height: 40px;
font-weight: 700; font-weight: 700;
font-size: 15px; font-size: 15px;
min-width: 216px;
}
:host(.product-detail__cta) .btn {
width: 100%; width: 100%;
} }

View File

@@ -1,11 +1,10 @@
<article <article class="w-100 p-3 rounded-0 cart-item" [class.cart-item-with-media]="imageUrl()">
class="w-100 p-3 rounded-0 cart-item"
[class.cart-item-with-media]="imageUrl()"
>
@if (imageUrl()) { @if (imageUrl()) {
<div class="position-relative overflow-hidden cart-item-media"> <div class="position-relative overflow-hidden cart-item-media">
@if (discountPercentage() && discountPercentage()! > 0) { @if (discountPercentage() && discountPercentage()! > 0) {
<span class="position-absolute top-0 start-0 z-1 rounded-0 cart-item-discount-badge">-{{ discountPercentage() }}%</span> <span class="position-absolute top-0 start-0 z-1 rounded-0 cart-item-discount-badge"
>-{{ discountPercentage() }}%</span
>
} }
<img class="w-100 h-100 object-fit-cover d-block" [src]="imageUrl()" [alt]="product()" /> <img class="w-100 h-100 object-fit-cover d-block" [src]="imageUrl()" [alt]="product()" />
@@ -18,21 +17,33 @@
<div class="text-nowrap cart-item-prices"> <div class="text-nowrap cart-item-prices">
@if (formattedOriginalPrice(); as originalPrice) { @if (formattedOriginalPrice(); as originalPrice) {
<span class="text-decoration-line-through mb-1 fw-light cart-item-original-price">{{ originalPrice }}</span> <span class="text-decoration-line-through mb-1 fw-light cart-item-original-price">{{
originalPrice
}}</span>
} }
<span class="fw-bold cart-item-discounted-price">{{ formattedDiscountedPrice() }}</span> <span class="fw-bold cart-item-discounted-price">{{ formattedDiscountedPrice() }}</span>
</div> </div>
</div> </div>
<div class="d-grid cart-item-attributes"> @if (hasVariantSelectors() && !quantityDisabled()) {
@for (attribute of attributes(); track attribute.label + attribute.value) { <app-variant-selector
<div class="cart-item-attribute"> class="cart-item-variant-selector"
<span class="fw-normal cart-item-attribute-label">{{ attribute.label }}: </span> [variants]="variants()"
<span class="fw-bold">{{ attribute.value }}</span> [selectedVariant]="selectedVariant()"
</div> [compact]="true"
} (selectedVariantChange)="onVariantChange($event)"
</div> />
} @else {
<div class="d-grid cart-item-attributes">
@for (attribute of attributes(); track attribute.label + attribute.value) {
<div class="cart-item-attribute">
<span class="fw-normal cart-item-attribute-label">{{ attribute.label }}: </span>
<span class="fw-bold">{{ attribute.value }}</span>
</div>
}
</div>
}
@if (!readonly()) { @if (!readonly()) {
<div class="d-flex justify-content-end align-items-center mt-auto cart-item-actions"> <div class="d-flex justify-content-end align-items-center mt-auto cart-item-actions">
@@ -45,15 +56,9 @@
(decrease)="onDecrease()" (decrease)="onDecrease()"
/> />
@if (!quantityDisabled() && showRemove()) { @if (!quantityDisabled() && showRemove()) {
<app-icon-button <app-icon-button variant="trash" class="cart-item-remove-btn" (click)="onRemove()" />
variant="trash"
class="cart-item-remove-btn"
(click)="onRemove()"
/>
} }
</div> </div>
} }
</div> </div>
</article> </article>

View File

@@ -11,7 +11,7 @@
:host(:first-child) .cart-item::before, :host(:first-child) .cart-item::before,
:host .cart-item::after { :host .cart-item::after {
content: ""; content: '';
position: absolute; position: absolute;
right: var(--item-divider-inset); right: var(--item-divider-inset);
left: var(--item-divider-inset); left: var(--item-divider-inset);
@@ -119,6 +119,11 @@
gap: 0.125rem; gap: 0.125rem;
} }
.cart-item-variant-selector {
display: block;
width: 100%;
}
.cart-item-remove-btn { .cart-item-remove-btn {
margin-left: 0.35rem; margin-left: 0.35rem;
} }

View File

@@ -1,6 +1,10 @@
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core'; import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
import { QuantitySelectorComponent } from '../quantity-selector/quantity-selector.component'; import { QuantitySelectorComponent } from '../quantity-selector/quantity-selector.component';
import { IconButtonComponent } from '../icon-button/icon-button.component'; import { IconButtonComponent } from '../icon-button/icon-button.component';
import {
VariantSelectorComponent,
VariantSelectorVariant,
} from '../variant-selector/variant-selector.component';
export interface CartItemAttribute { export interface CartItemAttribute {
label: string; label: string;
@@ -10,10 +14,10 @@ export interface CartItemAttribute {
@Component({ @Component({
selector: 'app-cart-item', selector: 'app-cart-item',
standalone: true, standalone: true,
imports: [QuantitySelectorComponent, IconButtonComponent], imports: [QuantitySelectorComponent, IconButtonComponent, VariantSelectorComponent],
templateUrl: './cart-item.component.html', templateUrl: './cart-item.component.html',
styleUrl: './cart-item.component.scss', styleUrl: './cart-item.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class CartItemComponent { export class CartItemComponent {
readonly imageUrl = input<string | null>(null); readonly imageUrl = input<string | null>(null);
@@ -22,6 +26,8 @@ export class CartItemComponent {
readonly discountedPrice = input<number>(0); readonly discountedPrice = input<number>(0);
readonly discountPercentage = input<number | null>(null); readonly discountPercentage = input<number | null>(null);
readonly attributes = input<CartItemAttribute[]>([]); readonly attributes = input<CartItemAttribute[]>([]);
readonly variants = input<VariantSelectorVariant[]>([]);
readonly selectedVariant = input<unknown>(null);
readonly quantity = input<number>(1); readonly quantity = input<number>(1);
readonly readonly = input<boolean>(false); readonly readonly = input<boolean>(false);
readonly quantityDisabled = input<boolean>(false); readonly quantityDisabled = input<boolean>(false);
@@ -31,6 +37,11 @@ export class CartItemComponent {
readonly remove = output<void>(); readonly remove = output<void>();
readonly increase = output<void>(); readonly increase = output<void>();
readonly decrease = output<void>(); readonly decrease = output<void>();
readonly variantChange = output<number>();
protected readonly hasVariantSelectors = computed(() =>
this.variants().some((variant) => Object.keys(variant.values).length > 0),
);
protected onQuantityChange(newQuantity: number): void { protected onQuantityChange(newQuantity: number): void {
if (this.quantityDisabled()) return; if (this.quantityDisabled()) return;
@@ -49,12 +60,20 @@ export class CartItemComponent {
this.decrease.emit(); this.decrease.emit();
} }
protected onVariantChange(variant: unknown): void {
if (!this.quantityDisabled() && typeof variant === 'number') {
this.variantChange.emit(variant);
}
}
protected readonly formattedOriginalPrice = computed(() => { protected readonly formattedOriginalPrice = computed(() => {
const price = this.originalPrice(); const price = this.originalPrice();
return price === null ? null : this.formatCurrency(price); 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 { private formatCurrency(value: number): string {
const rounded = Math.round(value); const rounded = Math.round(value);

View File

@@ -44,11 +44,14 @@
[discountedPrice]="item.discountedPrice" [discountedPrice]="item.discountedPrice"
[discountPercentage]="item.discountPercentage" [discountPercentage]="item.discountPercentage"
[attributes]="item.attributes" [attributes]="item.attributes"
[variants]="item.variants ?? []"
[selectedVariant]="getItemVariant(item)"
[quantity]="getItemQuantity(item)" [quantity]="getItemQuantity(item)"
[readonly]="readonly()" [readonly]="readonly()"
[quantityDisabled]="editingDisabled() || (allowEditing() && !editing())" [quantityDisabled]="editingDisabled() || (allowEditing() && !editing())"
[showRemove]="allowRemove()" [showRemove]="allowRemove()"
(quantityChange)="onItemQuantityChange(idx, $event)" (quantityChange)="onItemQuantityChange(idx, $event)"
(variantChange)="onItemVariantChange(idx, $event)"
(remove)="onItemRemove(idx)" (remove)="onItemRemove(idx)"
/> />
} @empty { } @empty {

View File

@@ -87,3 +87,13 @@
display: flex; display: flex;
gap: 12px; gap: 12px;
} }
@media (max-width: 767.98px) {
.cart-actions {
flex-direction: column;
::ng-deep app-button {
width: 100%;
}
}
}

View File

@@ -115,7 +115,7 @@ describe('CartComponent', () => {
expect(openConfirmDelete).toHaveBeenCalledWith({ expect(openConfirmDelete).toHaveBeenCalledWith({
title: 'Eliminar producto', 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', confirmLabel: 'Eliminar',
cancelLabel: 'Cancelar', cancelLabel: 'Cancelar',
}); });
@@ -407,4 +407,57 @@ describe('CartComponent', () => {
fixture.debugElement.query(By.css('app-cart-item')).componentInstance.quantityDisabled(), fixture.debugElement.query(By.css('app-cart-item')).componentInstance.quantityDisabled(),
).toBe(false); ).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);
});
}); });

View File

@@ -16,6 +16,7 @@ import { CartItemAttribute, CartItemComponent } from '../cart-item/cart-item.com
import { ModalService } from '../../../core/services/modal.service'; import { ModalService } from '../../../core/services/modal.service';
import { CartService } from '../../../core/services/cart/cart.service'; import { CartService } from '../../../core/services/cart/cart.service';
import { ToastService } from '../../../core/services/toast.service'; import { ToastService } from '../../../core/services/toast.service';
import { VariantSelectorVariant } from '../variant-selector/variant-selector.component';
export interface CartItemMock { export interface CartItemMock {
cartItemId?: number; cartItemId?: number;
@@ -26,6 +27,8 @@ export interface CartItemMock {
discountPercentage: number | null; discountPercentage: number | null;
attributes: CartItemAttribute[]; attributes: CartItemAttribute[];
quantity: number; quantity: number;
variantId?: number | null;
variants?: VariantSelectorVariant[];
} }
@Component({ @Component({
@@ -64,6 +67,7 @@ export class CartComponent {
}>(); }>();
protected readonly quantityOverrides = signal<Record<number, number>>({}); protected readonly quantityOverrides = signal<Record<number, number>>({});
protected readonly variantOverrides = signal<Record<number, number>>({});
constructor() { constructor() {
this.quantityUpdates$ this.quantityUpdates$
.pipe( .pipe(
@@ -104,6 +108,13 @@ export class CartComponent {
return item.quantity; 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 { private clearOverride(cartItemId: number): void {
this.quantityOverrides.update((overrides) => { this.quantityOverrides.update((overrides) => {
const copy = { ...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 { protected onItemRemove(index: number): void {
const target = this.resolveRemoveTarget(index); const target = this.resolveRemoveTarget(index);
@@ -163,7 +204,7 @@ export class CartComponent {
this.modalService this.modalService
.openConfirmDelete({ .openConfirmDelete({
title: 'Eliminar producto', 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', confirmLabel: 'Eliminar',
cancelLabel: 'Cancelar', cancelLabel: 'Cancelar',
}) })

View File

@@ -1,19 +1,25 @@
.confirm-modal { .confirm-modal {
display: grid; display: grid;
gap: 1.5rem; width: 100%;
justify-items: center; gap: 1.25rem;
text-align: center; text-align: center;
} }
.confirm-modal__content { .confirm-modal__content {
margin: 0; margin: 0;
color: #666666; color: #5f6368;
line-height: 1.5; line-height: 1.55;
} }
.confirm-modal__actions { .confirm-modal__actions {
display: flex; display: grid;
justify-content: center; grid-template-columns: repeat(2, minmax(0, 1fr));
width: 100%;
gap: 0.75rem; gap: 0.75rem;
flex-wrap: wrap; }
@media (max-width: 380px) {
.confirm-modal__actions {
grid-template-columns: 1fr;
}
} }

View File

@@ -1,11 +1,19 @@
<div class="hero-banner-container"> <div class="hero-banner-container">
<div <div class="hero-banner position-relative rounded">
class="hero-banner position-relative rounded" <div
[ngStyle]="{'background-image': heroConfig?.background_image ? 'url(' + heroConfig.background_image + ')' : 'none'}" class="hero-media"
> aria-hidden="true"
[ngStyle]="{
'background-image': heroConfig?.background_image_id
? 'url(' + heroConfig.background_image_id + ')'
: 'none',
}"
></div>
@if (heroConfig) { @if (heroConfig) {
<div class="hero-content d-flex justify-content-end p-4 p-md-5 w-100 h-100 align-items-center"> <div
class="hero-content d-flex justify-content-end p-4 p-md-5 w-100 h-100 align-items-center"
>
<div class="hero-text-box"> <div class="hero-text-box">
@if (heroConfig.title_html) { @if (heroConfig.title_html) {
<div class="hero-title" [innerHTML]="heroConfig.title_html"></div> <div class="hero-title" [innerHTML]="heroConfig.title_html"></div>
@@ -14,7 +22,10 @@
<div class="hero-description" [innerHTML]="heroConfig.description_html"></div> <div class="hero-description" [innerHTML]="heroConfig.description_html"></div>
} }
@if (heroConfig.button_text) { @if (heroConfig.button_text) {
<a [href]="heroConfig.button_href || '#'" class="text-decoration-none mt-3 d-inline-block"> <a
[href]="heroConfig.button_href || '#'"
class="hero-action text-decoration-none mt-3 d-inline-block"
>
<app-button variant="primary"> <app-button variant="primary">
{{ heroConfig.button_text }} {{ heroConfig.button_text }}
</app-button> </app-button>
@@ -23,30 +34,78 @@
</div> </div>
</div> </div>
} }
@if (eventConfig) {
<div class="event-card-container position-absolute w-100 d-flex justify-content-center">
<div class="event-card bg-white shadow rounded p-3 p-md-4 text-center">
@if (eventConfig.title) {
<h3 class="event-title text-primary fw-bold mb-3">{{ eventConfig.title }}</h3>
}
<div class="event-details d-flex flex-column flex-md-row justify-content-center align-items-center gap-3 gap-md-5 text-muted">
@if (eventConfig.dates && eventConfig.dates.length > 0) {
<div class="d-flex align-items-center gap-2">
<i class="fa-regular fa-calendar event-icon"></i>
<span class="event-info-text">{{ formattedDates }}</span>
</div>
}
@if (eventConfig.location) {
<div class="d-flex align-items-center gap-2">
<i class="fa-solid fa-location-dot event-icon"></i>
<span class="event-info-text">{{ eventConfig.location }}</span>
</div>
}
</div>
</div>
</div>
}
</div> </div>
@if (eventConfig) {
<div class="event-card-container w-100 d-flex justify-content-center">
<div id="event-details" class="event-card bg-white shadow rounded p-3 p-md-4 text-center">
@if (eventConfig.title) {
<h3 class="event-title text-primary fw-bold mb-3">{{ eventConfig.title }}</h3>
}
<div
class="event-details d-flex flex-column flex-md-row justify-content-center align-items-center gap-3 gap-md-5 text-muted"
>
@if (eventConfig.dates && eventConfig.dates.length > 0) {
<div class="d-flex align-items-center gap-2">
<i class="fa-regular fa-calendar event-icon"></i>
<span class="event-info-text">{{ formattedDates }}</span>
</div>
}
@if (eventConfig.location) {
<div class="d-flex align-items-center gap-2">
<i class="fa-solid fa-location-dot event-icon"></i>
<span class="event-info-text">{{ eventConfig.location }}</span>
</div>
}
</div>
@if (eventConfig.dates && eventConfig.dates.length > 0) {
<button
type="button"
class="event-schedule-toggle"
[attr.aria-expanded]="schedulesExpanded"
aria-controls="event-schedules"
(click)="toggleSchedules()"
>
<i
class="fa-solid"
[class.fa-chevron-down]="!schedulesExpanded"
[class.fa-chevron-up]="schedulesExpanded"
aria-hidden="true"
></i>
<span>{{ schedulesExpanded ? 'Ocultar horarios' : 'Ver horarios' }}</span>
</button>
@if (schedulesExpanded) {
<div id="event-schedules" class="event-schedules">
<table class="table table-striped table-borderless mb-0">
<tbody>
@for (eventDate of eventConfig.dates; track eventDate.id ?? eventDate.date) {
<tr>
<td>
<span class="event-schedule-value">
<i class="fa-regular fa-calendar" aria-hidden="true"></i>
<span>{{ formatScheduleDate(eventDate.date) }}</span>
</span>
</td>
<td>
<span class="event-schedule-value">
<i class="fa-regular fa-clock" aria-hidden="true"></i>
<span>
{{ formatScheduleTime(eventDate.start_time) }} -
{{ formatScheduleTime(eventDate.end_time) }}
</span>
</span>
</td>
</tr>
}
</tbody>
</table>
</div>
}
}
</div>
</div>
}
</div> </div>

View File

@@ -1,26 +1,33 @@
.hero-banner-container { .hero-banner-container {
padding-bottom: 4rem; /* Spacing to accommodate the overlapping card */
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.hero-banner { .hero-banner {
background-color: #e9ecef; background-color: #e9ecef;
background-size: cover;
background-position: center;
min-height: 400px; min-height: 400px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
}
&::before { .hero-media {
content: ""; position: absolute;
inset: 0;
background-position: center;
background-repeat: no-repeat;
background-size: cover;
border-radius: inherit;
&::after {
content: '';
position: absolute; position: absolute;
top: 0; inset: 0;
left: 0; background: linear-gradient(
right: 0; to right,
bottom: 0; rgba(255, 255, 255, 0) 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%); rgba(255, 255, 255, 0.7) 50%,
rgba(255, 255, 255, 0.95) 100%
);
border-radius: inherit; border-radius: inherit;
z-index: 1;
} }
} }
@@ -36,8 +43,8 @@
flex-grow: 1; flex-grow: 1;
} }
::ng-deep .hero-title,
::ng-deep .hero-title, .hero-title { .hero-title {
color: #666666; color: #666666;
font-size: 1.5rem; font-size: 1.5rem;
margin-bottom: 1rem; margin-bottom: 1rem;
@@ -46,7 +53,8 @@
} }
} }
::ng-deep .hero-description, .hero-description { ::ng-deep .hero-description,
.hero-description {
color: #666666; color: #666666;
font-size: 1.1rem; font-size: 1.1rem;
line-height: 1.5; line-height: 1.5;
@@ -56,8 +64,8 @@
} }
.event-card-container { .event-card-container {
bottom: -40px; /* Negative margin to pull it down and overlap */ position: relative;
left: 0; margin-top: -40px;
z-index: 10; z-index: 10;
} }
@@ -67,12 +75,14 @@
} }
.event-title { .event-title {
font-size: 1.4rem; font-size: 25px;
font-weight: 700;
letter-spacing: 0.5px; letter-spacing: 0.5px;
text-transform: uppercase;
} }
.event-details { .event-details {
font-size: 1.1rem; font-size: 21px;
} }
.event-icon { .event-icon {
@@ -82,30 +92,194 @@
.event-info-text { .event-info-text {
color: #8a8a8a; color: #8a8a8a;
font-weight: 500; font-weight: 400;
} }
@media (max-width: 768px) { .event-schedule-toggle {
.hero-banner::before { display: inline-flex;
background: none; 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) {
.hero-banner-container {
padding-bottom: 0;
margin-bottom: 0;
}
.hero-banner {
width: 100vw;
min-height: 0;
margin-inline: calc(50% - 50vw);
overflow: hidden;
background-color: #f5f5f5;
border-radius: 0 !important;
}
.hero-media {
position: relative;
inset: auto;
flex: 0 0 auto;
width: 100%;
height: clamp(8.75rem, 44vw, 211px);
background-position: center center;
background-size: 140% auto;
border-radius: 0;
}
.hero-media::after {
top: auto;
height: 48%;
background: linear-gradient(
to bottom,
rgba(245, 245, 245, 0) 0%,
rgba(245, 245, 245, 0.78) 55%,
#f5f5f5 100%
);
border-radius: 0;
} }
.hero-content { .hero-content {
justify-content: center !important; justify-content: center !important;
background: none; height: auto !important;
min-height: 8.25rem;
margin-top: -1.7rem;
padding: 0 1.25rem 1.75rem !important;
align-items: flex-start !important;
} }
.hero-text-box { .hero-text-box {
width: 100%;
max-width: 19rem;
padding: 0;
text-align: center; text-align: center;
background: rgba(255, 255, 255, 0.8); background: transparent;
border-radius: 8px; border-radius: 0;
} }
::ng-deep .hero-title,
.hero-title {
margin-bottom: 0.4rem;
font-size: 0.875rem;
line-height: 1.2;
}
::ng-deep .hero-title p,
::ng-deep .hero-description p {
margin-bottom: 0;
}
::ng-deep .hero-description,
.hero-description {
font-size: 0.75rem;
line-height: 1.25;
}
.hero-action {
margin-top: 1rem !important;
}
.hero-action ::ng-deep .btn {
font-size: 11px;
}
.event-card-container { .event-card-container {
bottom: -80px; margin-top: 0;
padding: 0 0 1.2rem;
} }
.hero-banner-container { .event-card {
padding-bottom: 6rem; width: 100%;
max-width: none;
min-height: 11.4rem;
padding: 2.5rem 1.5rem !important;
border-radius: 0 !important;
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.04) !important;
}
.event-title {
margin: 0 auto 0.85rem !important;
font-size: 22px;
line-height: 1.1;
letter-spacing: 0;
}
.event-details {
gap: 0.45rem !important;
font-size: 15px;
line-height: 1.25;
}
.event-details > div {
gap: 0.5rem !important;
}
.event-icon {
width: 0.9rem;
font-size: 0.85rem;
}
.event-schedule-toggle {
margin-top: 1.9rem;
font-size: 13px;
}
.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;
} }
} }

View File

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

View File

@@ -1,7 +1,7 @@
import { Component, ChangeDetectionStrategy, Input } from '@angular/core'; import { Component, ChangeDetectionStrategy, Input } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router'; 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'; import { ButtonComponent } from '../button/button.component';
@Component({ @Component({
@@ -16,26 +16,41 @@ export class HeroBannerComponent {
@Input() heroConfig?: HeroConfig | null; @Input() heroConfig?: HeroConfig | null;
@Input() eventConfig?: EventConfig | null; @Input() eventConfig?: EventConfig | null;
protected schedulesExpanded = false;
get formattedDates(): string { get formattedDates(): string {
if (this.eventConfig?.dates_text) {
return this.eventConfig.dates_text;
}
if (!this.eventConfig?.dates || this.eventConfig.dates.length === 0) { if (!this.eventConfig?.dates || this.eventConfig.dates.length === 0) {
return ''; return '';
} }
// Si ya viene formateado o es un string libre largo, lo devolvemos return this.eventConfig.dates.map(({ date }) => date).join(', ');
if (this.eventConfig.dates.length === 1 && this.eventConfig.dates[0].length > 10) { }
return this.eventConfig.dates[0];
} protected toggleSchedules(): void {
this.schedulesExpanded = !this.schedulesExpanded;
// 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 protected formatScheduleDate(date: string): string {
const hasIsoDates = this.eventConfig.dates.some(d => d.includes('-')); const [year, month, day] = date.split('-').map(Number);
if (hasIsoDates) { if (!year || !month || !day) {
// Un simple join por si acaso, aunque lo ideal es que el admin ponga el texto tal cual return date;
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.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;
} }
} }

View File

@@ -69,7 +69,8 @@
color: var(--tenant-danger); color: var(--tenant-danger);
} }
.icon-btn--user { .icon-btn--user,
.icon-btn--bars {
i { i {
width: 23px; width: 23px;
height: 23px; height: 23px;

View File

@@ -10,7 +10,8 @@ export type IconButtonVariant =
| 'angle-down' | 'angle-down'
| 'copy' | 'copy'
| 'download' | 'download'
| 'share'; | 'share'
| 'bars';
@Component({ @Component({
selector: 'app-icon-button', selector: 'app-icon-button',
@@ -38,6 +39,7 @@ export class IconButtonComponent {
copy: 'fa-solid fa-copy', copy: 'fa-solid fa-copy',
download: 'fa-solid fa-download', download: 'fa-solid fa-download',
share: 'fa-solid fa-share-nodes', share: 'fa-solid fa-share-nodes',
bars: 'fa-solid fa-bars',
}; };
return classes[this.variant()]; return classes[this.variant()];
}); });

View File

@@ -3,25 +3,10 @@ import { Component, inject } from '@angular/core';
import { DOCUMENT } from '@angular/common'; import { DOCUMENT } from '@angular/common';
import { TestBed, getTestBed } from '@angular/core/testing'; import { TestBed, getTestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser'; import { By } from '@angular/platform-browser';
import { import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
BrowserTestingModule, import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
platformBrowserTesting
} from '@angular/platform-browser/testing';
import {
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
import { import { MODAL_DATA, ModalRef, ModalService } from '../../../core/services/modal.service';
MODAL_DATA,
ModalRef,
ModalService
} from '../../../core/services/modal.service';
import { ModalHostComponent } from './modal-host.component'; import { ModalHostComponent } from './modal-host.component';
@Component({ @Component({
@@ -31,7 +16,7 @@ import { ModalHostComponent } from './modal-host.component';
<span class="modal-test-title">{{ data?.title }}</span> <span class="modal-test-title">{{ data?.title }}</span>
<button type="button" class="modal-test-close" (click)="close()">Cerrar</button> <button type="button" class="modal-test-close" (click)="close()">Cerrar</button>
</div> </div>
` `,
}) })
class ModalContentTestComponent { class ModalContentTestComponent {
readonly data = inject<{ title: string } | null>(MODAL_DATA); readonly data = inject<{ title: string } | null>(MODAL_DATA);
@@ -48,10 +33,7 @@ describe('ModalHostComponent', () => {
beforeAll(() => { beforeAll(() => {
try { try {
getTestBed().initTestEnvironment( getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
BrowserTestingModule,
platformBrowserTesting()
);
} catch { } catch {
// Test environment may already be initialized by another setup entrypoint. // Test environment may already be initialized by another setup entrypoint.
} }
@@ -59,7 +41,7 @@ describe('ModalHostComponent', () => {
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [ModalHostComponent] imports: [ModalHostComponent],
}).compileComponents(); }).compileComponents();
service = TestBed.inject(ModalService); service = TestBed.inject(ModalService);
@@ -68,6 +50,7 @@ describe('ModalHostComponent', () => {
afterEach(() => { afterEach(() => {
doc.body.style.overflow = ''; doc.body.style.overflow = '';
doc.body.style.paddingRight = '';
TestBed.resetTestingModule(); TestBed.resetTestingModule();
}); });
@@ -84,7 +67,7 @@ describe('ModalHostComponent', () => {
service.open(ModalContentTestComponent, { service.open(ModalContentTestComponent, {
title: 'Editar producto', title: 'Editar producto',
data: { title: 'Contenido del modal' } data: { title: 'Contenido del modal' },
}); });
fixture.detectChanges(); fixture.detectChanges();
@@ -99,27 +82,30 @@ describe('ModalHostComponent', () => {
it('closes with a result from the child component', () => { it('closes with a result from the child component', () => {
const fixture = TestBed.createComponent(ModalHostComponent); const fixture = TestBed.createComponent(ModalHostComponent);
const ref = service.open(ModalContentTestComponent, { const ref = service.open(ModalContentTestComponent, {
data: { title: 'Cerrar' } data: { title: 'Cerrar' },
}); });
const closedSpy = vi.fn(); const closedSpy = vi.fn();
ref.afterClosed$.subscribe(closedSpy); ref.afterClosed$.subscribe(closedSpy);
fixture.detectChanges(); fixture.detectChanges();
const closeButton = fixture.nativeElement.querySelector('.modal-test-close') as HTMLButtonElement; const closeButton = fixture.nativeElement.querySelector(
'.modal-test-close',
) as HTMLButtonElement;
closeButton.click(); closeButton.click();
fixture.detectChanges(); fixture.detectChanges();
expect(closedSpy).toHaveBeenCalledWith('accepted'); expect(closedSpy).toHaveBeenCalledWith('accepted');
expect(service.activeModal()).toBeNull(); expect(service.activeModal()).toBeNull();
expect(doc.body.style.overflow).toBe(''); expect(doc.body.style.overflow).toBe('');
expect(doc.body.style.paddingRight).toBe('');
}); });
it('closes on backdrop click when enabled', () => { it('closes on backdrop click when enabled', () => {
const fixture = TestBed.createComponent(ModalHostComponent); const fixture = TestBed.createComponent(ModalHostComponent);
const ref = service.open(ModalContentTestComponent, { const ref = service.open(ModalContentTestComponent, {
data: { title: 'Backdrop' }, data: { title: 'Backdrop' },
closeOnBackdrop: true closeOnBackdrop: true,
}); });
fixture.detectChanges(); fixture.detectChanges();
@@ -136,7 +122,7 @@ describe('ModalHostComponent', () => {
const fixture = TestBed.createComponent(ModalHostComponent); const fixture = TestBed.createComponent(ModalHostComponent);
const ref = service.open(ModalContentTestComponent, { const ref = service.open(ModalContentTestComponent, {
data: { title: 'Persistente' }, data: { title: 'Persistente' },
closeOnBackdrop: false closeOnBackdrop: false,
}); });
fixture.detectChanges(); fixture.detectChanges();
@@ -152,7 +138,7 @@ describe('ModalHostComponent', () => {
const fixture = TestBed.createComponent(ModalHostComponent); const fixture = TestBed.createComponent(ModalHostComponent);
const ref = service.open(ModalContentTestComponent, { const ref = service.open(ModalContentTestComponent, {
data: { title: 'Escape' }, data: { title: 'Escape' },
closeOnEscape: true closeOnEscape: true,
}); });
fixture.detectChanges(); fixture.detectChanges();
@@ -167,7 +153,7 @@ describe('ModalHostComponent', () => {
const fixture = TestBed.createComponent(ModalHostComponent); const fixture = TestBed.createComponent(ModalHostComponent);
const ref = service.open(ModalContentTestComponent, { const ref = service.open(ModalContentTestComponent, {
data: { title: 'No Escape' }, data: { title: 'No Escape' },
closeOnEscape: false closeOnEscape: false,
}); });
fixture.detectChanges(); fixture.detectChanges();
@@ -181,12 +167,13 @@ describe('ModalHostComponent', () => {
const fixture = TestBed.createComponent(ModalHostComponent); const fixture = TestBed.createComponent(ModalHostComponent);
const ref = service.open(ModalContentTestComponent, { const ref = service.open(ModalContentTestComponent, {
title: 'Con cierre', title: 'Con cierre',
data: { title: 'Boton' } data: { title: 'Boton' },
}); });
fixture.detectChanges(); 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(); closeButton.click();
fixture.detectChanges(); fixture.detectChanges();

View File

@@ -7,21 +7,17 @@ import {
computed, computed,
effect, effect,
inject, inject,
viewChild viewChild,
} from '@angular/core'; } from '@angular/core';
import { import { MODAL_DATA, ModalRef, ModalService } from '../../../core/services/modal.service';
MODAL_DATA,
ModalRef,
ModalService
} from '../../../core/services/modal.service';
import { ModalShellComponent } from '../modal-shell/modal-shell.component'; import { ModalShellComponent } from '../modal-shell/modal-shell.component';
@Component({ @Component({
selector: 'app-modal-host', selector: 'app-modal-host',
imports: [NgComponentOutlet, ModalShellComponent], imports: [NgComponentOutlet, ModalShellComponent],
templateUrl: './modal-host.component.html', templateUrl: './modal-host.component.html',
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class ModalHostComponent { export class ModalHostComponent {
private readonly modalService = inject(ModalService); private readonly modalService = inject(ModalService);
@@ -42,9 +38,9 @@ export class ModalHostComponent {
return Injector.create({ return Injector.create({
providers: [ providers: [
{ provide: ModalRef, useValue: modal.ref }, { 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 body = this.document.body;
const previousOverflow = body.style.overflow; 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'; body.style.overflow = 'hidden';
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
@@ -75,6 +83,7 @@ export class ModalHostComponent {
onCleanup(() => { onCleanup(() => {
this.document.removeEventListener('keydown', onKeyDown); this.document.removeEventListener('keydown', onKeyDown);
body.style.overflow = previousOverflow; body.style.overflow = previousOverflow;
body.style.paddingRight = previousPaddingRight;
}); });
}); });
} }

View File

@@ -10,8 +10,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 1rem; padding: 1rem;
background: rgba(16, 18, 22, 0.48); background: rgba(16, 18, 22, 0.56);
backdrop-filter: blur(2px);
} }
.modal-shell__dialog { .modal-shell__dialog {

View File

@@ -75,3 +75,9 @@
} }
} }
} }
@media (max-width: 767.98px) {
.product-column-with-image {
border-color: var(--color-primary, var(--tenant-primary, #009933));
}
}

View File

@@ -22,6 +22,7 @@
[title]="item.nombre" [title]="item.nombre"
[description]="item.descripcion ?? ''" [description]="item.descripcion ?? ''"
[price]="price(item)" [price]="price(item)"
[variants]="item.variants ?? []"
(buy)="emitColumnBuy(item, $event)" (buy)="emitColumnBuy(item, $event)"
(addToCart)="emitColumnCart(item, $event)" (addToCart)="emitColumnCart(item, $event)"
/> />

Some files were not shown because too many files have changed in this diff Show More