feat: add immersive store header with carousel and Smooch font

- Implemented a new immersive store header component with a background image, logo, hero content, and a continuous image carousel.
- Integrated the Smooch font for enhanced typography in the header.
- Updated the store layout to support the new immersive header type.
- Added necessary styles and templates for the immersive header and carousel components.
- Enhanced unit tests to cover the new immersive header functionality.
This commit is contained in:
2026-09-18 09:11:09 -03:00
parent 6927da6342
commit 7f4aa81d4b
19 changed files with 849 additions and 12 deletions

View File

@@ -1,3 +1,11 @@
@font-face {
font-family: 'Smooch';
src: url('/fonts/Smooch/Smooch-Regular.ttf') format('truetype');
font-style: normal;
font-weight: 400;
font-display: swap;
}
@font-face {
font-family: "Gotham";
src: url("/fonts/Gotham/Gotham Thin.otf") format("opentype");

View File

@@ -1,8 +1,9 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter, Router, UrlTree } from '@angular/router';
import { provideRouter, Router, UrlSerializer, UrlTree } from '@angular/router';
import { Tenant } from '../services/tenant.interface';
import { TenantService } from '../services/tenant.service';
import { TenantUrlSerializer } from '../services/tenant-url.serializer';
import { hasMenuGuard } from './menu.guard';
const tenant: Tenant = {
@@ -36,9 +37,10 @@ function runGuard(menuCode: string, url: string, currentTenant: Tenant | null) {
TestBed.configureTestingModule({
providers: [
provideRouter([]),
{ provide: UrlSerializer, useClass: TenantUrlSerializer },
{
provide: TenantService,
useValue: { tenant: () => currentTenant },
useValue: { tenant: () => currentTenant, getTenant: () => currentTenant },
},
],
});
@@ -73,4 +75,16 @@ describe('hasMenuGuard', () => {
expect(runGuard('index', '/', tenantWithoutMenus)).toBe(true);
});
it('allows a tenant base path as the store root when its menu is missing', () => {
const tenantWithoutMenus = { ...tenant, base_path: '/onticket', menues: [] };
expect(runGuard('index', '/onticket', tenantWithoutMenus)).toBe(true);
});
it('allows a tenant base path with query parameters as the store root', () => {
const tenantWithoutMenus = { ...tenant, base_path: '/onticket', menues: [] };
expect(runGuard('index', '/onticket?ref=home', tenantWithoutMenus)).toBe(true);
});
});

View File

@@ -1,5 +1,5 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { CanActivateFn, PRIMARY_OUTLET, Router } from '@angular/router';
import { findMenu } from '../services/menu.utils';
import { TenantService } from '../services/tenant.service';
@@ -16,7 +16,9 @@ export const hasMenuGuard = (menuCode: string): CanActivateFn => {
const hasMenu = findMenu(tenant.menues ?? [], menuCode) !== undefined;
if (hasMenu || state.url === '/') {
const primarySegments = router.parseUrl(state.url).root.children[PRIMARY_OUTLET]?.segments ?? [];
if (hasMenu || primarySegments.length === 0) {
return true;
}

View File

@@ -0,0 +1,108 @@
<header
class="immersive-header"
[class.immersive-header--compact]="!showHero()"
[class.immersive-header--has-image]="showHero() && !!backgroundImageUrl()"
>
<div
class="immersive-header__backdrop"
[style.background-image]="backgroundImageUrl() ? 'url(' + backgroundImageUrl() + ')' : null"
>
@if (showHero() && backgroundImageUrl(); as imageUrl) {
<img class="immersive-header__backdrop-size" [src]="imageUrl" alt="" aria-hidden="true" />
}
</div>
<div class="immersive-header__content">
<nav class="immersive-header__nav" 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" />
}
</a>
<div class="immersive-header__actions">
@if (displaySearchBar()) {
<form class="immersive-header__search" role="search" (submit)="submitSearch($event)">
<label class="visually-hidden" for="immersive-search-input">Buscar productos</label>
<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)"
/>
<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
>
}
</form>
}
@if (ticketsMenu(); as menu) {
<button class="immersive-header__tickets" type="button" (click)="ticketsClick.emit()">
{{ menu.label }}
</button>
}
@if (displayCart()) {
<app-cart-icon
[quantity]="cartDisabled() ? null : cartQuantity()"
[disabled]="cartDisabled()"
[ariaLabel]="
cartDisabled() ? 'Carrito no disponible durante el checkout' : 'Carrito de compras'
"
(click)="onCartClick()"
/>
}
<div class="immersive-header__account">
<app-icon-button
variant="user"
ariaLabel="Mi cuenta"
ariaControls="immersive-user-dropdown"
[ariaExpanded]="isUserDropdownOpen()"
(clicked)="toggleUserDropdown()"
/>
@if (isUserDropdownOpen()) {
<app-user-dropdown
id="immersive-user-dropdown"
[user]="user()"
[accountNavigationMenus]="accountNavigationMenus()"
(logoutClick)="onLogoutClick()"
/>
}
</div>
</div>
</nav>
@if (checkoutRemainingTime(); as remainingTime) {
<div class="immersive-header__timer" role="timer" aria-label="Tiempo restante de compra">
Tiempo restante de compra: <strong>{{ remainingTime }}</strong>
</div>
}
@if (showHero()) {
@if (heroContent(); as content) {
<div class="immersive-header__copy">
<p class="immersive-header__eyebrow">{{ content.eyebrow }}</p>
<h1 class="immersive-header__title">{{ content.title }}</h1>
<p class="immersive-header__description">{{ content.description }}</p>
</div>
}
@if (carouselImages().length) {
<app-continuous-image-carousel
[images]="carouselImages()"
ariaLabel="Imágenes del evento"
/>
}
}
</div>
</header>

View File

@@ -0,0 +1,236 @@
:host {
display: block;
}
.immersive-header {
position: relative;
display: grid;
isolation: isolate;
min-height: 26rem;
color: #fff;
background: #f5f5f5;
}
.immersive-header--has-image {
min-height: 0;
}
.immersive-header__content {
grid-area: 1 / 1;
position: relative;
z-index: 1;
padding-top: clamp(1.75rem, 4vw, 2.5rem);
padding-bottom: 0.5rem;
}
.immersive-header__backdrop {
grid-area: 1 / 1;
position: relative;
z-index: 0;
background-color: #101010;
background-position: center 42%;
background-size: cover;
}
.immersive-header__backdrop-size {
display: block;
width: 100%;
height: auto;
visibility: hidden;
}
.immersive-header__backdrop::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
180deg,
rgba(0, 0, 0, 0.48) 0%,
rgba(0, 0, 0, 0.4) 47%,
rgba(245, 245, 245, 0.88) 100%
);
}
.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;
gap: 1.5rem;
min-height: 2.25rem;
}
.immersive-header__brand {
display: inline-flex;
flex: none;
align-items: center;
}
.immersive-header__brand img {
display: block;
width: clamp(105px, 13vw, 145px);
max-height: 2.25rem;
object-fit: contain;
}
.immersive-header__actions {
display: flex;
align-items: center;
gap: clamp(0.35rem, 1vw, 1rem);
}
.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;
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);
}
.immersive-header__account {
position: relative;
display: flex;
}
.immersive-header__account app-user-dropdown {
position: absolute;
top: 100%;
right: 0;
z-index: 20;
color: #222;
}
:host ::ng-deep .immersive-header__actions .icon-btn,
:host ::ng-deep .immersive-header__actions .cart-icon {
color: var(--tenant-primary, #ff7006);
}
.immersive-header__copy {
position: relative;
z-index: 1;
margin: clamp(2.1rem, 5vw, 3.25rem) auto 0;
padding-inline: 1rem;
text-align: center;
}
.immersive-header__eyebrow,
.immersive-header__title {
font-family: 'Smooch', cursive;
font-weight: 400;
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.55);
}
.immersive-header__eyebrow {
margin: 0;
font-size: 50px;
line-height: 1;
}
.immersive-header__title {
margin: -0.15rem 0 0;
font-size: 140px;
line-height: 1.05;
}
.immersive-header__description {
margin: 1.15rem 0 0;
font-size: clamp(0.75rem, 1.4vw, 0.95rem);
text-shadow: 0 1px 5px rgba(0, 0, 0, 0.6);
}
.immersive-header__timer {
position: relative;
z-index: 1;
width: min(100% - 2rem, 1120px);
margin: 0.75rem auto 0;
text-align: right;
font-size: 0.75rem;
}
.immersive-header app-continuous-image-carousel {
display: block;
position: relative;
z-index: 1;
margin-top: clamp(3.25rem, 7vw, 5rem);
}
.immersive-header--compact {
min-height: 0;
padding-bottom: 1.5rem;
background: #171717;
}
.immersive-header--compact .immersive-header__backdrop {
position: absolute;
inset: 0;
}
.immersive-header--compact .immersive-header__backdrop::after {
background: rgba(0, 0, 0, 0.65);
}
@media (max-width: 600px) {
.immersive-header__nav {
flex-wrap: wrap;
gap: 0.5rem;
}
.immersive-header__actions {
flex: 1;
justify-content: flex-end;
}
.immersive-header__search {
width: clamp(105px, 38vw, 180px);
}
.immersive-header__copy {
margin-top: 3rem;
}
.immersive-header__eyebrow {
font-size: clamp(2rem, 8vw, 50px);
}
.immersive-header__title {
font-size: clamp(3rem, 12vw, 5rem);
}
}
@media (prefers-reduced-motion: reduce) {
.immersive-header__tickets {
transition: none;
}
}

View File

@@ -0,0 +1,108 @@
import {
Component,
ElementRef,
HostListener,
computed,
inject,
input,
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 { CartIconComponent } from '../../../../shared/components/cart-icon/cart-icon.component';
import { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.component';
import { ContinuousImageCarouselComponent } from '../../../../shared/components/continuous-image-carousel/continuous-image-carousel.component';
import { UserDropdownComponent } from './user-dropdown/user-dropdown.component';
@Component({
selector: 'app-immersive-store-header',
imports: [
RouterLink,
ReactiveFormsModule,
CartIconComponent,
IconButtonComponent,
ContinuousImageCarouselComponent,
UserDropdownComponent,
],
templateUrl: './immersive-store-header.component.html',
styleUrl: './immersive-store-header.component.scss',
})
export class ImmersiveStoreHeaderComponent {
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
readonly logoUrl = input<string | null>(null);
readonly backgroundImageUrl = input<string | null>(null);
readonly heroContent = input<WebsiteExtras['immersiveHero']>(undefined);
readonly carouselImages = input<readonly string[]>([]);
readonly showHero = input(true);
readonly cartQuantity = input(0);
readonly user = input<AuthUser | null>(null);
readonly isAuthenticated = input(false);
readonly accountNavigationMenus = input<readonly Menu[]>([]);
readonly ticketsMenu = input<Menu | null>(null);
readonly displaySearchBar = input(true);
readonly displayCart = input(true);
readonly cartDisabled = input(false);
readonly checkoutRemainingSeconds = input<number | null>(null);
readonly cartClick = output<void>();
readonly ticketsClick = output<void>();
readonly loginClick = output<void>();
readonly logoutClick = output<void>();
readonly searchSubmit = output<string>();
protected readonly searchControl = new FormControl('', { nonNullable: true });
protected readonly showSearchError = signal(false);
protected readonly isUserDropdownOpen = signal(false);
protected readonly checkoutRemainingTime = computed(() => {
const seconds = this.checkoutRemainingSeconds();
if (seconds === null) return null;
return `${Math.floor(seconds / 60)
.toString()
.padStart(2, '0')}:${(seconds % 60).toString().padStart(2, '0')}`;
});
protected submitSearch(event?: Event): void {
event?.preventDefault();
const term = this.searchControl.value.trim();
if (term.length < 3) {
this.showSearchError.set(true);
return;
}
this.showSearchError.set(false);
this.searchSubmit.emit(term);
}
protected toggleUserDropdown(): void {
if (!this.isAuthenticated()) {
this.loginClick.emit();
return;
}
this.isUserDropdownOpen.update((open) => !open);
}
protected onCartClick(): void {
if (!this.cartDisabled()) this.cartClick.emit();
}
protected onLogoutClick(): void {
this.isUserDropdownOpen.set(false);
this.logoutClick.emit();
}
@HostListener('document:click', ['$event'])
protected onDocumentClick(event: MouseEvent): void {
if (event.target instanceof Node && !this.host.nativeElement.contains(event.target)) {
this.isUserDropdownOpen.set(false);
}
}
@HostListener('document:keydown.escape')
protected onEscapeKey(): void {
this.isUserDropdownOpen.set(false);
}
}

View File

@@ -1,7 +1,26 @@
@switch (headerType()) {
@case ('immersive') {
<!-- Use standard until the immersive header is implemented. -->
<ng-container *ngTemplateOutlet="standardHeader" />
<app-immersive-store-header
[logoUrl]="logoUrl()"
[backgroundImageUrl]="backgroundImageUrl()"
[heroContent]="heroContent()"
[carouselImages]="carouselImages()"
[showHero]="showHero()"
[cartQuantity]="cartQuantity()"
[user]="user()"
[isAuthenticated]="isAuthenticated()"
[accountNavigationMenus]="accountNavigationMenus()"
[ticketsMenu]="ticketsMenu()"
[displaySearchBar]="displaySeachBar()"
[displayCart]="displayCart()"
[cartDisabled]="cartDisabled()"
[checkoutRemainingSeconds]="checkoutRemainingSeconds()"
(cartClick)="cartClick.emit()"
(ticketsClick)="ticketsClick.emit()"
(loginClick)="loginClick.emit()"
(logoutClick)="logoutClick.emit()"
(searchSubmit)="searchSubmit.emit($event)"
/>
}
@default {
<ng-container *ngTemplateOutlet="standardHeader" />

View File

@@ -1,20 +1,24 @@
import { NgTemplateOutlet } from '@angular/common';
import { Component, input, output } from '@angular/core';
import { AuthUser } from '../../../services/auth/auth.interfaces';
import { Category, Menu } from '../../../services/tenant.interface';
import { Category, Menu, WebsiteExtras } from '../../../services/tenant.interface';
import { ImmersiveStoreHeaderComponent } from './immersive-store-header.component';
import { StandardStoreHeaderComponent } from './standard-store-header.component';
export type StoreHeaderType = 'standard' | 'immersive';
@Component({
selector: 'app-store-header',
imports: [NgTemplateOutlet, StandardStoreHeaderComponent],
imports: [NgTemplateOutlet, StandardStoreHeaderComponent, ImmersiveStoreHeaderComponent],
templateUrl: './store-header.component.html',
})
export class StoreHeaderComponent {
readonly headerType = input<StoreHeaderType>('standard');
readonly logoUrl = input<string | null>(null);
readonly backgroundImageUrl = input<string | null>(null);
readonly heroContent = input<WebsiteExtras['immersiveHero']>(undefined);
readonly carouselImages = input<readonly string[]>([]);
readonly showHero = input(true);
readonly cartQuantity = input<number>(0);
readonly user = input<AuthUser | null>(null);
readonly isAuthenticated = input<boolean>(false);

View File

@@ -2,6 +2,9 @@
<app-store-header
ngSkipHydration
[headerType]="tenant()?.header_type ?? 'standard'"
[heroContent]="tenant()?.extras?.immersiveHero"
[carouselImages]="tenant()?.extras?.heroCarousel ?? []"
[showHero]="isHomeRoute()"
[cartQuantity]="cartQuantity()"
[logoUrl]="tenant()?.header_logo ?? null"
[backgroundImageUrl]="tenant()?.header_bg_image ?? null"

View File

@@ -248,7 +248,7 @@ describe('StoreLayoutComponent', () => {
expect(compiled.querySelector('app-store-footer .store-layout__footer')).not.toBeNull();
});
it('keeps the standard header visible for both configured header types', () => {
it('renders the immersive header with configured copy and carousel images', () => {
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
@@ -257,12 +257,34 @@ describe('StoreLayoutComponent', () => {
expect(header.componentInstance.headerType()).toBe('standard');
expect(element.querySelector('app-standard-store-header')).not.toBeNull();
tenantState.set({ ...tenant, header_type: 'immersive' });
tenantState.set({
...tenant,
header_type: 'immersive',
extras: {
immersiveHero: {
eyebrow: 'Encendé tu',
title: 'experiencia',
description: 'Reservá tu entrada',
},
heroCarousel: ['/first.jpg', '/second.jpg'],
},
});
fixture.detectChanges();
expect(header.componentInstance.headerType()).toBe('immersive');
expect(element.querySelector('app-standard-store-header')).not.toBeNull();
expect(element.querySelector('[data-testid="store-header-brand-slot"]')).not.toBeNull();
expect(element.querySelector('app-standard-store-header')).toBeNull();
expect(element.querySelector('app-immersive-store-header')).not.toBeNull();
expect(element.querySelector('.immersive-header__eyebrow')?.textContent).toContain(
'Encendé tu',
);
expect(element.querySelector('.immersive-header__title')?.textContent).toContain('experiencia');
expect(element.querySelector('.immersive-header__description')?.textContent).toContain(
'Reservá tu entrada',
);
expect(element.querySelectorAll('.continuous-carousel__set')).toHaveLength(2);
expect(element.querySelector('.continuous-carousel__image')?.getAttribute('src')).toBe(
'/first.jpg',
);
expect(element.querySelector('app-cart-icon')).not.toBeNull();
});

View File

@@ -68,6 +68,7 @@ export class StoreLayoutComponent implements OnInit {
protected readonly isCartOpen = signal(false);
protected readonly isCheckoutRoute = signal(this.isCheckoutUrl(this.router.url));
protected readonly isHomeRoute = signal(this.isHomeUrl(this.router.url));
protected readonly checkoutRemainingSeconds = this.checkoutCountdownService.remainingSeconds;
protected readonly isCreatingPurchase = signal(false);
protected readonly displayCart = computed(() => this.tenant()?.display_cart ?? true);
@@ -210,6 +211,7 @@ export class StoreLayoutComponent implements OnInit {
.subscribe((event) => {
const isCheckoutRoute = this.isCheckoutUrl(event.urlAfterRedirects);
this.isCheckoutRoute.set(isCheckoutRoute);
this.isHomeRoute.set(this.isHomeUrl(event.urlAfterRedirects));
if (isCheckoutRoute) {
this.isCartOpen.set(false);
@@ -233,6 +235,11 @@ export class StoreLayoutComponent implements OnInit {
return primarySegments[0]?.path === 'checkout';
}
private isHomeUrl(url: string): boolean {
const primarySegments = this.router.parseUrl(url).root.children[PRIMARY_OUTLET]?.segments ?? [];
return primarySegments.length === 0;
}
protected onLoginClick(): void {
void this.router.navigate(['/login']);
}

View File

@@ -72,6 +72,11 @@ export interface TenantEvent {
export interface WebsiteExtras {
carousel?: string[];
heroCarousel?: string[];
immersiveHero?: {
eyebrow: string;
title: string;
description: string;
};
heroConfig?: HeroConfig | null;
additionalInfoConfig?: AdditionalInfoConfig | null;
[extraName: string]: unknown;

View File

@@ -0,0 +1,18 @@
@if (trackImages().length) {
<div class="continuous-carousel" role="region" [attr.aria-label]="ariaLabel()">
<div class="continuous-carousel__track" [style.--duration]="durationSeconds() + 's'">
@for (set of [0, 1]; track set) {
<div class="continuous-carousel__set" [attr.aria-hidden]="set === 1 ? 'true' : null">
@for (image of trackImages(); track $index) {
<img
class="continuous-carousel__image"
[src]="image"
alt=""
[loading]="set === 0 && $index < 5 ? 'eager' : 'lazy'"
/>
}
</div>
}
</div>
</div>
}

View File

@@ -0,0 +1,71 @@
:host {
display: block;
width: 100%;
}
.continuous-carousel {
--gap: 1rem;
--card-width: calc((100vw - 4 * var(--gap)) / 5);
width: 100%;
overflow: hidden;
}
.continuous-carousel__track {
display: flex;
width: max-content;
animation: continuous-carousel-scroll var(--duration) linear infinite;
}
.continuous-carousel__set {
display: flex;
flex: none;
gap: var(--gap);
padding-right: var(--gap);
}
.continuous-carousel__image {
display: block;
flex: none;
width: var(--card-width);
aspect-ratio: 1.45;
object-fit: cover;
border-radius: 2px;
background: #e5e5e5;
}
.continuous-carousel:hover .continuous-carousel__track,
.continuous-carousel:focus-within .continuous-carousel__track {
animation-play-state: paused;
}
@keyframes continuous-carousel-scroll {
to {
transform: translateX(-50%);
}
}
@media (max-width: 767px) {
.continuous-carousel {
--gap: 0.75rem;
--card-width: calc((100vw - 2 * var(--gap)) / 3);
}
}
@media (max-width: 480px) {
.continuous-carousel {
--card-width: calc((100vw - var(--gap)) / 2);
}
}
@media (prefers-reduced-motion: reduce) {
.continuous-carousel {
overflow-x: auto;
}
.continuous-carousel__track {
animation: none;
}
.continuous-carousel__set[aria-hidden='true'] {
display: none;
}
}

View File

@@ -0,0 +1,24 @@
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
@Component({
selector: 'app-continuous-image-carousel',
templateUrl: './continuous-image-carousel.component.html',
styleUrl: './continuous-image-carousel.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ContinuousImageCarouselComponent {
readonly images = input.required<readonly string[]>();
readonly ariaLabel = input('Imágenes destacadas');
// One full set must cover the viewport before the duplicate set takes over.
protected readonly trackImages = computed(() => {
const images = this.images().filter(Boolean);
if (images.length === 0) return [];
return Array.from(
{ length: Math.max(images.length, 5) },
(_, index) => images[index % images.length],
);
});
protected readonly durationSeconds = computed(() => Math.max(this.trackImages().length * 4, 16));
}