Compare commits

..

4 Commits

19 changed files with 179 additions and 89 deletions

View File

@@ -1,14 +1,14 @@
<header
class="immersive-header"
[class.immersive-header--compact]="!showHero()"
>
<header class="immersive-header" [class.immersive-header--compact]="!showHero()">
<div
class="immersive-header__backdrop"
[style.background-image]="backgroundImageUrl() ? 'url(' + backgroundImageUrl() + ')' : null"
></div>
<div class="immersive-header__content">
<nav class="immersive-header__nav container-xl px-3 px-md-4" aria-label="Navegación de la tienda">
<nav
class="immersive-header__nav container-xl px-3 px-md-4"
aria-label="Navegación de la tienda"
>
<a routerLink="/" class="immersive-header__brand" aria-label="Ir al inicio">
@if (logoUrl()) {
<img [src]="logoUrl()" alt="Logo del comercio" />
@@ -19,19 +19,14 @@
@if (displaySearchBar()) {
<form class="immersive-header__search" role="search" (submit)="submitSearch($event)">
<label class="visually-hidden" for="immersive-search-input">Buscar productos</label>
<input
<app-input
id="immersive-search-input"
type="search"
placeholder="Buscar"
autocomplete="off"
[formControl]="searchControl"
[attr.aria-invalid]="showSearchError()"
[attr.aria-describedby]="showSearchError() ? 'immersive-search-error' : null"
(input)="showSearchError.set(false)"
type="search"
[(value)]="searchTerm"
[invalid]="showSearchError()"
(valueChange)="showSearchError.set(false)"
/>
<button type="submit" aria-label="Buscar">
<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
</button>
@if (showSearchError()) {
<span id="immersive-search-error" class="immersive-header__search-error" role="alert"
>Ingresá al menos 3 caracteres.</span
@@ -41,9 +36,13 @@
}
@if (ticketsMenu(); as menu) {
<button class="immersive-header__tickets" type="button" (click)="ticketsClick.emit()">
<app-button
variant="secondary"
hostClass="immersive-header__tickets"
(click)="ticketsClick.emit()"
>
{{ menu.label }}
</button>
</app-button>
}
@if (displayCart()) {
@@ -78,7 +77,11 @@
</nav>
@if (checkoutRemainingTime(); as remainingTime) {
<div class="immersive-header__timer container-xl px-3 px-md-4" role="timer" aria-label="Tiempo restante de compra">
<div
class="immersive-header__timer container-xl px-3 px-md-4"
role="timer"
aria-label="Tiempo restante de compra"
>
Tiempo restante de compra: <strong>{{ remainingTime }}</strong>
</div>
}

View File

@@ -74,57 +74,19 @@
.immersive-header__search {
position: relative;
display: flex;
width: clamp(115px, 17vw, 205px);
height: 2rem;
border: 1px solid var(--tenant-primary, #ff7006);
border-radius: 3px;
background: rgba(0, 0, 0, 0.1);
}
.immersive-header__search input {
width: 100%;
min-width: 0;
padding: 0 0.5rem;
border: 0;
outline: 0;
color: #fff;
background: transparent;
font-size: 0.75rem;
}
.immersive-header__search input::placeholder {
color: rgba(255, 255, 255, 0.78);
}
.immersive-header__search input::-webkit-search-cancel-button {
filter: invert(1);
}
.immersive-header__search button {
border: 0;
padding-inline: 0.55rem;
color: var(--tenant-primary, #ff7006);
background: transparent;
}
.immersive-header__search:focus-within {
outline: 2px solid var(--tenant-primary, #ff7006);
outline-offset: 2px;
}
.immersive-header__search-error {
position: absolute;
top: calc(100% + 0.25rem);
right: 0;
width: max-content;
color: #fff;
color: var(--tenant-danger, #dc3545);
font-size: 0.7rem;
}
.immersive-header__tickets {
border: 1px solid var(--tenant-primary, #ff7006);
border-radius: 3px;
padding: 0.25rem 0.6rem;
color: #fff;
background: rgba(0, 0, 0, 0.2);
font-size: 0.75rem;
}
.immersive-header__tickets:hover {
background: var(--tenant-primary, #ff7006);
:host ::ng-deep .immersive-header__tickets {
width: auto;
}
.immersive-header__account {
position: relative;
@@ -219,9 +181,3 @@
font-size: clamp(3rem, 12vw, 5rem);
}
}
@media (prefers-reduced-motion: reduce) {
.immersive-header__tickets {
transition: none;
}
}

View File

@@ -8,12 +8,13 @@ import {
output,
signal,
} from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { AuthUser } from '../../../services/auth/auth.interfaces';
import { Menu, WebsiteExtras } from '../../../services/tenant.interface';
import { ButtonComponent } from '../../../../shared/components/button/button.component';
import { CartIconComponent } from '../../../../shared/components/cart-icon/cart-icon.component';
import { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.component';
import { InputComponent } from '../../../../shared/components/input/input.component';
import { ContinuousImageCarouselComponent } from '../../../../shared/components/continuous-image-carousel/continuous-image-carousel.component';
import { UserDropdownComponent } from './user-dropdown/user-dropdown.component';
@@ -21,9 +22,10 @@ import { UserDropdownComponent } from './user-dropdown/user-dropdown.component';
selector: 'app-immersive-store-header',
imports: [
RouterLink,
ReactiveFormsModule,
ButtonComponent,
CartIconComponent,
IconButtonComponent,
InputComponent,
ContinuousImageCarouselComponent,
UserDropdownComponent,
],
@@ -54,7 +56,7 @@ export class ImmersiveStoreHeaderComponent {
readonly logoutClick = output<void>();
readonly searchSubmit = output<string>();
protected readonly searchControl = new FormControl('', { nonNullable: true });
protected searchTerm = '';
protected readonly showSearchError = signal(false);
protected readonly isUserDropdownOpen = signal(false);
protected readonly checkoutRemainingTime = computed(() => {
@@ -68,7 +70,7 @@ export class ImmersiveStoreHeaderComponent {
protected submitSearch(event?: Event): void {
event?.preventDefault();
const term = this.searchControl.value.trim();
const term = this.searchTerm.trim();
if (term.length < 3) {
this.showSearchError.set(true);
return;

View File

@@ -17,7 +17,7 @@
[displaySeachBar]="tenant()?.display_seach_bar ?? true"
[displayCart]="displayCart()"
[cartDisabled]="isCheckoutRoute()"
[checkoutRemainingSeconds]="checkoutRemainingSeconds()"
[checkoutRemainingSeconds]="headerCheckoutRemainingSeconds()"
(cartClick)="onCartClick()"
(ticketsClick)="onTicketsClick()"
(loginClick)="onLoginClick()"

View File

@@ -428,6 +428,26 @@ describe('StoreLayoutComponent', () => {
);
});
it('moves the countdown out of the header on multi-event checkout', () => {
tenantState.set({
...tenant,
storefront_website_type_code: 'onticket_multi_event',
checkout_summary_type: 'event',
});
checkoutRemainingSecondsState.set(587);
const router = TestBed.inject(Router);
vi.spyOn(router, 'url', 'get').mockReturnValue('/checkout/25');
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const header = fixture.debugElement.query(By.directive(StoreHeaderComponent));
expect(header.componentInstance.checkoutRemainingSeconds()).toBeNull();
expect(element.querySelector('.store-layout__checkout-timer')).toBeNull();
});
it('hides the configured header elements when the tenant disables them', () => {
tenantState.set({
...tenant,

View File

@@ -74,6 +74,11 @@ export class StoreLayoutComponent implements OnInit {
private readonly isMultiEventStorefront = computed(
() => this.tenant()?.storefront_website_type_code === 'onticket_multi_event',
);
protected readonly headerCheckoutRemainingSeconds = computed(() =>
this.isCheckoutRoute() && this.tenant()?.checkout_summary_type === 'event'
? null
: this.checkoutRemainingSeconds(),
);
protected readonly navigationCategories = computed(() =>
this.isMultiEventStorefront()
? (this.tenant()?.event_categories ?? [])

View File

@@ -121,11 +121,17 @@ export interface PurchaseDetailItemResponse {
nombre: string;
descripcion: string | null;
slug: string;
imagen: string | null;
imagen?: string | null;
attributes: PurchaseDetailAttributeResponse[];
};
}
export interface PurchaseCheckoutEventResponse {
id: number;
title: string;
image: string | null;
}
export interface PurchaseDetailResponse extends PurchaseStatusResponse {
id: number;
cart_id: number | null;
@@ -140,6 +146,7 @@ export interface PurchaseDetailResponse extends PurchaseStatusResponse {
email: string | null;
items_source: 'purchase';
items: PurchaseDetailItemResponse[];
event?: PurchaseCheckoutEventResponse | null;
tickets_count?: number;
has_generated_tickets?: boolean;
subtotal: string;

View File

@@ -134,6 +134,7 @@ export interface Tenant {
footer_bg_image?: string | null;
storefront_website_type_code?: string | null;
header_type?: 'standard' | 'immersive';
checkout_summary_type?: 'default' | 'event';
extras?: WebsiteExtras;
event?: TenantEvent | null;
selected_bank_account_id?: number | null;

View File

@@ -706,7 +706,7 @@
<app-location-map
[locations]="mapLocations"
[markerColor]="tenant()?.primary_color ?? '#24a9e0'"
mapStyle="minimal-light"
mapStyle="standard"
ariaLabel="Mapa de puntos de atención en Rosario"
/>
</div>

View File

@@ -103,7 +103,7 @@ export class PurchaseDetailPage implements OnInit {
id: item.id,
title: item.item_details.nombre || 'Producto sin nombre',
description: item.item_details.descripcion,
imageUrl: item.item_details.imagen,
imageUrl: item.item_details.imagen ?? null,
quantity: item.quantity,
attributes: item.item_details.attributes.map((attribute) => ({
label: attribute.name,

View File

@@ -47,7 +47,26 @@
</div>
<div class="checkout-page__cart-col">
@if (checkoutRemainingTime(); as remainingTime) {
@if (usesEventCheckoutSummary()) {
@if (checkoutRemainingTime(); as remainingTime) {
<div
class="checkout-page__summary-countdown d-flex align-items-baseline justify-content-center gap-3"
role="timer"
aria-label="Tiempo restante de compra"
>
<span class="checkout-page__summary-countdown-label">Tiempo restante de compra:</span>
<span class="checkout-page__summary-countdown-value">{{ remainingTime }}</span>
</div>
}
@if (checkoutEvent(); as event) {
@if (event.image) {
<div class="checkout-page__event-media">
<img [src]="event.image" [alt]="event.title" />
</div>
}
}
} @else if (checkoutRemainingTime(); as remainingTime) {
<div
class="checkout-page__mobile-countdown d-flex d-md-none align-items-baseline justify-content-center gap-3"
role="timer"

View File

@@ -46,6 +46,38 @@
line-height: 1;
font-variant-numeric: tabular-nums;
}
&__summary-countdown {
flex: 0 0 auto;
margin-bottom: 1.5rem;
color: var(--tenant-primary);
font-weight: 700;
white-space: nowrap;
}
&__summary-countdown-label {
font-size: 13px;
}
&__summary-countdown-value {
font-size: 20px;
line-height: 1;
font-variant-numeric: tabular-nums;
}
&__event-media {
flex: 0 0 auto;
width: 100%;
margin-bottom: 1rem;
overflow: hidden;
border-radius: 4px;
img {
display: block;
width: 100%;
height: auto;
}
}
}
.checkout-page__loading {

View File

@@ -39,6 +39,8 @@ describe('CheckoutPageComponent payment validation', () => {
let tenantState: ReturnType<
typeof signal<{
codigo: string;
storefront_website_type_code?: string | null;
checkout_summary_type?: 'default' | 'event';
checkout_editing_policy?: CartEditingPolicy;
}>
>;
@@ -468,6 +470,49 @@ describe('CheckoutPageComponent payment validation', () => {
});
});
it('uses the event image and removes item images for multi-event checkout', () => {
tenantState.set({
...tenantState(),
checkout_summary_type: 'event',
});
const { component } = createComponent();
component.createdPurchase.set({
id: 25,
status: 'created',
event: {
id: 12,
title: 'Festival de prueba',
image: 'https://example.com/event.jpg',
},
items: [
{
id: 91,
quantity: 1,
unit_price: '10000.00',
line_total: '10000.00',
source_catalog_item_id: 8,
source_variant_id: null,
item_details: {
nombre: 'Entrada general',
descripcion: null,
slug: 'entrada-general',
imagen: 'https://example.com/product.jpg',
attributes: [],
},
},
],
subtotal: '10000.00',
total: '10000.00',
});
expect(component.checkoutEvent()).toEqual({
id: 12,
title: 'Festival de prueba',
image: 'https://example.com/event.jpg',
});
expect(component.mappedCartItems()[0].imageUrl).toBeNull();
});
it('keeps the checkout hidden while the purchase is loading', async () => {
let resolvePurchase!: (purchase: any) => void;
routeParamMap = convertToParamMap({ id: 25 });

View File

@@ -138,6 +138,12 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
const purchase = this.createdPurchase();
return purchase ? purchase.items.map((item) => this.mapPurchaseItemToMock(item)) : [];
});
protected readonly usesEventCheckoutSummary = computed(
() => this.tenantService.tenant()?.checkout_summary_type === 'event',
);
protected readonly checkoutEvent = computed(() =>
this.usesEventCheckoutSummary() ? (this.createdPurchase()?.event ?? null) : null,
);
protected readonly isStep1Valid = signal(this.form.valid);
protected readonly paymentMethods: ReadonlyArray<PaymentMethodOption> = [
@@ -235,7 +241,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
private mapPurchaseItemToMock(item: PurchaseDetailItemResponse): CartItemMock {
return {
cartItemId: item.id,
imageUrl: item.item_details.imagen,
imageUrl: this.usesEventCheckoutSummary() ? null : (item.item_details.imagen ?? null),
product: item.item_details.nombre,
originalPrice: null,
discountedPrice: parseFloat(item.unit_price),

View File

@@ -37,7 +37,7 @@
class="event-detail__map"
[locations]="mapLocations()"
ariaLabel="Ubicación del evento"
mapStyle="minimal-light"
mapStyle="standard"
/>
}
</aside>

View File

@@ -10,8 +10,8 @@
.event-detail__description p { margin: 0; white-space: pre-line; line-height: 1.36; font-size: 14px; }
.event-detail__social { display: grid; gap: .35rem; margin-top: 2.3rem; }
.event-detail__social a { color: #666666; font-family: 'Gotham Book', sans-serif; font-size: 14px; font-weight: 400; display: flex; align-items: center; gap: .5rem; text-decoration: none; font-style: italic; }
.event-detail__social a:hover { color: var(--tenant-primary); text-decoration: underline; }
.event-detail__social i { width: 14px; font-size: 14px; text-align: center; }
.event-detail__social a:hover { color: var(--tenant-primary); text-decoration: none; }
.event-detail__social i { width: 18px; height: 18px; font-size: 18px; line-height: 18px; text-align: center; }
.event-detail__map { margin-top: 16px; --location-map-min-height: 180px; --location-map-border-radius: 0; }
.event-detail__header { margin-bottom: 2rem; padding-top: .7rem; }
.event-detail__header h1 { margin: 0; color: #666666; font-size: 24px; line-height: 1.25; font-weight: 700; text-transform: uppercase; letter-spacing: .03em; }

View File

@@ -45,7 +45,7 @@
class="contact-page__map"
[locations]="contact().mapLocations"
[markerColor]="markerColor()"
mapStyle="minimal-light"
mapStyle="standard"
ariaLabel="Mapa de sucursales y puntos de atención"
/>
}

View File

@@ -128,7 +128,7 @@ describe('ContactPage', () => {
);
expect(map).not.toBeNull();
expect(map?.getAttribute('mapstyle')).toBe('minimal-light');
expect(map?.getAttribute('mapstyle')).toBe('standard');
expect(accessibleLocations).toHaveLength(2);
expect(map?.textContent).toContain('Gigante de Arroyito');
expect(map?.textContent).toContain('Telepagos');

View File

@@ -13,7 +13,7 @@ import {
} from '@angular/core';
import type { LayerGroup, Map as LeafletMap, LatLngBounds, LatLngTuple, TileLayer } from 'leaflet';
export type MapStyle = 'minimal-light' | 'standard';
export type MapStyle = 'standard';
const MAP_LAYERS: Record<
MapStyle,
@@ -23,12 +23,6 @@ const MAP_LAYERS: Record<
maxZoom: number;
}
> = {
'minimal-light': {
url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>',
maxZoom: 20,
},
standard: {
url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
attribution:
@@ -56,7 +50,7 @@ export class LocationMapComponent implements AfterViewInit, OnChanges, OnDestroy
@Input() fallbackCenter: LatLngTuple = [-32.94682, -60.63932];
@Input() fallbackZoom = 13;
@Input() maxFitZoom = 15;
@Input() mapStyle: MapStyle = 'minimal-light';
@Input() mapStyle: MapStyle = 'standard';
@Input() markerColor = '#24a9e0';
@ViewChild('mapContainer', { static: true })