feat(event-detail): enhance event detail page with improved layout and additional features

feat(counter-to): add countdown component for event timing
feat(fonts): include Gotham Book font for better typography
fix(location-map): make map height responsive with CSS variables
This commit is contained in:
2026-09-18 16:37:39 -03:00
parent 6997550767
commit 14bda25c56
14 changed files with 406 additions and 40 deletions

View File

@@ -38,6 +38,14 @@
font-display: swap;
}
@font-face {
font-family: "Gotham Book";
src: url("/fonts/Gotham/Gotham Book.otf") format("opentype");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Gotham";
src: url("/fonts/Gotham/Gotham Italic.otf") format("opentype");

View File

@@ -8,7 +8,7 @@
></div>
<div class="immersive-header__content">
<nav class="immersive-header__nav" 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" />
@@ -78,7 +78,7 @@
</nav>
@if (checkoutRemainingTime(); as remainingTime) {
<div class="immersive-header__timer" 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

@@ -45,8 +45,6 @@
.immersive-header__nav {
position: relative;
z-index: 1;
width: min(100% - 2rem, 1120px);
margin-inline: auto;
display: flex;
align-items: center;
justify-content: space-between;
@@ -172,8 +170,7 @@
.immersive-header__timer {
position: relative;
z-index: 1;
width: min(100% - 2rem, 1120px);
margin: 0.75rem auto 0;
margin-top: 0.75rem;
text-align: right;
font-size: 0.75rem;
}

View File

@@ -6,9 +6,29 @@ export interface StoreEvent {
subtitle: string | null;
description: string | null;
location: string | null;
exact_location: { latitude: number; longitude: number } | null;
date_text: string | null;
start_time?: string | null;
starts_at?: string | null;
dates?: { id: number; date: string; time_start: string }[];
social_media?: { code: string; url: string }[];
attachment_id: number | null;
image: string | null;
catalog_items?: {
id: number;
name: string;
description: string | null;
price: string;
image: string | null;
requires_selection: boolean;
maximum_quantity: number | null;
variants: {
id: number;
price: string;
event_date_ids: number[];
maximum_quantity: number | null;
}[];
}[];
}
export type StoreEventPage = ApiPaginatedResponse<StoreEvent[]>;

View File

@@ -5,7 +5,7 @@
}
</div>
<div class="event-card__body">
<h2 class="event-card__title">{{ event().title }}</h2>
<h2 class="event-card__title">{{ event().title | uppercase }}</h2>
@if (event().subtitle) {
<p class="event-card__subtitle">{{ event().subtitle }}</p>
}

View File

@@ -5,7 +5,7 @@
.event-card__image { width: 100%; aspect-ratio: 1.43; background: #e5e6e7; }
.event-card__image img { width: 100%; height: 100%; object-fit: cover; display: block; }
.event-card__body { padding: 1rem 1.1rem 1.2rem; }
.event-card__title { margin: 0; font-size: .9rem; font-weight: 700; line-height: 1.3; text-transform: uppercase; }
.event-card__subtitle { margin: .25rem 0 .8rem; font-size: .75rem; color: #63666a; }
.event-card__meta { display: flex; align-items: baseline; gap: .45rem; margin: .2rem 0 0; font-size: .7rem; color: #72767a; }
.event-card__title { margin: 0; font-size: 15px; font-weight: 700; line-height: 1.3; color: #666666; }
.event-card__subtitle { margin: .25rem 0 .8rem; font-size: 12px; font-weight: 400; color: #666666; }
.event-card__meta { display: flex; align-items: baseline; gap: .45rem; margin: .2rem 0 0; font-size: 9px; font-weight: 400; font-style: italic; color: #666666; }
.event-card__meta i { width: .75rem; flex: none; }

View File

@@ -1,11 +1,12 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { UpperCasePipe } from '@angular/common';
import { RouterLink } from '@angular/router';
import { StoreEvent } from '../../../../core/services/event/event.interface';
@Component({
selector: 'app-event-card',
imports: [RouterLink],
imports: [RouterLink, UpperCasePipe],
templateUrl: './event-card.component.html',
styleUrl: './event-card.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,

View File

@@ -1,19 +1,111 @@
<a routerLink="/" class="event-detail__back">← Volver a eventos</a>
@if (event(); as item) {
<article class="event-detail">
<div class="event-detail__image">
@if (item.image) {
<img [src]="item.image" [alt]="item.title" />
<div class="event-detail row gx-sm-4 gx-lg-5">
<aside class="event-detail__sidebar col-12 col-sm-4">
<div class="event-detail__image" [class.event-detail__image--placeholder]="!item.image">
@if (item.image) {
<img [src]="item.image" [alt]="item.title" />
} @else {
<span aria-hidden="true"><i class="fa-regular fa-calendar"></i></span>
}
</div>
@if (item.starts_at) {
<app-counter-to [startsAt]="item.starts_at" />
}
</div>
<div class="event-detail__content">
<h1>{{ item.title }}</h1>
@if (item.subtitle) { <p class="event-detail__subtitle">{{ item.subtitle }}</p> }
@if (item.date_text) { <p><i class="fa-regular fa-calendar" aria-hidden="true"></i> {{ item.date_text }}</p> }
@if (item.location) { <p><i class="fa-solid fa-location-dot" aria-hidden="true"></i> {{ item.location }}</p> }
@if (item.description) { <p class="event-detail__description">{{ item.description }}</p> }
</div>
</article>
@if (item.description) {
<section class="event-detail__description" aria-labelledby="event-description-title">
<h2 id="event-description-title">Descripción</h2>
<p>{{ item.description }}</p>
</section>
}
@if (item.social_media?.length) {
<section class="event-detail__social" aria-labelledby="event-social-title">
<h2 id="event-social-title">Información</h2>
@for (social of item.social_media; track social.code) {
<a [href]="social.url" target="_blank" rel="noopener noreferrer">
<i [class]="socialIcon(social.code)" aria-hidden="true"></i>
<span>{{ socialLabel(social.code) }}</span>
</a>
}
</section>
}
@if (mapLocations().length) {
<app-location-map
class="event-detail__map"
[locations]="mapLocations()"
ariaLabel="Ubicación del evento"
mapStyle="minimal-light"
/>
}
</aside>
<main class="event-detail__main col-12 col-sm-8">
<header class="event-detail__header">
<h1>{{ item.title }}</h1>
@if (item.subtitle) { <p class="event-detail__subtitle">{{ item.subtitle }}</p> }
<div class="event-detail__meta">
@if (item.date_text) {
<p><i class="fa-regular fa-calendar" aria-hidden="true"></i>
{{ item.date_text }}@if (item.dates?.length === 1 && item.start_time) { · {{ item.start_time.slice(0, 5) }} h }
</p>
}
@if (item.location) {
<p><i class="fa-solid fa-location-dot" aria-hidden="true"></i>{{ item.location }}</p>
}
</div>
</header>
<section class="event-detail__products" aria-labelledby="event-products-title">
<div class="event-detail__section-heading"><h2 id="event-products-title">Productos</h2></div>
@if ((item.dates?.length ?? 0) > 1) {
<label class="event-detail__date-label" for="event-date-select">Seleccioná fecha</label>
<select id="event-date-select" class="event-detail__date-select" [value]="selectedDateId()" (change)="selectDate($event)">
@for (date of item.dates; track date.id) {
<option [value]="date.id">{{ formatDate(date.date) }} · {{ date.time_start.slice(0, 5) }} h</option>
}
</select>
}
@if (item.catalog_items?.length) {
<div class="event-detail__product-list">
@for (product of item.catalog_items; track product.id) {
<div class="event-detail__product row align-items-center gx-3 gy-2">
<h3 class="col-12 col-sm">{{ product.name }}</h3>
<div class="event-detail__product-action col-auto ms-auto ms-sm-0">
@if (needsSelection(product)) {
<a [routerLink]="['/producto', product.id]">Elegir opciones</a>
} @else {
<app-quantity-selector
[quantity]="quantityFor(product.id)"
[min]="0"
[max]="maximumQuantityFor(product)"
size="medium"
(decrease)="changeQuantity(product, -1)"
(increase)="changeQuantity(product, 1)"
/>
}
</div>
<strong class="event-detail__price col-auto">{{ priceFor(product) }}</strong>
</div>
}
</div>
<div class="event-detail__total"><span>Total</span><strong>{{ formatPrice(total()) }}</strong></div>
<div class="event-detail__buttons">
<app-button variant="cancel" (click)="cancel()">Cancelar</app-button>
<app-button variant="primary" [disabled]="total() === 0 || submitting()" (click)="buyNow()">
{{ submitting() ? 'Procesando…' : 'Comprar' }}
</app-button>
</div>
} @else {
<p class="event-detail__empty">Por ahora no hay productos disponibles para este evento.</p>
}
</section>
</main>
</div>
} @else if (!loading()) {
<p role="alert">No encontramos este evento.</p>
}

View File

@@ -1,8 +1,43 @@
.event-detail__back { display: inline-block; margin-bottom: 2rem; color: var(--tenant-primary); }
.event-detail { display: grid; grid-template-columns: 1fr 1fr; gap: clamp(2rem, 5vw, 5rem); }
.event-detail__image { aspect-ratio: 1.43; background: #e5e6e7; }
.event-detail__image img { width: 100%; height: 100%; object-fit: cover; display: block; }
.event-detail__content h1 { font-size: clamp(1.7rem, 3vw, 2.8rem); text-transform: uppercase; }
.event-detail__subtitle { color: #63666a; }
.event-detail__description { margin-top: 2rem; white-space: pre-line; }
@media (max-width: 767.98px) { .event-detail { grid-template-columns: 1fr; } }
:host { display: block; color: #666666; }
.event-detail { margin-bottom: 3rem; }
.event-detail__sidebar, .event-detail__main { min-width: 0; }
.event-detail__image { background: #e7e8e9; }
.event-detail__image--placeholder { aspect-ratio: 1.43; display: grid; place-items: center; }
.event-detail__image img { width: 100%; height: auto; display: block; }
.event-detail__image span { font-size: 3rem; color: #aaa; }
.event-detail__description { margin-top: 1.5rem; }
.event-detail__description h2, .event-detail__social h2, .event-detail__section-heading h2 { margin: 0 0 .65rem; color: #666666; font-size: 15px; font-weight: 700; text-transform: uppercase; letter-spacing: .015em; }
.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__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; }
.event-detail__subtitle { margin: .15rem 0 1.2rem; color: #666666; font-size: 18px; font-weight: 400; }
.event-detail__meta { display: grid; gap: .3rem; }
.event-detail__meta p { margin: 0; display: flex; align-items: baseline; gap: .4rem; color: #666666; font-family: 'Gotham Book', sans-serif; font-size: 12px; font-weight: 400; font-style: italic; }
.event-detail__meta i { width: .65rem; flex: none; text-align: center; }
.event-detail__section-heading { border-bottom: 1px solid #e2e4e6; margin-bottom: .9rem; }
.event-detail__section-heading h2 { margin-bottom: .5rem; }
.event-detail__date-label { display: block; margin-bottom: .4rem; color: #6b7175; font-size: .75rem; }
.event-detail__date-select { width: min(100%, 320px); padding: .55rem .75rem; border: 1px solid #d7d9db; border-radius: 4px; background: #fff; color: #555b60; font-size: .82rem; }
.event-detail__product-list { display: grid; gap: 28px; margin-top: 1.1rem; }
.event-detail__product { min-height: 40px; }
.event-detail__product h3 { min-width: 0; margin: 0; color: #838383; font-size: 20px; line-height: 1.2; font-weight: 700; text-transform: uppercase; }
.event-detail__product-action { width: 110px; text-align: right; }
.event-detail__product-action > a { color: var(--tenant-primary); font-size: .62rem; white-space: nowrap; }
.event-detail__price { width: 120px; color: #838383; font-size: 22px; font-weight: 900; text-align: right; white-space: nowrap; }
.event-detail__total { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; padding: .75rem 0 0; border-top: 1px solid #dfe2e4; margin-top: 1.15rem; font-size: 13px; color: #999999; text-transform: uppercase; }
.event-detail__total strong { color: var(--tenant-primary); font-size: 22px; font-weight: 900; }
.event-detail__buttons { display: flex; justify-content: flex-end; gap: .5rem; margin-top: 1.6rem; }
.event-detail__buttons app-button { min-width: 0; flex: 0 0 36%; }
.event-detail__empty { color: #7c8286; font-size: .85rem; }
@media (max-width: 575.98px) {
.event-detail__main { margin-top: 2rem; }
.event-detail__description, .event-detail__social { margin-top: 1.5rem; }
.event-detail__header { min-height: 0; margin-bottom: 2rem; }
.event-detail__buttons { flex-wrap: wrap; }
.event-detail__buttons app-button { flex: 1 1 100%; }
}

View File

@@ -1,24 +1,61 @@
import { ChangeDetectionStrategy, Component, DestroyRef, inject, signal } from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http';
import { ChangeDetectionStrategy, Component, DestroyRef, computed, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { catchError, map, of, switchMap } from 'rxjs';
import { EventService } from '../../../../core/services/event/event.service';
import { AuthService } from '../../../../core/services/auth/auth.service';
import { CheckoutService, DirectCheckoutItem } from '../../../../core/services/checkout.service';
import { StoreEvent } from '../../../../core/services/event/event.interface';
import { EventService } from '../../../../core/services/event/event.service';
import { TenantService } from '../../../../core/services/tenant.service';
import { ToastService } from '../../../../core/services/toast.service';
import { CounterToComponent } from '../../../../shared/components/counter-to/counter-to.component';
import { ButtonComponent } from '../../../../shared/components/button/button.component';
import { LocationMapComponent, MapLocation } from '../../../../shared/components/location-map/location-map.component';
import { QuantitySelectorComponent } from '../../../../shared/components/quantity-selector/quantity-selector.component';
type EventProduct = NonNullable<StoreEvent['catalog_items']>[number];
@Component({
selector: 'app-event-detail-page',
imports: [RouterLink],
imports: [RouterLink, ButtonComponent, CounterToComponent, LocationMapComponent, QuantitySelectorComponent],
templateUrl: './event-detail-page.component.html',
styleUrl: './event-detail-page.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EventDetailPageComponent {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly events = inject(EventService);
private readonly checkout = inject(CheckoutService);
private readonly auth = inject(AuthService);
private readonly tenant = inject(TenantService);
private readonly toast = inject(ToastService);
private readonly destroyRef = inject(DestroyRef);
protected readonly event = signal<StoreEvent | null>(null);
protected readonly loading = signal(true);
protected readonly selectedDateId = signal<number | null>(null);
protected readonly quantities = signal<Record<number, number>>({});
protected readonly submitting = signal(false);
protected readonly mapLocations = computed<MapLocation[]>(() => {
const event = this.event();
const coordinates = event?.exact_location;
if (!event || !coordinates) return [];
return [{
id: event.id,
label: event.title,
latitude: coordinates.latitude,
longitude: coordinates.longitude,
address: event.location ?? undefined,
}];
});
protected readonly total = computed(() => (this.event()?.catalog_items ?? []).reduce((sum, product) => {
const choice = this.resolveChoice(product);
return sum + (choice ? choice.price * this.quantityFor(product.id) : 0);
}, 0));
constructor() {
this.route.paramMap.pipe(
@@ -29,7 +66,128 @@ export class EventDetailPageComponent {
takeUntilDestroyed(this.destroyRef),
).subscribe((event) => {
this.event.set(event);
this.selectedDateId.set(event?.dates?.[0]?.id ?? null);
this.quantities.set({});
this.loading.set(false);
});
}
protected selectDate(event: Event): void {
const value = Number((event.target as HTMLSelectElement).value);
this.selectedDateId.set(Number.isInteger(value) ? value : null);
this.quantities.set({});
}
protected quantityFor(productId: number): number {
return this.quantities()[productId] ?? 0;
}
protected changeQuantity(product: EventProduct, amount: number): void {
const choice = this.resolveChoice(product);
if (!choice) return;
const maximum = choice.maximumQuantity ?? Number.MAX_SAFE_INTEGER;
const quantity = Math.min(maximum, Math.max(0, this.quantityFor(product.id) + amount));
this.quantities.update((current) => ({ ...current, [product.id]: quantity }));
}
protected maximumQuantityFor(product: EventProduct): number | null {
return this.resolveChoice(product)?.maximumQuantity ?? null;
}
protected needsSelection(product: EventProduct): boolean {
return this.resolveChoice(product) === null;
}
protected priceFor(product: EventProduct): string {
return this.formatPrice(this.resolveChoice(product)?.price ?? Number(product.price));
}
protected formatPrice(price: number): string {
return '$' + price.toLocaleString('es-AR', { maximumFractionDigits: 0 });
}
protected formatDate(date: string): string {
const [year, month, day] = date.split('-').map(Number);
if (!year || !month || !day) return date;
return new Intl.DateTimeFormat('es-AR', { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' })
.format(new Date(Date.UTC(year, month - 1, day)));
}
protected socialLabel(code: string): string {
const labels: Record<string, string> = {
instagram: 'Instagram', facebook: 'Facebook', whatsapp: 'WhatsApp',
youtube: 'YouTube', tiktok: 'TikTok', x: 'X', twitter: 'X',
};
return labels[code.toLowerCase()] ?? code;
}
protected socialIcon(code: string): string {
const brands = ['instagram', 'facebook', 'whatsapp', 'youtube', 'tiktok', 'x-twitter', 'linkedin'];
const normalized = code.toLowerCase() === 'twitter' || code.toLowerCase() === 'x'
? 'x-twitter'
: code.toLowerCase();
return brands.includes(normalized) ? `fa-brands fa-${normalized}` : 'fa-solid fa-link';
}
protected cancel(): void {
void this.router.navigate(['/']);
}
protected async buyNow(): Promise<void> {
const event = this.event();
if (!event || this.submitting()) return;
const items: DirectCheckoutItem[] = [];
for (const product of event.catalog_items ?? []) {
const quantity = this.quantityFor(product.id);
if (quantity === 0) continue;
const choice = this.resolveChoice(product);
if (!choice) return;
items.push({ catalog_item_id: product.id, variant_id: choice.variantId, cantidad: quantity });
}
if (items.length === 0) return;
if (!this.auth.user()) {
await this.router.navigate(['/login'], { queryParams: { returnUrl: this.router.url } });
return;
}
const tenant = this.tenant.tenant();
if (!tenant) {
this.toast.danger('No se pudo identificar la tienda.');
return;
}
this.submitting.set(true);
try {
const purchase = await this.checkout.startCheckout(tenant.codigo, { direct_items: items });
await this.router.navigate(['/checkout', purchase.id]);
} catch (error) {
const message = error instanceof HttpErrorResponse && typeof error.error?.message === 'string'
? error.error.message
: 'No se pudo iniciar la compra.';
this.toast.danger(message);
} finally {
this.submitting.set(false);
}
}
private resolveChoice(product: EventProduct): { variantId: number | null; price: number; maximumQuantity: number | null } | null {
if (product.requires_selection) return null;
if (product.variants.length === 0) {
return { variantId: null, price: Number(product.price), maximumQuantity: product.maximum_quantity };
}
const dateId = this.selectedDateId();
const candidates = product.variants.filter((variant) =>
dateId === null || variant.event_date_ids.length === 0 || variant.event_date_ids.includes(dateId));
if (candidates.length !== 1) return null;
return {
variantId: candidates[0].id,
price: Number(candidates[0].price),
maximumQuantity: candidates[0].maximum_quantity,
};
}
}

View File

@@ -0,0 +1,12 @@
@if (countdown(); as timer) {
<div class="counter-to" aria-label="Tiempo restante para el evento">
@if (timer.finished) {
<span>El evento comenzó</span>
} @else {
<div><strong>{{ timer.days }}</strong><small>DÍAS</small></div>
<div><strong>{{ timer.hours }}</strong><small>HORAS</small></div>
<div><strong>{{ timer.minutes }}</strong><small>MINUTOS</small></div>
<div><strong>{{ timer.seconds }}</strong><small>SEGUNDOS</small></div>
}
</div>
}

View File

@@ -0,0 +1,6 @@
:host { display: block; margin-top: 9px; }
.counter-to { min-height: 30px; padding-block: 12px; display: grid; grid-template-columns: repeat(4, 1fr); align-items: center; text-align: center; border: 1px solid #d7d9db; border-radius: 3px; }
.counter-to > div + div { border-left: 1px solid #e6e7e8; }
.counter-to strong { display: block; color: #666666; font-size: 16px; font-weight: 700; line-height: 1.1; }
.counter-to small { display: block; margin-top: 1px; color: #666666; font-size: 11px; font-weight: 400; letter-spacing: .02em; }
.counter-to > span { grid-column: 1 / -1; font-weight: 600; }

View File

@@ -0,0 +1,37 @@
import { isPlatformBrowser } from '@angular/common';
import { ChangeDetectionStrategy, Component, DestroyRef, PLATFORM_ID, computed, inject, input, signal } from '@angular/core';
@Component({
selector: 'app-counter-to',
templateUrl: './counter-to.component.html',
styleUrl: './counter-to.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CounterToComponent {
readonly startsAt = input<string | null | undefined>(null);
private readonly now = signal(Date.now());
protected readonly countdown = computed(() => {
const startsAt = this.startsAt();
const instant = startsAt ? Date.parse(startsAt) : NaN;
if (!Number.isFinite(instant)) return null;
const remaining = Math.max(0, instant - this.now());
const seconds = Math.floor(remaining / 1000);
return {
finished: remaining === 0,
days: Math.floor(seconds / 86400),
hours: Math.floor((seconds % 86400) / 3600),
minutes: Math.floor((seconds % 3600) / 60),
seconds: seconds % 60,
};
});
constructor() {
if (isPlatformBrowser(inject(PLATFORM_ID))) {
const timer = setInterval(() => this.now.set(Date.now()), 1000);
inject(DestroyRef).onDestroy(() => clearInterval(timer));
}
}
}

View File

@@ -5,10 +5,10 @@
.location-map {
width: 100%;
min-height: 22rem;
min-height: var(--location-map-min-height, 22rem);
overflow: hidden;
background: #e9ecef;
border-radius: 0.75rem;
border-radius: var(--location-map-border-radius, 0.75rem);
}
:host ::ng-deep .leaflet-popup-content {
@@ -27,6 +27,6 @@
@media (max-width: 575.98px) {
.location-map {
min-height: 18rem;
min-height: var(--location-map-min-height, 18rem);
}
}