feat(modal): enhance modal behavior and styling

- Adjust modal padding to account for scrollbar width.
- Update modal background opacity for better visibility.

style(product-list): refine layout and spacing
- Remove gap in product list rows for a cleaner look.

feat(product-list): improve variant handling
- Update variant mapping to include description and price.

refactor(product-row-card): restructure HTML for better layout
- Simplify structure by combining price and quantity controls.
- Enhance button placement for better UX.

style(product-row-card): improve card styling
- Adjust flex properties for better alignment and spacing.
- Update button styles for consistency.

feat(product-vertical-with-cart-card): enhance variant selection
- Implement variant selector for better user interaction.
- Update price and description display based on selected variant.

test(product-vertical-with-cart-card): add tests for variant selection
- Ensure correct variant description and price are displayed.

fix(environment): update API URL for testing environment
- Change backend URL to point to the QA server.

style(global): update global styles for better readability
- Adjust table styles for improved visibility and aesthetics.
This commit is contained in:
2026-08-11 16:59:01 -03:00
61 changed files with 2013 additions and 729 deletions

View File

@@ -7,21 +7,21 @@
}
.store-layout__brand-slot {
width: min(100%, 12rem);
width: min(100%, 230px);
min-width: 10rem;
}
.store-layout__brand-logo-shell {
position: relative;
width: min(100%, 12rem);
height: 2.75rem;
width: min(100%, 230px);
height: 65px;
}
.store-layout__brand-logo {
display: block;
width: 100%;
height: 100%;
max-height: 2.75rem;
max-height: 65px;
object-fit: contain;
object-position: left center;
}
@@ -67,7 +67,7 @@
.store-layout__footer-link {
width: fit-content;
color: rgba(255, 255, 255, 0.72);
color: #ffffff;
text-decoration: none;
&:hover,
@@ -98,6 +98,7 @@
@media (max-width: 767.98px) {
.store-layout__footer-content {
padding: 1.45rem 2.5rem 1.8rem !important;
font-size: 13px;
}
.store-layout__footer-grid {
@@ -117,7 +118,6 @@
order: 1;
width: auto;
padding-bottom: 1.65rem;
font-size: 0.72rem;
line-height: 1.25;
&:nth-of-type(even) {
@@ -145,21 +145,21 @@
}
.store-layout__brand-slot {
width: min(100%, 7.75rem);
width: min(100%, 150px);
min-width: 0;
}
.store-layout__brand-logo {
max-height: 2.5rem;
max-height: 55px;
}
.store-layout__brand-logo-shell {
width: min(100%, 7.75rem);
height: 2.5rem;
width: min(100%, 150px);
height: 55px;
}
.store-layout__brand-text {
font-size: 1.45rem;
font-size: 13px;
}
.store-layout__social-column {
@@ -183,11 +183,10 @@
.store-layout__contact-details {
justify-items: center;
gap: 0.4rem !important;
font-size: 0.7rem;
}
.store-layout__copyright {
font-size: 0.6rem;
font-size: 11px;
line-height: 1.3;
}
}

View File

@@ -19,46 +19,50 @@
</a>
</div>
<div class="col-12 px-0 d-md-none order-3">
<hr class="store-layout__mobile-separator">
</div>
<div class="col-12 col-md order-4 order-md-2">
<div class="store-layout__search-wrapper ms-md-auto">
<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>
}
@if (displaySeachBar()) {
<div class="col-12 px-0 d-md-none order-3">
<hr class="store-layout__mobile-separator" />
</div>
</div>
<div 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">
<div class="col-12 col-md order-4 order-md-2">
<div class="store-layout__search-wrapper ms-md-auto">
<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>
}
<div
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"
>
@if (ticketsMenu(); as menu) {
<app-button
variant="primary"
@@ -108,24 +112,29 @@
/>
@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"
<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>
<hr class="my-1 border-secondary opacity-25">
@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>
@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>
}
@@ -135,28 +144,30 @@
</div>
</div>
<div class="store-layout__categories d-none d-md-block">
<div class="container-xl px-3 px-md-4">
<div class="store-layout__category-menu">
<button
type="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"
aria-label="Categorias"
aria-controls="store-category-dropdown"
[attr.aria-expanded]="isCategoryDropdownOpen()"
(click)="toggleCategoryDropdown()"
>
<span>Categorias</span>
<i class="fa-solid fa-chevron-down" aria-hidden="true"></i>
</button>
@if (displayCategories()) {
<div class="store-layout__categories d-none d-md-block">
<div class="container-xl px-3 px-md-4">
<div class="store-layout__category-menu">
<button
type="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"
aria-label="Categorias"
aria-controls="store-category-dropdown"
[attr.aria-expanded]="isCategoryDropdownOpen()"
(click)="toggleCategoryDropdown()"
>
<span>Categorias</span>
<i class="fa-solid fa-chevron-down" aria-hidden="true"></i>
</button>
<app-category-dropdown
id="store-category-dropdown"
[categories]="categories()"
[open]="isCategoryDropdownOpen()"
(categorySelect)="onCategorySelect($event)"
/>
<app-category-dropdown
id="store-category-dropdown"
[categories]="categories()"
[open]="isCategoryDropdownOpen()"
(categorySelect)="onCategorySelect($event)"
/>
</div>
</div>
</div>
</div>
}
</header>

View File

@@ -1,6 +1,6 @@
.store-layout__header {
color: #ffffff;
background-color: var(--tenant-header-bg);
background-color: transparent;
box-shadow: inset 0 -1px 0 rgba(255, 255, 255, 0.08);
}
@@ -158,8 +158,21 @@
max-width: 150px;
}
.store-layout__actions-column {
flex: 1 1 auto;
width: auto;
min-width: 0;
}
::ng-deep .store-layout__tickets-btn-host {
flex: 1 1 0;
width: auto;
min-width: 0;
max-width: 50vw;
}
.store-layout__mobile-separator {
border-color: #DDDDDD;
border-color: #dddddd;
opacity: 1;
margin: 0 -1rem;
}
@@ -180,8 +193,9 @@
}
.store-layout__actions-column {
flex: 0 0 auto;
flex: 1 1 auto;
width: auto;
min-width: 0;
margin-left: 0 !important;
padding-left: 0.25rem;
gap: 0.25rem !important;

View File

@@ -35,6 +35,8 @@ export class StoreHeaderComponent {
readonly accountNavigationMenus = input<readonly Menu[]>([]);
readonly ticketsMenu = input<Menu | null>(null);
readonly categories = input<readonly Category[]>([]);
readonly displayCategories = input(true);
readonly displaySeachBar = input(true);
readonly cartClick = output<void>();
readonly ticketsClick = output<void>();
readonly loginClick = output<void>();

View File

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

View File

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

View File

@@ -204,6 +204,29 @@ describe('StoreLayoutComponent', () => {
expect(compiled.querySelector('app-store-footer .store-layout__footer')).not.toBeNull();
});
it('hides the configured header elements when the tenant disables them', () => {
tenantState.set({
...tenant,
categories: [{ id: 1, nombre: 'Remeras', subcategories: [] }],
display_categories: false,
display_seach_bar: false,
});
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('#store-search-input')).toBeNull();
expect(compiled.querySelector('.store-layout__category-trigger')).toBeNull();
const header = fixture.debugElement.query(By.directive(StoreHeaderComponent));
(header.componentInstance as any).toggleMobileMenu();
fixture.detectChanges();
expect(compiled.querySelectorAll('.store-layout__mobile-dropdown-item')).toHaveLength(1);
});
it('renders the tenant branding and action icons', () => {
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();

View File

@@ -6,7 +6,7 @@ import { StoreFooterComponent, StoreFooterSection } from './store-footer/store-f
import { StoreHeaderComponent } from './store-header/store-header.component';
import { CartComponent, CartItemMock } from '../../../shared/components/cart/cart.component';
import { ButtonComponent } from '../../../shared/components/button/button.component';
import { CartItem } from '../../services/cart/cart.interface';
import { CartItem, CartItemVariantValue } from '../../services/cart/cart.interface';
import { AuthService } from '../../services/auth/auth.service';
import { findMenu } from '../../services/menu.utils';
import { CheckoutService } from '../../services/checkout.service';
@@ -69,6 +69,21 @@ export class StoreLayoutComponent implements OnInit {
});
}
const variants = (item.product?.variants ?? []).map((variant) => ({
value: variant.id,
values: variant.values,
}));
const selectedVariant = item.product?.variants?.find(
(variant) => variant.id === item.variant_id,
);
if (selectedVariant) {
attributes = Object.entries(selectedVariant.values).map(([label, value]) => ({
label: this.formatAttributeLabel(label),
value: this.formatAttributeValue(value),
}));
}
return {
cartItemId: item.id,
imageUrl: item.product?.imagen ?? null,
@@ -78,9 +93,21 @@ export class StoreLayoutComponent implements OnInit {
discountPercentage: null,
attributes,
quantity: item.cantidad,
variantId: item.variant_id,
variants,
};
}
private formatAttributeLabel(value: string): string {
const label = value.replace(/[_-]+/g, ' ');
return label.charAt(0).toUpperCase() + label.slice(1);
}
private formatAttributeValue(value: CartItemVariantValue): string {
const values = Array.isArray(value) ? value : [value];
return values.map((item) => (typeof item === 'string' ? item : item.label)).join(', ');
}
protected readonly currentYear = new Date().getFullYear();
protected readonly tenant = this.tenantService.tenant;
protected readonly user = this.authService.user;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -166,7 +166,10 @@ export class ModalService {
}
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> {

View File

@@ -9,18 +9,62 @@ export interface BankAccount {
cvu: string;
}
export interface WebsiteType {
codigo: string;
nombre: string;
primary_color: string | null;
secondary_color: string | null;
danger_color: string | null;
success_color: string | null;
site_logo: string | null;
}
export interface HeroConfig {
background_image?: string | null;
background_image_id?: string | null;
title_html?: string | null;
description_html?: string | null;
button_text?: string | null;
button_href?: string | null;
}
export interface AdditionalInfoConfig {
description?: string | null;
}
export interface EventDate {
id?: number;
date: string;
start_time: string;
end_time: string;
}
export interface EventConfig {
id?: number;
title?: string | null;
location?: string | null;
dates?: string[] | null;
dates_text?: string | null;
dates?: EventDate[] | null;
contact?: SocialMedia[];
}
export interface ActiveEventDate {
id: number;
date: string;
time_start: string;
time_end: string;
}
export interface TenantEvent {
title: string;
location: string;
dates: ActiveEventDate[];
}
export interface WebsiteExtras {
carousel?: string[];
heroConfig?: HeroConfig | null;
additionalInfoConfig?: AdditionalInfoConfig | null;
[extraName: string]: unknown;
}
export interface Menu {
@@ -60,14 +104,18 @@ export interface Tenant {
footer_bg_color: string;
header_logo: string;
footer_logo: string;
website_type_code?: string | null;
website_type?: WebsiteType | null;
event_date_text?: string | null;
extras?: WebsiteExtras;
event?: TenantEvent | null;
selected_bank_account_id?: number | null;
selected_bank_account?: BankAccount | null;
hero_config?: HeroConfig | null;
event_config?: EventConfig | null;
search_product_layout?: 'row' | 'column_with_image' | 'column_with_cart';
search_group_layout?: 'paginated' | 'simple' | 'simple_vertical' | 'carousel';
search_items_per_page?: number;
main_carousel_images?: string[];
display_categories?: boolean;
display_seach_bar?: boolean;
social_media?: SocialMedia[];
menues?: Menu[];
categories: Category[];

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;