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:
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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[]>;
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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%; }
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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; }
|
||||
37
src/app/shared/components/counter-to/counter-to.component.ts
Normal file
37
src/app/shared/components/counter-to/counter-to.component.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user