Compare commits

...

4 Commits

18 changed files with 288 additions and 188 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,8 +19,9 @@
</a>
</div>
@if (displaySeachBar()) {
<div class="col-12 px-0 d-md-none order-3">
<hr class="store-layout__mobile-separator">
<hr class="store-layout__mobile-separator" />
</div>
<div class="col-12 col-md order-4 order-md-2">
@@ -57,8 +58,11 @@
}
</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="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,7 +112,10 @@
/>
@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">
<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()"
@@ -117,7 +124,8 @@
<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
@@ -127,6 +135,7 @@
<span class="fw-medium">{{ category.nombre }}</span>
</a>
}
}
</div>
}
</div>
@@ -135,6 +144,7 @@
</div>
</div>
@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">
@@ -159,4 +169,5 @@
</div>
</div>
</div>
}
</header>

View File

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

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

@@ -114,6 +114,8 @@ export interface Tenant {
search_product_layout?: 'row' | 'column_with_image' | 'column_with_cart';
search_group_layout?: 'paginated' | 'simple' | 'simple_vertical' | 'carousel';
search_items_per_page?: number;
display_categories?: boolean;
display_seach_bar?: boolean;
social_media?: SocialMedia[];
menues?: Menu[];
categories: Category[];

View File

@@ -30,6 +30,13 @@ app-store-section + app-store-section {
margin-bottom: 0;
}
@media (max-width: 767.98px) {
:host > app-hero-banner:first-child {
display: block;
margin-top: -3rem;
}
}
.store-home__alert-error {
margin: 0;
border-color: rgba(var(--tenant-danger-rgb, 220, 53, 69), 0.18);

View File

@@ -120,8 +120,8 @@
}
.cart-item-variant-selector {
display: flex;
justify-content: flex-end;
display: block;
width: 100%;
}
.cart-item-remove-btn {

View File

@@ -78,6 +78,7 @@
font-size: 25px;
font-weight: 700;
letter-spacing: 0.5px;
text-transform: uppercase;
}
.event-details {
@@ -149,9 +150,12 @@
}
.hero-banner {
width: 100vw;
min-height: 0;
margin-inline: calc(50% - 50vw);
overflow: hidden;
background-color: #f5f5f5;
border-radius: 0 !important;
}
.hero-media {
@@ -159,7 +163,7 @@
inset: auto;
flex: 0 0 auto;
width: 100%;
height: clamp(8.75rem, 44vw, 10.5rem);
height: clamp(8.75rem, 44vw, 211px);
background-position: center center;
background-size: 140% auto;
border-radius: 0;
@@ -218,35 +222,33 @@
}
.hero-action ::ng-deep .btn {
min-height: 1.875rem;
padding: 0.4rem 1.35rem;
border-radius: 0.25rem;
font-size: 0.625rem;
font-size: 11px;
}
.event-card-container {
margin-top: 0;
padding: 0 1.25rem 1.2rem;
padding: 0 0 1.2rem;
}
.event-card {
width: 100%;
max-width: 22rem;
max-width: none;
min-height: 11.4rem;
padding: 1.75rem 1rem 0.8rem !important;
padding: 2.5rem 1.5rem !important;
border-radius: 0 !important;
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.04) !important;
}
.event-title {
max-width: 16rem;
margin: 0 auto 0.85rem !important;
font-size: 22px;
line-height: 1.1;
letter-spacing: 0;
}
.event-details {
gap: 0.45rem !important;
font-size: 15px;
line-height: 1.25;
}
@@ -261,6 +263,7 @@
.event-schedule-toggle {
margin-top: 1.9rem;
font-size: 13px;
}
.event-schedule-toggle i {
@@ -277,5 +280,6 @@
.event-schedule-value {
gap: 0.4rem;
font-size: 14px;
}
}

View File

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

View File

@@ -7,21 +7,17 @@ import {
computed,
effect,
inject,
viewChild
viewChild,
} from '@angular/core';
import {
MODAL_DATA,
ModalRef,
ModalService
} from '../../../core/services/modal.service';
import { MODAL_DATA, ModalRef, ModalService } from '../../../core/services/modal.service';
import { ModalShellComponent } from '../modal-shell/modal-shell.component';
@Component({
selector: 'app-modal-host',
imports: [NgComponentOutlet, ModalShellComponent],
templateUrl: './modal-host.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ModalHostComponent {
private readonly modalService = inject(ModalService);
@@ -42,9 +38,9 @@ export class ModalHostComponent {
return Injector.create({
providers: [
{ provide: ModalRef, useValue: modal.ref },
{ provide: MODAL_DATA, useValue: modal.config.data ?? null }
{ provide: MODAL_DATA, useValue: modal.config.data ?? null },
],
parent: this.injector
parent: this.injector,
});
});
@@ -58,6 +54,18 @@ export class ModalHostComponent {
const body = this.document.body;
const previousOverflow = body.style.overflow;
const previousPaddingRight = body.style.paddingRight;
const view = this.document.defaultView;
const scrollbarWidth = view
? Math.max(0, view.innerWidth - this.document.documentElement.clientWidth)
: 0;
if (scrollbarWidth > 0 && view) {
const currentPaddingRight =
Number.parseFloat(view.getComputedStyle(body).paddingRight) || 0;
body.style.paddingRight = `${currentPaddingRight + scrollbarWidth}px`;
}
body.style.overflow = 'hidden';
const onKeyDown = (event: KeyboardEvent) => {
@@ -75,6 +83,7 @@ export class ModalHostComponent {
onCleanup(() => {
this.document.removeEventListener('keydown', onKeyDown);
body.style.overflow = previousOverflow;
body.style.paddingRight = previousPaddingRight;
});
});
}

View File

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

View File

@@ -12,6 +12,8 @@
&--row {
--product-list-item-min-width: 32rem;
gap: 0;
}
&--adaptive {

View File

@@ -1,6 +1,4 @@
<div
class="product-row-card border rounded bg-white shadow-sm d-flex justify-content-between p-3 gap-3"
>
<div class="product-row-card">
<!-- Left Side: Title and Description -->
<div class="product-row-card__info d-flex flex-column justify-content-center flex-grow-1">
<h3 class="product-row-card__title text-uppercase mb-1 m-0">
@@ -13,20 +11,10 @@
}
</div>
<!-- Right Side: Actions and Pricing -->
<div class="product-row-card__actions d-flex flex-column gap-2 justify-content-center">
<!-- Top Row: Price and Comprar button -->
<div class="d-flex align-items-center justify-content-end gap-3">
<span class="product-row-card__price text-primary">
{{ formattedPrice() }}
</span>
<div class="product-row-card__btn-wrapper">
<app-button variant="primary" (click)="onBuy()"> Comprar </app-button>
</div>
</div>
<!-- Bottom Row: Attribute selects, Quantity, and Agregar al carrito button -->
<div class="d-flex align-items-center justify-content-end gap-2">
<!-- Right Side: Selectors and quantity first, then price -->
<div class="product-row-card__actions">
<div class="product-row-card__summary">
<div class="product-row-card__controls">
<app-variant-selector
class="product-row-card__selectors"
[variants]="variants()"
@@ -34,7 +22,17 @@
/>
<app-quantity-selector [(quantity)]="quantity"></app-quantity-selector>
</div>
<span class="product-row-card__price text-primary">
{{ formattedPrice() }}
</span>
</div>
<div class="product-row-card__buttons">
<div class="product-row-card__btn-wrapper">
<app-button variant="primary" (click)="onBuy()"> Comprar </app-button>
</div>
<div class="product-row-card__btn-wrapper">
<app-button variant="secondary" (click)="onAddToCart()"> Agregar al carrito </app-button>
</div>

View File

@@ -4,9 +4,14 @@
}
.product-row-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 2rem;
width: 100%;
background-color: #f8f9fa; /* slightly light bg matching the screenshot if needed, but let's use white or #f8f9fa based on the border */
border-color: #e9ecef !important;
padding: 2rem 0;
border-bottom: 1px solid #e3e3e3;
background: transparent;
&__title {
font-size: 16px;
@@ -26,13 +31,38 @@
}
&__price {
display: block;
align-self: flex-end;
font-size: 20px;
font-weight: 800;
color: var(--tenant-primary, #009933) !important; /* Green matching screenshot */
color: var(--tenant-primary, #009933) !important;
white-space: nowrap;
}
&__actions {
display: grid;
grid-template-columns: minmax(0, auto) 160px;
align-items: stretch;
gap: 0.75rem 2.5rem;
}
&__summary,
&__buttons {
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.5rem;
}
&__controls {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
}
&__btn-wrapper {
min-width: 160px; /* To make both buttons equal width as in screenshot */
min-width: 160px;
app-button {
display: block;
@@ -46,3 +76,14 @@
}
}
}
@media (max-width: 991.98px) {
.product-row-card {
align-items: stretch;
flex-direction: column;
&__actions {
grid-template-columns: minmax(0, 1fr) 160px;
}
}
}

View File

@@ -73,17 +73,6 @@
width: 100%;
}
&__variant-selectors ::ng-deep .variant-selector {
flex-wrap: nowrap;
width: 100%;
}
&__variant-selectors ::ng-deep .variant-selector__select {
flex: 1 1 0;
width: 0;
min-width: 0;
}
&__price {
color: var(--tenant-primary, #009933);
font-size: 22px;
@@ -114,6 +103,16 @@
@media (max-width: 767.98px) {
.product-vertical-with-cart-card {
border-color: var(--color-primary, var(--tenant-primary, #009933));
&__title {
color: var(--color-primary, var(--tenant-primary, #009933));
font-size: 15px;
}
&__description {
font-size: 13px;
font-weight: 400;
}
}
}

View File

@@ -1,19 +1,22 @@
:host {
display: block;
width: 100%;
min-width: 0;
}
.variant-selector {
display: flex;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: center;
gap: 8px;
width: 100%;
min-width: 0;
flex-wrap: wrap;
}
.variant-selector__select {
width: auto;
min-width: 120px;
width: 100%;
min-width: 0;
max-width: none;
height: 38px;
color: #666666;
border-color: #cccccc;
@@ -24,15 +27,18 @@
border-color: var(--tenant-primary);
box-shadow: 0 0 0 0.25rem rgba(0, 153, 51, 0.25);
}
&:first-child:nth-last-child(odd) {
grid-column: 1 / -1;
}
}
.variant-selector--compact {
justify-content: flex-end;
gap: 4px;
.variant-selector__select {
min-width: 0;
max-width: 150px;
max-width: none;
height: 28px;
padding: 0.2rem 1.75rem 0.2rem 0.45rem;
font-size: 11px;