diff --git a/public/fonts/KaushanScript/KaushanScript-Regular.ttf b/public/fonts/KaushanScript/KaushanScript-Regular.ttf new file mode 100644 index 0000000..e69e4ac Binary files /dev/null and b/public/fonts/KaushanScript/KaushanScript-Regular.ttf differ diff --git a/public/fonts/KaushanScript/OFL.txt b/public/fonts/KaushanScript/OFL.txt new file mode 100644 index 0000000..87a559e --- /dev/null +++ b/public/fonts/KaushanScript/OFL.txt @@ -0,0 +1,95 @@ +Copyright (c) 2011, Pablo Impallari (www.impallari.com|impallari@gmail.com), +Copyright (c) 2011, Igino Marini. (www.ikern.com|mail@iginomarini.com), +with Reserved Font Name Kaushan Script. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/public/fonts/Smooch/OFL.txt b/public/fonts/Smooch/OFL.txt new file mode 100644 index 0000000..ddee91b --- /dev/null +++ b/public/fonts/Smooch/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2017 The Smooch Project Authors (https://github.com/googlefonts/smooch) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/public/fonts/Smooch/Smooch-Regular.ttf b/public/fonts/Smooch/Smooch-Regular.ttf new file mode 100644 index 0000000..17afcde Binary files /dev/null and b/public/fonts/Smooch/Smooch-Regular.ttf differ diff --git a/src/_fonts.scss b/src/_fonts.scss index 38ec4ad..7aa33d8 100644 --- a/src/_fonts.scss +++ b/src/_fonts.scss @@ -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"); @@ -30,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"); diff --git a/src/app/app.config.spec.ts b/src/app/app.config.spec.ts index 300a5eb..29fff20 100644 --- a/src/app/app.config.spec.ts +++ b/src/app/app.config.spec.ts @@ -14,7 +14,6 @@ describe('isStoreCatalogRequest', () => { }); it.each([ - ['POST', 'http://localhost:8000/api/tenants/demo/catalog-items'], ['GET', 'http://localhost:8000/api/tenants/demo/productos'], ['GET', 'http://localhost:8000/api/tenants/demo/categories/7'], ['GET', 'http://localhost:8000/api/tenants/demo/catalog-items/42/variant-options'], diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 49ee050..a0989d2 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -17,7 +17,7 @@ import { TenantUrlSerializer } from './core/services/tenant-url.serializer'; export function isStoreCatalogRequest(request: HttpRequest): boolean { return ( request.method === 'GET' && - /\/api\/tenants\/[^/?#]+\/(?:catalog(?:\/featured-groups\/\d+\/items)?|catalog-items(?:\/\d+)?)\/?(?:[?#]|$)/.test( + /\/api\/tenants\/[^/?#]+\/(?:catalog(?:\/featured-groups\/\d+\/items)?|catalog-items(?:\/\d+)?|events(?:\/\d+)?)\/?(?:[?#]|$)/.test( request.urlWithParams, ) ); diff --git a/src/app/app.spec.ts b/src/app/app.spec.ts index ce72eea..1b15138 100644 --- a/src/app/app.spec.ts +++ b/src/app/app.spec.ts @@ -90,6 +90,11 @@ describe('App', () => { expect( document.head.querySelector('link[rel~="icon"]')?.getAttribute('href'), ).toBe(tenant.favicon); + expect( + document.head + .querySelector('link[rel~="icon"]') + ?.getAttribute('fetchpriority'), + ).toBe('high'); }); it('renders the tenant not found screen when the tenant is missing', async () => { @@ -114,6 +119,10 @@ describe('App', () => { expect( document.head.querySelector('link[rel~="icon"]')?.getAttribute('href'), ).toBe("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'/%3E"); + expect( + document.head + .querySelector('link[rel~="icon"]') + ?.getAttribute('fetchpriority'), + ).toBe('high'); }); - }); diff --git a/src/app/app.ts b/src/app/app.ts index 268c29d..fcb49a6 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -78,6 +78,7 @@ export class App { } favicon.setAttribute('href', faviconHref); + favicon.setAttribute('fetchpriority', 'high'); }); } diff --git a/src/app/core/guards/menu.guard.spec.ts b/src/app/core/guards/menu.guard.spec.ts index 3bf9479..f0f898e 100644 --- a/src/app/core/guards/menu.guard.spec.ts +++ b/src/app/core/guards/menu.guard.spec.ts @@ -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); + }); }); diff --git a/src/app/core/guards/menu.guard.ts b/src/app/core/guards/menu.guard.ts index 5dffedb..5f57308 100644 --- a/src/app/core/guards/menu.guard.ts +++ b/src/app/core/guards/menu.guard.ts @@ -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; } diff --git a/src/app/core/layout/store-layout/store-footer/store-footer.component.scss b/src/app/core/layout/store-layout/store-footer/store-footer.component.scss index e2cb705..058bac4 100644 --- a/src/app/core/layout/store-layout/store-footer/store-footer.component.scss +++ b/src/app/core/layout/store-layout/store-footer/store-footer.component.scss @@ -22,8 +22,9 @@ .store-layout__brand-logo { display: block; - width: 100%; - height: 100%; + width: auto; + height: auto; + max-width: 130px; max-height: 65px; object-fit: contain; object-position: left center; diff --git a/src/app/core/layout/store-layout/store-header/immersive-store-header.component.html b/src/app/core/layout/store-layout/store-header/immersive-store-header.component.html new file mode 100644 index 0000000..cf44879 --- /dev/null +++ b/src/app/core/layout/store-layout/store-header/immersive-store-header.component.html @@ -0,0 +1,151 @@ +
+
+ @if (showHero() && backgroundImageUrl(); as backgroundImage) { + + } +
+ +
+ + + @if (checkoutRemainingTime(); as remainingTime) { +
+ Tiempo restante de compra: {{ remainingTime }} +
+ } + + @if (showHero()) { + @if (heroContent(); as content) { +
+

{{ content.eyebrow }}

+

{{ content.title }}

+

{{ content.description }}

+
+ } + + @if (carouselImages().length) { + + } + } + + +
+
diff --git a/src/app/core/layout/store-layout/store-header/immersive-store-header.component.scss b/src/app/core/layout/store-layout/store-header/immersive-store-header.component.scss new file mode 100644 index 0000000..981ffea --- /dev/null +++ b/src/app/core/layout/store-layout/store-header/immersive-store-header.component.scss @@ -0,0 +1,456 @@ +:host { + display: block; +} + +.immersive-header { + position: relative; + display: grid; + grid-template-columns: minmax(0, 1fr); + isolation: isolate; + color: #fff; + background: #f5f5f5; +} + +.immersive-header__content { + grid-area: 1 / 1; + position: relative; + z-index: 1; + min-width: 0; + padding-top: clamp(1.75rem, 4vw, 2.5rem); + padding-bottom: clamp(1rem, 2vw, 1.5rem); +} + +.immersive-header__backdrop { + grid-area: 1 / 1; + position: relative; + z-index: 0; + overflow: hidden; + background-color: #101010; +} + +.immersive-header__backdrop-image { + position: absolute; + inset: 0; + z-index: 0; + display: block; + width: 100%; + height: 100%; + object-fit: cover; + object-position: center 42%; +} + +.immersive-header__backdrop::before, +.immersive-header__backdrop::after { + content: ''; + position: absolute; + inset: 0; + z-index: 1; + pointer-events: none; +} + +.immersive-header__backdrop::before { + background: rgba(0, 0, 0, 0.7); +} + +.immersive-header__backdrop::after { + background: linear-gradient(180deg, rgba(0, 0, 0, 0.48) 0%, rgba(0, 0, 0, 0.4) 47%, #f5f5f5 100%); +} + +.immersive-header__nav { + position: relative; + // Keep the whole navigation stacking context above the hero copy and carousel. + // Otherwise those later siblings can sit on top of the dropdown and capture + // pointer events even though the dropdown itself has a high z-index. + z-index: 10; + 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; + width: clamp(115px, 17vw, 205px); +} +.immersive-header__search-error { + position: absolute; + top: calc(100% + 0.25rem); + right: 0; + width: max-content; + color: var(--tenant-danger, #dc3545); + font-size: 0.7rem; +} + +:host ::ng-deep .immersive-header__tickets { + width: auto; +} +.immersive-header__account { + position: relative; + // The nav becomes display: contents on mobile, so it cannot provide the + // stacking context there. Keep the dropdown trigger above following rows. + z-index: 10; + display: flex; +} +: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: 0.7; +} +.immersive-header__description { + margin: 30px 0 0; + font-family: 'Gotham Book', sans-serif; + font-size: 18px; + font-weight: 300; + text-shadow: 0 1px 5px rgba(0, 0, 0, 0.6); +} +.immersive-header__timer { + position: relative; + z-index: 1; + margin-top: 0.75rem; + 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: 115px; + color: #666666; + background: transparent; + + .immersive-header__content { + padding: 35px 0 36px; + } + + .immersive-header__nav { + flex-wrap: nowrap; + gap: 0.75rem; + min-height: 44px; + } + + .immersive-header__brand img { + width: clamp(92px, 24vw, 145px); + } + + .immersive-header__actions { + flex: 0 1 auto; + gap: 0.375rem; + } +} + +.immersive-header__search-toggle { + display: none; + flex: none; + align-items: center; + justify-content: center; + width: 32px; + height: 44px; + padding: 0; + border: 0; + border-radius: 4px; + background: transparent; + color: #888; + font-size: 20px; + + &:focus-visible { + outline: 2px solid var(--tenant-primary); + outline-offset: 2px; + } +} + +.immersive-header__guest-menu { + position: absolute; + top: calc(100% + 0.5rem); + right: 0; + min-width: 180px; + padding: 0.5rem; + border-radius: 6px; + background: #fff; + box-shadow: 0 4px 16px #0002; + + button { + width: 100%; + padding: 0.75rem; + border: 0; + background: transparent; + color: #666; + text-align: left; + } +} + +:host ::ng-deep .immersive-header--compact { + .immersive-header__tickets { + flex: none; + width: auto; + + .btn { + min-width: 120px; + white-space: nowrap; + } + } + + .immersive-header__account .icon-btn { + width: 32px; + height: 44px; + color: #888; + } +} +.immersive-header--compact .immersive-header__backdrop { + display: none; +} + +:host ::ng-deep .immersive-header--compact .immersive-header__actions .icon-btn, +:host ::ng-deep .immersive-header--compact .immersive-header__actions .cart-icon { + color: #888; +} + +@media (max-width: 359.98px) { + .immersive-header--compact .immersive-header__nav { + padding-inline: 1rem !important; + gap: 0.375rem; + } + + .immersive-header--compact .immersive-header__actions { + gap: 0.125rem; + } +} + +@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 (max-width: 767.98px) { + .immersive-header--compact { + .immersive-header__search { + position: absolute; + top: calc(100% + 0.5rem); + right: 8vw; + display: none; + width: min(320px, 84vw); + padding: 0.75rem; + border-radius: 6px; + background: #f5f5f5; + box-shadow: 0 4px 16px #0002; + } + + .immersive-header__search--open { + display: block; + } + + .immersive-header__search-error { + position: static; + display: block; + margin-top: 0.25rem; + } + + .immersive-header__search-toggle { + display: inline-flex; + } + } + + .immersive-header:not(.immersive-header--compact) { + min-height: 0; + + .immersive-header__content { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: center; + align-content: start; + padding: 1.875rem 0 1.5rem; + } + + // Keep one search form while placing it below the hero copy on mobile. + .immersive-header__nav, + .immersive-header__actions { + display: contents; + } + + .immersive-header__brand { + grid-area: 1 / 1; + margin-left: 8vw; + } + + .immersive-header__brand img { + width: clamp(105px, 34vw, 145px); + } + + app-cart-icon { + grid-area: 1 / 2; + } + + .immersive-header__account { + grid-area: 1 / 3; + margin-right: calc(8vw - 0.75rem); + } + + .immersive-header__timer { + grid-area: 2 / 1 / auto / -1; + } + + .immersive-header__copy { + grid-area: 3 / 1 / auto / -1; + margin: 1.25rem 0 0; + } + + .immersive-header__eyebrow { + font-size: clamp(18px, 5.5vw, 24px); + } + + .immersive-header__title { + font-size: clamp(40px, 12vw, 56px); + line-height: 0.7; + } + + .immersive-header__description { + margin-top: 30px; + font-size: clamp(11px, 3vw, 14px); + line-height: 1.5; + color: #666; + text-shadow: none; + } + + .immersive-header__search { + grid-area: 4 / 1 / auto / -1; + width: 74%; + max-width: 28rem; + margin: 0.5rem auto 0; + } + + .immersive-header__search-error { + right: auto; + left: 0; + width: 100%; + color: var(--tenant-danger, #dc3545); + } + + app-continuous-image-carousel { + grid-area: 6 / 1 / auto / -1; + margin-top: 1.5rem; + --carousel-card-width: 27vw; + --carousel-gap: 3.3vw; + --carousel-offset: -9vw; + } + + .immersive-header__backdrop-image { + object-position: center 42%; + } + + .immersive-header__backdrop { + // Keep the fade tied to the hero, independent of the content below it. + align-self: start; + height: 12.5rem; + // Fade the entire layer so its dark base cannot leave a subpixel seam. + mask-image: linear-gradient(#000 calc(100% - 8px), transparent 100%); + } + + .immersive-header__backdrop::after { + background: linear-gradient( + 180deg, + rgba(0, 0, 0, 0.25) 0%, + rgba(245, 245, 245, 0.1) 30%, + rgba(245, 245, 245, 0.65) 65%, + #f5f5f5 98%, + #f5f5f5 100% + ); + } + } + + :host ::ng-deep .immersive-header:not(.immersive-header--compact) { + .immersive-header__search .form-control { + min-height: 36px; + padding: 0.25rem 2.5rem 0.25rem 0.75rem; + border: 1px solid #888; + border-radius: 3px; + background: transparent; + color: #666; + font-size: 16px; + + &::placeholder { + color: transparent; + } + + &:focus-visible { + outline: 2px solid #666; + outline-offset: 2px; + } + } + + .immersive-header__search .input-search-icon { + color: #666; + right: 0.75rem; + } + + .immersive-header__account .icon-btn { + min-width: 44px; + min-height: 44px; + + i { + font-size: 18px; + } + } + } +} diff --git a/src/app/core/layout/store-layout/store-header/immersive-store-header.component.ts b/src/app/core/layout/store-layout/store-header/immersive-store-header.component.ts new file mode 100644 index 0000000..ea5e032 --- /dev/null +++ b/src/app/core/layout/store-layout/store-header/immersive-store-header.component.ts @@ -0,0 +1,131 @@ +import { + Component, + ElementRef, + HostListener, + computed, + inject, + input, + output, + signal, + afterRenderEffect, + viewChild, +} from '@angular/core'; +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'; + +@Component({ + selector: 'app-immersive-store-header', + imports: [ + RouterLink, + ButtonComponent, + CartIconComponent, + IconButtonComponent, + InputComponent, + ContinuousImageCarouselComponent, + UserDropdownComponent, + ], + templateUrl: './immersive-store-header.component.html', + styleUrl: './immersive-store-header.component.scss', +}) +export class ImmersiveStoreHeaderComponent { + private readonly host = inject>(ElementRef); + + readonly logoUrl = input(null); + readonly backgroundImageUrl = input(null); + readonly heroContent = input(undefined); + readonly carouselImages = input([]); + readonly showHero = input(true); + readonly cartQuantity = input(0); + readonly user = input(null); + readonly isAuthenticated = input(false); + readonly accountNavigationMenus = input([]); + readonly ticketsMenu = input(null); + readonly displaySearchBar = input(true); + readonly displayCart = input(true); + readonly cartDisabled = input(false); + readonly checkoutRemainingSeconds = input(null); + + readonly cartClick = output(); + readonly ticketsClick = output(); + readonly loginClick = output(); + readonly logoutClick = output(); + readonly searchSubmit = output(); + + protected searchTerm = ''; + protected readonly showSearchError = signal(false); + protected readonly isUserDropdownOpen = signal(false); + protected readonly isSearchOpen = signal(false); + private readonly searchInput = viewChild(InputComponent); + private readonly searchToggle = viewChild>('searchToggle'); + + constructor() { + afterRenderEffect(() => { + if (this.isSearchOpen()) this.searchInput()?.focus(); + }); + } + + protected toggleSearch(): void { + this.isUserDropdownOpen.set(false); + this.isSearchOpen.update((open) => !open); + } + 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.searchTerm.trim(); + if (term.length < 3) { + this.showSearchError.set(true); + return; + } + this.showSearchError.set(false); + this.isSearchOpen.set(false); + this.searchSubmit.emit(term); + } + + protected toggleUserDropdown(): void { + this.isSearchOpen.set(false); + if (this.showHero() && !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); + this.isSearchOpen.set(false); + } + } + + @HostListener('document:keydown.escape') + protected onEscapeKey(): void { + if (this.isSearchOpen()) this.searchToggle()?.nativeElement.focus(); + this.isSearchOpen.set(false); + this.isUserDropdownOpen.set(false); + } +} diff --git a/src/app/core/layout/store-layout/store-header/standard-store-header.component.html b/src/app/core/layout/store-layout/store-header/standard-store-header.component.html new file mode 100644 index 0000000..defc5b2 --- /dev/null +++ b/src/app/core/layout/store-layout/store-header/standard-store-header.component.html @@ -0,0 +1,199 @@ +
+
+
+
+ + + @if (displaySeachBar()) { +
+
+
+ +
+
+ + + @if (showSearchError()) { + + } +
+
+ } + +
+ @if (ticketsMenu(); as menu) { + + {{ menu.label }} + + } + + @if (displayCart()) { + + } + +
+ + + @if (isUserDropdownOpen()) { + + } +
+ +
+ + + @if (isMobileMenuOpen()) { +
+ + + Mi cuenta + + + @if (displayCategories()) { +
+ + @for (category of categories(); track category.id) { + + {{ category.nombre }} + + } + } +
+ } +
+
+
+
+
+ +
+
+ @if (displayCategories()) { +
+ + + +
+ } + + @if (checkoutRemainingTime(); as remainingTime) { +
+ Tiempo restante de compra: + {{ remainingTime }} +
+ } +
+
+
diff --git a/src/app/core/layout/store-layout/store-header/standard-store-header.component.scss b/src/app/core/layout/store-layout/store-header/standard-store-header.component.scss new file mode 100644 index 0000000..4707ae8 --- /dev/null +++ b/src/app/core/layout/store-layout/store-header/standard-store-header.component.scss @@ -0,0 +1,242 @@ +.store-layout__header { + color: #ffffff; + background-color: var(--tenant-header-bg); + background-repeat: no-repeat; + background-position: center; + background-size: cover; +} + +.store-layout__categories { + height: 3.5rem; +} + +.store-layout__checkout-timer { + color: var(--tenant-primary); + font-weight: 700; + white-space: nowrap; +} + +.store-layout__checkout-timer-label { + font-size: 13px; +} + +.store-layout__checkout-timer-value { + font-size: 20px; + line-height: 1; + font-variant-numeric: tabular-nums; +} + +.store-layout__brand-slot { + min-width: 150px; +} + +.store-layout__brand-column { + max-width: 12rem; +} + +.store-layout__brand-logo-shell { + position: relative; + width: min(100%, 12rem); + height: 2.75rem; +} + +.store-layout__brand-logo { + display: block; + width: 100%; + height: 100%; + object-fit: contain; +} + +.store-layout__brand-logo-skeleton { + display: block; + width: 100%; + height: 100%; + border-radius: 0.75rem; + background: linear-gradient( + 90deg, + rgba(255, 255, 255, 0.12) 0%, + rgba(255, 255, 255, 0.22) 50%, + rgba(255, 255, 255, 0.12) 100% + ); + background-size: 200% 100%; + animation: store-header-logo-skeleton 1.2s ease-in-out infinite; +} + +.store-layout__search-wrapper { + width: 100%; + min-width: 0; +} + +@media (min-width: 768px) { + .store-layout__search-wrapper { + width: min(100%, 260px); + } + + .store-layout__actions-column { + padding-left: 0; + } + + ::ng-deep .store-layout__tickets-btn-host { + flex: 0 0 clamp(8rem, 15vw, 13.5rem); + width: clamp(8rem, 15vw, 13.5rem); + } +} + +.store-layout__search { + width: 100%; +} + +.store-layout__search-error { + margin-top: 0.25rem; + font-size: 0.75rem; + line-height: 1.2; + color: var(--tenant-danger, #dc3545); +} + +.store-layout__search-input { + color: #666666; + border-color: var(--border-color); + background-color: rgba(255, 255, 255, 0.96); +} + +.store-layout__search-input::placeholder { + color: #666666; +} + +.store-layout__search-input:focus { + border-color: var(--border-color); + box-shadow: none; + outline: none; +} + +.store-layout__search-button { + color: #a0a0a0; + border-color: var(--border-color); + background-color: rgba(255, 255, 255, 0.96); +} + +.store-layout__search-button:hover, +.store-layout__search-button:focus-visible { + color: #8a8a8a; + border-color: var(--border-color); + background-color: #ffffff; +} + +.store-layout__action-button { + color: #a0a0a0; +} + +.store-layout__action-button:hover, +.store-layout__action-button:focus-visible { + color: #8a8a8a; +} + +.store-layout__user-menu { + position: relative; + display: inline-flex; +} + +.store-layout__category-trigger { + font-size: 0.8rem; + letter-spacing: 0.02em; + color: #a0a0a0; +} + +.store-layout__category-trigger:hover, +.store-layout__category-trigger:focus-visible { + color: #8a8a8a; +} + +.store-layout__category-menu { + position: relative; + display: inline-flex; + flex-direction: column; +} + +.store-layout__category-menu app-category-dropdown { + position: absolute; + z-index: 1050; + top: 100%; + left: 0; +} + +@keyframes store-header-logo-skeleton { + from { + background-position: 200% 0; + } + + to { + background-position: -200% 0; + } +} + +@media (max-width: 767.98px) { + .store-layout__brand-slot { + min-width: 0; + 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: var(--border-color); + opacity: 1; + margin: 0 -1rem; + } +} + +@media (max-width: 399.98px) { + .store-layout__brand-column { + flex: 1 1 0; + width: auto; + min-width: 0; + max-width: none; + padding-right: 0.25rem; + } + + .store-layout__brand-slot { + width: 100%; + max-width: 6.5rem; + } + + .store-layout__actions-column { + flex: 1 1 auto; + width: auto; + min-width: 0; + margin-left: 0 !important; + padding-left: 0.25rem; + gap: 0.25rem !important; + } + + ::ng-deep .store-layout__tickets-btn-host .btn { + padding-inline: 0.75rem; + font-size: 0.875rem; + } +} + +.store-layout__mobile-dropdown-menu { + top: 100%; + min-width: 220px; + z-index: 1050; +} + +.store-layout__mobile-dropdown-item { + cursor: pointer; + color: #8a8a8a; + + &:hover, + &:focus-visible { + color: #666666; + } +} diff --git a/src/app/core/layout/store-layout/store-header/standard-store-header.component.ts b/src/app/core/layout/store-layout/store-header/standard-store-header.component.ts new file mode 100644 index 0000000..a2d6ec3 --- /dev/null +++ b/src/app/core/layout/store-layout/store-header/standard-store-header.component.ts @@ -0,0 +1,172 @@ +import { + Component, + computed, + ElementRef, + HostListener, + inject, + input, + output, + signal, +} from '@angular/core'; +import { Router, RouterLink } from '@angular/router'; +import { AuthUser } from '../../../services/auth/auth.interfaces'; +import { CartIconComponent } from '../../../../shared/components/cart-icon/cart-icon.component'; +import { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.component'; +import { UserDropdownComponent } from './user-dropdown/user-dropdown.component'; +import { Menu } from '../../../services/tenant.interface'; +import { ButtonComponent } from '../../../../shared/components/button/button.component'; +import { ReactiveFormsModule, FormControl } from '@angular/forms'; +import { Category } from '../../../services/tenant.interface'; +import { CategoryDropdownComponent } from './category-dropdown/category-dropdown.component'; + +@Component({ + selector: 'app-standard-store-header', + imports: [ + ButtonComponent, + CartIconComponent, + IconButtonComponent, + RouterLink, + ReactiveFormsModule, + UserDropdownComponent, + CategoryDropdownComponent, + ], + templateUrl: './standard-store-header.component.html', + styleUrl: './standard-store-header.component.scss', +}) +export class StandardStoreHeaderComponent { + private readonly elementRef = inject(ElementRef); + private readonly router = inject(Router); + + readonly logoUrl = input(null); + readonly backgroundImageUrl = input(null); + readonly cartQuantity = input(0); + readonly user = input(null); + readonly isAuthenticated = input(false); + readonly accountNavigationMenus = input([]); + readonly ticketsMenu = input(null); + readonly categories = input([]); + readonly displayCategories = input(true); + readonly displaySeachBar = input(true); + readonly displayCart = input(true); + readonly cartDisabled = input(false); + readonly checkoutRemainingSeconds = input(null); + readonly cartClick = output(); + readonly ticketsClick = output(); + readonly loginClick = output(); + readonly logoutClick = output(); + readonly searchSubmit = output(); + readonly categorySelect = output(); + + protected readonly isUserDropdownOpen = signal(false); + protected readonly isCategoryDropdownOpen = signal(false); + protected readonly isMobileMenuOpen = signal(false); + protected readonly minSearchLength = 3; + protected readonly showSearchError = signal(false); + protected readonly searchControl = new FormControl('', { nonNullable: true }); + protected readonly checkoutRemainingTime = computed(() => { + const remainingSeconds = this.checkoutRemainingSeconds(); + + if (remainingSeconds === null) { + return null; + } + + const minutes = Math.floor(remainingSeconds / 60); + const seconds = remainingSeconds % 60; + + return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; + }); + + protected onCartClick(): void { + if (this.cartDisabled()) { + return; + } + + this.cartClick.emit(); + } + + @HostListener('document:click', ['$event']) + protected onDocumentClick(event: MouseEvent): void { + if (!this.isUserDropdownOpen() && !this.isCategoryDropdownOpen() && !this.isMobileMenuOpen()) { + return; + } + + const target = event.target; + if (target instanceof Node && !this.elementRef.nativeElement.contains(target)) { + this.isUserDropdownOpen.set(false); + this.isCategoryDropdownOpen.set(false); + this.isMobileMenuOpen.set(false); + } + } + + @HostListener('document:keydown.escape') + protected onEscapeKey(): void { + this.isUserDropdownOpen.set(false); + this.isCategoryDropdownOpen.set(false); + this.isMobileMenuOpen.set(false); + } + + protected toggleUserDropdown(): void { + if (!this.isAuthenticated()) { + this.loginClick.emit(); + return; + } + + this.isUserDropdownOpen.update((isOpen) => !isOpen); + } + + protected onLoginClick(): void { + this.isUserDropdownOpen.set(false); + this.loginClick.emit(); + } + + protected onLogoutClick(): void { + this.isUserDropdownOpen.set(false); + this.logoutClick.emit(); + } + + protected submitSearch(event?: Event): void { + event?.preventDefault(); + + const term = this.searchControl.value.trim(); + + if (term.length < this.minSearchLength) { + this.showSearchError.set(true); + return; + } + + this.showSearchError.set(false); + this.searchSubmit.emit(term); + } + + protected clearSearchError(): void { + this.showSearchError.set(false); + } + + protected toggleCategoryDropdown(): void { + this.isCategoryDropdownOpen.update((isOpen) => !isOpen); + } + + protected onCategorySelect(category: Category): void { + this.isCategoryDropdownOpen.set(false); + this.isMobileMenuOpen.set(false); + this.categorySelect.emit(category); + } + + protected toggleMobileMenu(): void { + this.isMobileMenuOpen.update((isOpen) => !isOpen); + } + + protected onMobileAccountClick(): void { + this.isMobileMenuOpen.set(false); + if (!this.isAuthenticated()) { + this.loginClick.emit(); + } else { + const menus = this.accountNavigationMenus(); + if (menus.length > 0) { + this.router.navigate([menus[0].route]); + } else { + this.router.navigate(['/cuenta']); + } + } + } +} diff --git a/src/app/core/layout/store-layout/store-header/store-header.component.html b/src/app/core/layout/store-layout/store-header/store-header.component.html index b8a3115..26b42a1 100644 --- a/src/app/core/layout/store-layout/store-header/store-header.component.html +++ b/src/app/core/layout/store-layout/store-header/store-header.component.html @@ -1,192 +1,62 @@ -
-
-
-
- - - @if (displaySeachBar()) { -
-
-
- -
-
- - - @if (showSearchError()) { - - } -
-
- } - -
- @if (ticketsMenu(); as menu) { - - {{ menu.label }} - - } - - @if (displayCart()) { - - } - -
- - - @if (isUserDropdownOpen()) { - - } -
- -
- - - @if (isMobileMenuOpen()) { -
- - - Mi cuenta - - - @if (displayCategories()) { -
- - @for (category of categories(); track category.id) { - - {{ category.nombre }} - - } - } -
- } -
-
-
-
-
- -
-
- @if (displayCategories()) { -
- - - + @if (showHero() && displayCategories() && categories().length) { +
+ } + + } + @default { + + } +} - @if (checkoutRemainingTime(); as remainingTime) { -
- Tiempo restante de compra: - {{ remainingTime }} -
- } -
-
-
+ + + diff --git a/src/app/core/layout/store-layout/store-header/store-header.component.scss b/src/app/core/layout/store-layout/store-header/store-header.component.scss index 256dd7c..342b5e8 100644 --- a/src/app/core/layout/store-layout/store-header/store-header.component.scss +++ b/src/app/core/layout/store-layout/store-header/store-header.component.scss @@ -1,246 +1,55 @@ -.store-layout__header { - color: #ffffff; - background-color: var(--tenant-header-bg); - background-repeat: no-repeat; - background-position: center; - background-size: cover; +:host { + display: block; } -.store-layout__header-main { - border-bottom: 1px solid var(--border-color); +app-immersive-store-header { + position: relative; + z-index: 1; } -.store-layout__categories { - height: 3.5rem; +:host(.immersive-shell--compact) { + position: relative; + background-color: #f5f5f5; + background-image: none !important; + + &::before { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + background-image: var(--compact-header-background); + background-position: left top, center 42%; + background-size: 100% 100%, cover; + background-repeat: no-repeat; + mask-image: linear-gradient(#000 calc(100% - 12px), transparent calc(100% - 4px)); + } } -.store-layout__checkout-timer { - color: var(--tenant-primary); +.immersive-categories { + position: relative; + z-index: 1; + width: 100%; + margin-top: clamp(5rem, 11vw, 9rem); + padding: 0.375rem 1rem; + background: transparent; + color: #666; + font-size: 15px; font-weight: 700; - white-space: nowrap; -} - -.store-layout__checkout-timer-label { - font-size: 13px; -} - -.store-layout__checkout-timer-value { - font-size: 20px; - line-height: 1; - font-variant-numeric: tabular-nums; -} - -.store-layout__brand-slot { - min-width: 150px; -} - -.store-layout__brand-column { - max-width: 12rem; -} - -.store-layout__brand-logo-shell { - position: relative; - width: min(100%, 12rem); - height: 2.75rem; -} - -.store-layout__brand-logo { - display: block; - width: 100%; - height: 100%; - object-fit: contain; -} - -.store-layout__brand-logo-skeleton { - display: block; - width: 100%; - height: 100%; - border-radius: 0.75rem; - background: linear-gradient( - 90deg, - rgba(255, 255, 255, 0.12) 0%, - rgba(255, 255, 255, 0.22) 50%, - rgba(255, 255, 255, 0.12) 100% - ); - background-size: 200% 100%; - animation: store-header-logo-skeleton 1.2s ease-in-out infinite; -} - -.store-layout__search-wrapper { - width: 100%; - min-width: 0; -} - -@media (min-width: 768px) { - .store-layout__search-wrapper { - width: min(100%, 260px); - } - - .store-layout__actions-column { - padding-left: 0; - } - - ::ng-deep .store-layout__tickets-btn-host { - flex: 0 0 clamp(8rem, 15vw, 13.5rem); - width: clamp(8rem, 15vw, 13.5rem); - } -} - -.store-layout__search { - width: 100%; -} - -.store-layout__search-error { - margin-top: 0.25rem; - font-size: 0.75rem; - line-height: 1.2; - color: var(--tenant-danger, #dc3545); -} - -.store-layout__search-input { - color: #666666; - border-color: var(--border-color); - background-color: rgba(255, 255, 255, 0.96); -} - -.store-layout__search-input::placeholder { - color: #666666; -} - -.store-layout__search-input:focus { - border-color: var(--border-color); - box-shadow: none; - outline: none; -} - -.store-layout__search-button { - color: #a0a0a0; - border-color: var(--border-color); - background-color: rgba(255, 255, 255, 0.96); -} - -.store-layout__search-button:hover, -.store-layout__search-button:focus-visible { - color: #8a8a8a; - border-color: var(--border-color); - background-color: #ffffff; -} - -.store-layout__action-button { - color: #a0a0a0; -} - -.store-layout__action-button:hover, -.store-layout__action-button:focus-visible { - color: #8a8a8a; -} - -.store-layout__user-menu { - position: relative; - display: inline-flex; -} - -.store-layout__category-trigger { - font-size: 0.8rem; - letter-spacing: 0.02em; - color: #a0a0a0; -} - -.store-layout__category-trigger:hover, -.store-layout__category-trigger:focus-visible { - color: #8a8a8a; -} - -.store-layout__category-menu { - position: relative; - display: inline-flex; - flex-direction: column; -} - -.store-layout__category-menu app-category-dropdown { - position: absolute; - z-index: 1050; - top: 100%; - left: 0; -} - -@keyframes store-header-logo-skeleton { - from { - background-position: 200% 0; - } - - to { - background-position: -200% 0; - } + --horizontal-carousel-gap: 90px; + --horizontal-carousel-text-transform: uppercase; } @media (max-width: 767.98px) { - .store-layout__brand-slot { + .immersive-categories { + grid-area: 5 / 1 / auto / -1; min-width: 0; - 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: var(--border-color); - opacity: 1; - margin: 0 -1rem; - } -} - -@media (max-width: 399.98px) { - .store-layout__brand-column { - flex: 1 1 0; - width: auto; - min-width: 0; - max-width: none; - padding-right: 0.25rem; - } - - .store-layout__brand-slot { - width: 100%; - max-width: 6.5rem; - } - - .store-layout__actions-column { - flex: 1 1 auto; - width: auto; - min-width: 0; - margin-left: 0 !important; - padding-left: 0.25rem; - gap: 0.25rem !important; - } - - ::ng-deep .store-layout__tickets-btn-host .btn { - padding-inline: 0.75rem; - font-size: 0.875rem; - } -} - -.store-layout__mobile-dropdown-menu { - top: 100%; - min-width: 220px; - z-index: 1050; -} - -.store-layout__mobile-dropdown-item { - cursor: pointer; - color: #8a8a8a; - - &:hover, - &:focus-visible { - color: #666666; + margin-top: 1.5rem; + padding: 0 1rem; + font-size: clamp(10px, 2.8vw, 12px); + --horizontal-carousel-visible-items: 3; + --horizontal-carousel-gap: 0.5rem; + --horizontal-carousel-item-width: calc((100cqw - 2 * var(--horizontal-carousel-gap)) / 3); + --horizontal-carousel-white-space: normal; + --horizontal-carousel-snap: x mandatory; } } diff --git a/src/app/core/layout/store-layout/store-header/store-header.component.ts b/src/app/core/layout/store-layout/store-header/store-header.component.ts index dfe1831..642e465 100644 --- a/src/app/core/layout/store-layout/store-header/store-header.component.ts +++ b/src/app/core/layout/store-layout/store-header/store-header.component.ts @@ -1,44 +1,51 @@ -import { - Component, - computed, - ElementRef, - HostListener, - inject, - input, - output, - signal, -} from '@angular/core'; -import { Router, RouterLink } from '@angular/router'; +import { NgTemplateOutlet } from '@angular/common'; +import { Component, computed, input, output } from '@angular/core'; import { AuthUser } from '../../../services/auth/auth.interfaces'; -import { CartIconComponent } from '../../../../shared/components/cart-icon/cart-icon.component'; -import { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.component'; -import { UserDropdownComponent } from './user-dropdown/user-dropdown.component'; -import { Menu } from '../../../services/tenant.interface'; -import { ButtonComponent } from '../../../../shared/components/button/button.component'; -import { ReactiveFormsModule, FormControl } from '@angular/forms'; -import { Category } from '../../../services/tenant.interface'; -import { CategoryDropdownComponent } from './category-dropdown/category-dropdown.component'; +import { Category, Menu, WebsiteExtras } from '../../../services/tenant.interface'; +import { ImmersiveStoreHeaderComponent } from './immersive-store-header.component'; +import { StandardStoreHeaderComponent } from './standard-store-header.component'; + +import { + HorizontalCarouselComponent, + HorizontalCarouselItem, +} from '../../../../shared/components/horizontal-carousel/horizontal-carousel.component'; + +export type StoreHeaderType = 'standard' | 'immersive'; @Component({ selector: 'app-store-header', imports: [ - ButtonComponent, - CartIconComponent, - IconButtonComponent, - RouterLink, - ReactiveFormsModule, - UserDropdownComponent, - CategoryDropdownComponent, + NgTemplateOutlet, + HorizontalCarouselComponent, + StandardStoreHeaderComponent, + ImmersiveStoreHeaderComponent, ], templateUrl: './store-header.component.html', styleUrl: './store-header.component.scss', + host: { + '[class.immersive-shell--compact]': 'isCompactImmersive()', + '[style.background-image]': 'null', + '[style.--compact-header-background]': 'compactBackground()', + }, }) export class StoreHeaderComponent { - private readonly elementRef = inject(ElementRef); - private readonly router = inject(Router); - + readonly headerType = input('standard'); readonly logoUrl = input(null); readonly backgroundImageUrl = input(null); + readonly heroContent = input(undefined); + readonly carouselImages = input([]); + readonly showHero = input(true); + protected readonly isCompactImmersive = computed( + () => this.headerType() === 'immersive' && !this.showHero(), + ); + protected readonly compactBackground = computed(() => { + if (!this.isCompactImmersive()) return null; + + const gradient = + 'linear-gradient(180deg, rgba(245, 245, 245, 0.72) 0%, rgba(245, 245, 245, 0.88) 58%, #f5f5f5 calc(100% - 4px), #f5f5f5 100%)'; + const image = this.backgroundImageUrl(); + return image ? `${gradient}, url(${JSON.stringify(image)})` : gradient; + }); readonly cartQuantity = input(0); readonly user = input(null); readonly isAuthenticated = input(false); @@ -50,6 +57,7 @@ export class StoreHeaderComponent { readonly displayCart = input(true); readonly cartDisabled = input(false); readonly checkoutRemainingSeconds = input(null); + readonly cartClick = output(); readonly ticketsClick = output(); readonly loginClick = output(); @@ -57,116 +65,12 @@ export class StoreHeaderComponent { readonly searchSubmit = output(); readonly categorySelect = output(); - protected readonly isUserDropdownOpen = signal(false); - protected readonly isCategoryDropdownOpen = signal(false); - protected readonly isMobileMenuOpen = signal(false); - protected readonly minSearchLength = 3; - protected readonly showSearchError = signal(false); - protected readonly searchControl = new FormControl('', { nonNullable: true }); - protected readonly checkoutRemainingTime = computed(() => { - const remainingSeconds = this.checkoutRemainingSeconds(); + protected readonly categoryItems = computed(() => + this.categories().map((category) => ({ value: category.id, label: category.nombre })), + ); - if (remainingSeconds === null) { - return null; - } - - const minutes = Math.floor(remainingSeconds / 60); - const seconds = remainingSeconds % 60; - - return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; - }); - - protected onCartClick(): void { - if (this.cartDisabled()) { - return; - } - - this.cartClick.emit(); - } - - @HostListener('document:click', ['$event']) - protected onDocumentClick(event: MouseEvent): void { - if (!this.isUserDropdownOpen() && !this.isCategoryDropdownOpen() && !this.isMobileMenuOpen()) { - return; - } - - const target = event.target; - if (target instanceof Node && !this.elementRef.nativeElement.contains(target)) { - this.isUserDropdownOpen.set(false); - this.isCategoryDropdownOpen.set(false); - this.isMobileMenuOpen.set(false); - } - } - - @HostListener('document:keydown.escape') - protected onEscapeKey(): void { - this.isUserDropdownOpen.set(false); - this.isCategoryDropdownOpen.set(false); - this.isMobileMenuOpen.set(false); - } - - protected toggleUserDropdown(): void { - if (!this.isAuthenticated()) { - this.loginClick.emit(); - return; - } - - this.isUserDropdownOpen.update((isOpen) => !isOpen); - } - - protected onLoginClick(): void { - this.isUserDropdownOpen.set(false); - this.loginClick.emit(); - } - - protected onLogoutClick(): void { - this.isUserDropdownOpen.set(false); - this.logoutClick.emit(); - } - - protected submitSearch(event?: Event): void { - event?.preventDefault(); - - const term = this.searchControl.value.trim(); - - if (term.length < this.minSearchLength) { - this.showSearchError.set(true); - return; - } - - this.showSearchError.set(false); - this.searchSubmit.emit(term); - } - - protected clearSearchError(): void { - this.showSearchError.set(false); - } - - protected toggleCategoryDropdown(): void { - this.isCategoryDropdownOpen.update((isOpen) => !isOpen); - } - - protected onCategorySelect(category: Category): void { - this.isCategoryDropdownOpen.set(false); - this.isMobileMenuOpen.set(false); - this.categorySelect.emit(category); - } - - protected toggleMobileMenu(): void { - this.isMobileMenuOpen.update((isOpen) => !isOpen); - } - - protected onMobileAccountClick(): void { - this.isMobileMenuOpen.set(false); - if (!this.isAuthenticated()) { - this.loginClick.emit(); - } else { - const menus = this.accountNavigationMenus(); - if (menus.length > 0) { - this.router.navigate([menus[0].route]); - } else { - this.router.navigate(['/cuenta']); - } - } + protected onCategoryItemSelect(item: HorizontalCarouselItem): void { + const category = this.categories().find((candidate) => candidate.id === item.value); + if (category) this.categorySelect.emit(category); } } diff --git a/src/app/core/layout/store-layout/store-header/user-dropdown/user-dropdown.component.scss b/src/app/core/layout/store-layout/store-header/user-dropdown/user-dropdown.component.scss index 022069a..9b2266c 100644 --- a/src/app/core/layout/store-layout/store-header/user-dropdown/user-dropdown.component.scss +++ b/src/app/core/layout/store-layout/store-header/user-dropdown/user-dropdown.component.scss @@ -71,6 +71,7 @@ @media (max-width: 575.98px) { :host { - right: -0.5rem; + right: 0; + width: min(18rem, calc(100vw - 1rem)); } } diff --git a/src/app/core/layout/store-layout/store-layout.component.html b/src/app/core/layout/store-layout/store-layout.component.html index e3113a9..e559ac9 100644 --- a/src/app/core/layout/store-layout/store-layout.component.html +++ b/src/app/core/layout/store-layout/store-layout.component.html @@ -1,6 +1,10 @@
{ expect(compiled.querySelector('app-store-footer .store-layout__footer')).not.toBeNull(); }); + it('renders the immersive header with configured copy and carousel images', () => { + const fixture = TestBed.createComponent(StoreLayoutComponent); + fixture.detectChanges(); + + const element = fixture.nativeElement as HTMLElement; + const header = fixture.debugElement.query(By.directive(StoreHeaderComponent)); + expect(header.componentInstance.headerType()).toBe('standard'); + expect(element.querySelector('app-standard-store-header')).not.toBeNull(); + + tenantState.set({ + ...tenant, + header_type: 'immersive', + extras: { + immersiveHero: { + eyebrow: 'Encendé tu', + title: 'experiencia', + description: 'Reservá tu entrada', + }, + immersiveHeroCarousel: ['/first.jpg', '/second.jpg'], + }, + }); + fixture.detectChanges(); + + expect(header.componentInstance.headerType()).toBe('immersive'); + 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', + ); + const backdropImage = element.querySelector( + '.immersive-header__backdrop-image', + ); + expect(backdropImage?.getAttribute('src')).toBe(tenant.header_bg_image); + expect(backdropImage?.getAttribute('loading')).toBe('eager'); + expect(backdropImage?.getAttribute('fetchpriority')).toBe('high'); + expect(backdropImage?.getAttribute('decoding')).toBe('async'); + const headerLogo = element.querySelector('.immersive-header__brand img'); + expect(headerLogo?.getAttribute('loading')).toBe('eager'); + expect(headerLogo?.getAttribute('fetchpriority')).toBe('high'); + expect(headerLogo?.getAttribute('decoding')).toBe('async'); + expect(element.querySelectorAll('.continuous-carousel__set')).toHaveLength(2); + const carouselImages = element.querySelectorAll( + '.continuous-carousel__image', + ); + expect(carouselImages[0]?.getAttribute('src')).toBe('/first.jpg'); + expect(carouselImages[0]?.getAttribute('loading')).toBe('eager'); + expect(carouselImages[0]?.getAttribute('fetchpriority')).toBe('low'); + expect(carouselImages[1]?.getAttribute('fetchpriority')).toBe('low'); + expect(carouselImages[5]?.getAttribute('loading')).toBe('lazy'); + expect(carouselImages[5]?.getAttribute('fetchpriority')).toBe('low'); + expect(element.querySelector('app-cart-icon')).not.toBeNull(); + }); + + it('keeps tickets in the immersive account dropdown while hiding its root action', () => { + const authService = TestBed.inject(AuthService); + (authService.isAuthenticated as any).set(true); + (authService.user as any).set({ + id: 1, + nombre_apellido: 'Ada Lovelace', + email: 'ada@example.com', + }); + tenantState.set({ ...tenant, header_type: 'immersive' }); + + const fixture = TestBed.createComponent(StoreLayoutComponent); + fixture.detectChanges(); + + const element = fixture.nativeElement as HTMLElement; + expect(element.querySelector('[data-testid="store-header-tickets"]')).toBeNull(); + + element.querySelector('app-icon-button[variant="user"] button')?.click(); + fixture.detectChanges(); + + expect(element.querySelector('[data-testid="user-dropdown-account.tickets"]')).not.toBeNull(); + + (fixture.componentInstance as any).isHomeRoute.set(false); + fixture.detectChanges(); + + expect(element.querySelector('[data-testid="store-header-tickets"]')).not.toBeNull(); + }); + + it('shows immersive categories inside the header and navigates on selection', () => { + tenantState.set({ + ...tenant, + header_type: 'immersive', + display_categories: true, + categories: [{ id: 11, nombre: 'Música', subcategories: [] }], + menues: [ + ...(tenant.menues ?? []), + { + id: 20, + code: 'product.category', + label: 'Categoría de productos', + parent_menu_code: null, + content_type: 'dynamic', + route: '/categoria/:id', + submenues: [], + }, + ], + }); + + const fixture = TestBed.createComponent(StoreLayoutComponent); + fixture.detectChanges(); + + const element = fixture.nativeElement as HTMLElement; + const header = element.querySelector('app-immersive-store-header header'); + const categories = element.querySelector('app-store-header .immersive-categories'); + const categoryButton = categories?.querySelector('button'); + + expect(header).not.toBeNull(); + expect(categories).not.toBeNull(); + expect(header?.contains(categories)).toBe(true); + expect(categoryButton?.textContent?.trim()).toBe('MÚSICA'); + + const navigate = vi.spyOn(TestBed.inject(Router), 'navigate'); + categoryButton?.click(); + expect(navigate).toHaveBeenCalledWith(['/categoria', 11], { queryParams: { page: 1 } }); + + tenantState.set({ + ...tenant, + header_type: 'immersive', + display_categories: false, + categories: [{ id: 11, nombre: 'Música', subcategories: [] }], + }); + fixture.detectChanges(); + expect(element.querySelector('.immersive-categories')).toBeNull(); + }); + + it('uses event categories and their dedicated route for multi-event storefronts', () => { + tenantState.set({ + ...tenant, + storefront_website_type_code: 'onticket_multi_event', + header_type: 'immersive', + display_categories: true, + categories: [], + event_categories: [{ id: 12, nombre: 'Teatro', subcategories: [] }], + menues: [ + ...(tenant.menues ?? []), + { + id: 21, + code: 'event.category', + label: 'Categoría de eventos', + parent_menu_code: null, + content_type: 'dynamic', + route: '/eventos/categoria/:id', + submenues: [], + }, + ], + }); + + const fixture = TestBed.createComponent(StoreLayoutComponent); + fixture.detectChanges(); + + const categoryButton = (fixture.nativeElement as HTMLElement).querySelector( + '.immersive-categories button', + ); + const navigate = vi.spyOn(TestBed.inject(Router), 'navigate'); + + expect(categoryButton?.textContent?.trim()).toBe('TEATRO'); + categoryButton?.click(); + expect(navigate).toHaveBeenCalledWith(['/eventos/categoria', 12], { + queryParams: { page: 1 }, + }); + }); + it('opens the cart when requested through the openCart query parameter', () => { const fixture = TestBed.createComponent(StoreLayoutComponent); fixture.detectChanges(); @@ -303,6 +472,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, @@ -327,7 +516,7 @@ describe('StoreLayoutComponent', () => { expect(compiled.querySelector('app-cart')).toBeNull(); expect(compiled.querySelector('.store-layout__cart-overlay')).toBeNull(); - const header = fixture.debugElement.query(By.directive(StoreHeaderComponent)); + const header = fixture.debugElement.query(By.directive(StandardStoreHeaderComponent)); (header.componentInstance as any).toggleMobileMenu(); fixture.detectChanges(); @@ -342,11 +531,13 @@ describe('StoreLayoutComponent', () => { expect(compiled.querySelector('[data-testid="store-header-brand-slot"]')).not.toBeNull(); expect(compiled.querySelector('[data-testid="store-footer-brand-slot"]')).not.toBeNull(); - expect( - compiled.querySelector( - `app-store-header img.store-layout__brand-logo[src="${tenant.header_logo}"]`, - ), - ).not.toBeNull(); + const headerLogo = compiled.querySelector( + `app-store-header img.store-layout__brand-logo[src="${tenant.header_logo}"]`, + ); + expect(headerLogo).not.toBeNull(); + expect(headerLogo?.getAttribute('loading')).toBe('eager'); + expect(headerLogo?.getAttribute('fetchpriority')).toBe('high'); + expect(headerLogo?.getAttribute('decoding')).toBe('async'); expect( compiled.querySelector( `app-store-footer img.store-layout__brand-logo[src="${tenant.footer_logo}"]`, @@ -378,7 +569,7 @@ describe('StoreLayoutComponent', () => { const fixture = TestBed.createComponent(StoreLayoutComponent); fixture.detectChanges(); - const header = fixture.debugElement.query(By.directive(StoreHeaderComponent)); + const header = fixture.debugElement.query(By.directive(StandardStoreHeaderComponent)); const searchButton = header.query(By.css('.store-layout__search-button')); (header.componentInstance as any).searchControl.setValue(' zapatillas '); @@ -396,7 +587,7 @@ describe('StoreLayoutComponent', () => { const fixture = TestBed.createComponent(StoreLayoutComponent); fixture.detectChanges(); - const header = fixture.debugElement.query(By.directive(StoreHeaderComponent)); + const header = fixture.debugElement.query(By.directive(StandardStoreHeaderComponent)); const input = header.query(By.css('#store-search-input')); const keyboardEvent = { preventDefault: vi.fn(), @@ -417,7 +608,7 @@ describe('StoreLayoutComponent', () => { const fixture = TestBed.createComponent(StoreLayoutComponent); fixture.detectChanges(); - const header = fixture.debugElement.query(By.directive(StoreHeaderComponent)); + const header = fixture.debugElement.query(By.directive(StandardStoreHeaderComponent)); const input = header.query(By.css('#store-search-input')); const searchButton = header.query(By.css('.store-layout__search-button')); @@ -544,7 +735,7 @@ describe('StoreLayoutComponent', () => { expect( compiled.querySelector('[data-testid="user-dropdown-account.purchases"]'), ).not.toBeNull(); - expect(compiled.querySelector('[data-testid="user-dropdown-account.tickets"]')).toBeNull(); + expect(compiled.querySelector('[data-testid="user-dropdown-account.tickets"]')).not.toBeNull(); expect(router.navigate).not.toHaveBeenCalled(); }); @@ -619,7 +810,7 @@ describe('StoreLayoutComponent', () => { expect(profileLink?.getAttribute('href')).toBe('/mi-cuenta/datos-personales'); expect(purchasesLink?.getAttribute('href')).toBe('/mi-cuenta/compras'); - expect(ticketsLink).toBeUndefined(); + expect(ticketsLink?.getAttribute('href')).toBe('/mi-cuenta/tickets'); expect(logoutButton).toBeDefined(); logoutButton!.click(); diff --git a/src/app/core/layout/store-layout/store-layout.component.ts b/src/app/core/layout/store-layout/store-layout.component.ts index 803f6c1..23a53be 100644 --- a/src/app/core/layout/store-layout/store-layout.component.ts +++ b/src/app/core/layout/store-layout/store-layout.component.ts @@ -68,8 +68,31 @@ 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); + 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 ?? []) + : (this.tenant()?.categories ?? []), + ); + private readonly categoryMenu = computed(() => + findMenu( + this.tenant()?.menues ?? [], + this.isMultiEventStorefront() ? 'event.category' : 'product.category', + ), + ); + protected readonly displayCategories = computed( + () => (this.tenant()?.display_categories ?? true) && this.categoryMenu() !== undefined, + ); protected readonly displayCart = computed(() => this.tenant()?.display_cart ?? true); protected readonly cartEditingPolicy = computed(() => this.tenant()?.cart_editing_policy ?? null); protected readonly canModifyCart = computed( @@ -188,10 +211,12 @@ export class StoreLayoutComponent implements OnInit { const profile = accountMenu.submenues.find((menu) => menu.code === 'account.profile'); const purchases = accountMenu.submenues.find((menu) => menu.code === 'account.purchases'); + const tickets = accountMenu.submenues.find((menu) => menu.code === 'account.tickets'); return [ ...(profile ? [{ ...accountMenu, route: profile.route }] : []), ...(purchases ? [purchases] : []), + ...(tickets ? [tickets] : []), ]; }); @@ -210,6 +235,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 +259,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']); } @@ -274,7 +305,9 @@ export class StoreLayoutComponent implements OnInit { } protected onCategorySelect(category: Category): void { - void this.router.navigate(['/categoria', category.id], { + const route = this.isMultiEventStorefront() ? '/eventos/categoria' : '/categoria'; + + void this.router.navigate([route, category.id], { queryParams: { page: 1 }, }); } diff --git a/src/app/core/services/checkout.service.ts b/src/app/core/services/checkout.service.ts index 4cad0da..624d238 100644 --- a/src/app/core/services/checkout.service.ts +++ b/src/app/core/services/checkout.service.ts @@ -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; diff --git a/src/app/core/services/event/event.interface.ts b/src/app/core/services/event/event.interface.ts new file mode 100644 index 0000000..a51cce6 --- /dev/null +++ b/src/app/core/services/event/event.interface.ts @@ -0,0 +1,36 @@ +import { ApiPaginatedResponse } from '../api-paginated-response.interface'; + +export interface StoreEvent { + id: number; + slug: string; + event_category_id: number | null; + title: string; + 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; diff --git a/src/app/core/services/event/event.service.ts b/src/app/core/services/event/event.service.ts new file mode 100644 index 0000000..0affe3c --- /dev/null +++ b/src/app/core/services/event/event.service.ts @@ -0,0 +1,24 @@ +import { inject, Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; + +import { BaseApiService } from '../base-api.service'; +import { ApiResponse } from '../api-response.interface'; +import { TenantService } from '../tenant.service'; +import { StoreEvent, StoreEventPage } from './event.interface'; + +@Injectable({ providedIn: 'root' }) +export class EventService extends BaseApiService { + private readonly tenantService = inject(TenantService); + + list(page: number, query = '', categoryId?: number): Observable { + return this.http.get(`${this.tenantService.getTenantApiUrl()}/events`, { + params: { page, ...(query ? { q: query } : {}), ...(categoryId ? { category_id: categoryId } : {}) }, + }); + } + + get(slug: string): Observable> { + return this.http.get>( + `${this.tenantService.getTenantApiUrl()}/events/${slug}`, + ); + } +} diff --git a/src/app/core/services/tenant.interface.ts b/src/app/core/services/tenant.interface.ts index 0792a45..fcc075c 100644 --- a/src/app/core/services/tenant.interface.ts +++ b/src/app/core/services/tenant.interface.ts @@ -19,16 +19,6 @@ export interface BankAccount { cvu: string; } -export interface WebsiteType { - codigo: string; - nombre: string; - primary_color: string | null; - secondary_color: string | null; - danger_color: string | null; - success_color: string | null; - site_logo: string | null; -} - export interface HeroConfig { background_image_id?: string | ResponsiveImage | null; title_html?: string | null; @@ -74,13 +64,24 @@ export interface ActiveEventDate { } export interface TenantEvent { + id: number; title: string; - location: string; + subtitle: string | null; + description: string | null; + location: string | null; + date_text: string | null; dates: ActiveEventDate[]; + social_media: SocialMedia[]; } export interface WebsiteExtras { carousel?: string[]; + immersiveHeroCarousel?: string[]; + immersiveHero?: { + eyebrow: string; + title: string; + description: string; + }; heroConfig?: HeroConfig | null; additionalInfoConfig?: AdditionalInfoConfig | null; [extraName: string]: unknown; @@ -116,6 +117,7 @@ export interface Tenant { nombre: string; dominio: string; base_path?: string; + timezone?: string; site_title?: string | null; asset_url?: string | null; address?: string | null; @@ -131,9 +133,9 @@ export interface Tenant { footer_logo: string; header_bg_image?: string | null; footer_bg_image?: string | null; - website_type_code?: string | null; - website_type?: WebsiteType | null; - event_date_text?: 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; @@ -153,7 +155,8 @@ export interface Tenant { ticket_partial_refund_percentage?: string; social_media?: SocialMedia[]; menues?: Menu[]; - categories: Category[]; + categories?: Category[]; + event_categories?: Category[]; } export type TenantBootstrapResponse = ApiResponse; diff --git a/src/app/core/services/tenant.service.ts b/src/app/core/services/tenant.service.ts index 748a06d..03ecd4d 100644 --- a/src/app/core/services/tenant.service.ts +++ b/src/app/core/services/tenant.service.ts @@ -183,6 +183,10 @@ export class TenantService extends BaseApiService { } private setReady(tenant: Tenant): void { + if (isPlatformBrowser(this.platformId) && !environment.production) { + console.info('[Tenant bootstrap]', tenant); + } + this.ensureAssetPreconnect(tenant.asset_url); this.tenantState.set(tenant); this.statusState.set('ready'); diff --git a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.html b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.html index d3d5d8c..43309d7 100644 --- a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.html +++ b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.html @@ -706,7 +706,7 @@
diff --git a/src/app/features/store/components/event-card/event-card.component.html b/src/app/features/store/components/event-card/event-card.component.html new file mode 100644 index 0000000..3c64eaa --- /dev/null +++ b/src/app/features/store/components/event-card/event-card.component.html @@ -0,0 +1,19 @@ + +
+ @if (event().image) { + + } +
+
+

{{ event().title | uppercase }}

+ @if (event().subtitle) { +

{{ event().subtitle }}

+ } + @if (event().date_text) { +

{{ event().date_text }}

+ } + @if (event().location) { +

{{ event().location }}

+ } +
+
diff --git a/src/app/features/store/components/event-card/event-card.component.scss b/src/app/features/store/components/event-card/event-card.component.scss new file mode 100644 index 0000000..88c47f9 --- /dev/null +++ b/src/app/features/store/components/event-card/event-card.component.scss @@ -0,0 +1,11 @@ +:host { display: block; min-width: 0; } +.event-card { display: block; height: 100%; color: inherit; background: #fff; text-decoration: none; box-shadow: 0 12px 30px rgba(0, 0, 0, .05); transition: transform .2s, box-shadow .2s; } +.event-card:hover { transform: translateY(-3px); box-shadow: 0 16px 32px rgba(0, 0, 0, .1); } +.event-card:focus-visible { outline: 2px solid var(--tenant-primary); outline-offset: 3px; } +.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: 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; } diff --git a/src/app/features/store/components/event-card/event-card.component.ts b/src/app/features/store/components/event-card/event-card.component.ts new file mode 100644 index 0000000..5a1657d --- /dev/null +++ b/src/app/features/store/components/event-card/event-card.component.ts @@ -0,0 +1,16 @@ +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, UpperCasePipe], + templateUrl: './event-card.component.html', + styleUrl: './event-card.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class EventCardComponent { + readonly event = input.required(); +} diff --git a/src/app/features/store/components/event-list/event-list.component.html b/src/app/features/store/components/event-list/event-list.component.html new file mode 100644 index 0000000..ec4f385 --- /dev/null +++ b/src/app/features/store/components/event-list/event-list.component.html @@ -0,0 +1,20 @@ +@if (loading()) { +

Cargando eventos...

+} @else if (error()) { + +} @else if (results(); as page) { + @if (page.data.length) { +
+ @for (event of page.data; track event.id) { + + } +
+ @if (page.meta.last_page > 1) { +
+ +
+ } + } @else { +

{{ search() ? 'No encontramos eventos para tu búsqueda.' : 'No hay eventos disponibles en este momento.' }}

+ } +} diff --git a/src/app/features/store/components/event-list/event-list.component.scss b/src/app/features/store/components/event-list/event-list.component.scss new file mode 100644 index 0000000..8fd1cf0 --- /dev/null +++ b/src/app/features/store/components/event-list/event-list.component.scss @@ -0,0 +1,5 @@ +.event-list__grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: clamp(1.25rem, 3vw, 2.5rem); } +.event-list__pagination { display: flex; justify-content: center; margin-top: 3rem; } +.event-list__message { text-align: center; padding: 2rem 0; } +@media (max-width: 767.98px) { .event-list__grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } +@media (max-width: 479.98px) { .event-list__grid { grid-template-columns: 1fr; } } diff --git a/src/app/features/store/components/event-list/event-list.component.ts b/src/app/features/store/components/event-list/event-list.component.ts new file mode 100644 index 0000000..9640bb4 --- /dev/null +++ b/src/app/features/store/components/event-list/event-list.component.ts @@ -0,0 +1,68 @@ +import { ChangeDetectionStrategy, Component, DestroyRef, inject, input, OnInit, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { ActivatedRoute, Router } from '@angular/router'; +import { catchError, combineLatest, distinctUntilChanged, map, of, switchMap, tap } from 'rxjs'; + +import { EventService } from '../../../../core/services/event/event.service'; +import { StoreEventPage } from '../../../../core/services/event/event.interface'; +import { PaginatorComponent } from '../../../../shared/components/paginator/paginator.component'; +import { EventCardComponent } from '../event-card/event-card.component'; + +@Component({ + selector: 'app-event-list', + imports: [EventCardComponent, PaginatorComponent], + templateUrl: './event-list.component.html', + styleUrl: './event-list.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class EventListComponent implements OnInit { + private readonly eventService = inject(EventService); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly destroyRef = inject(DestroyRef); + + readonly search = input(false); + readonly categoryId = input(null); + protected readonly results = signal(null); + protected readonly loading = signal(true); + protected readonly error = signal(false); + protected readonly query = signal(''); + + ngOnInit(): void { + combineLatest([this.route.queryParamMap, this.route.paramMap]).pipe( + map(([params, routeParams]) => { + const requestedPage = Number(params.get('page')); + return { + page: Number.isSafeInteger(requestedPage) && requestedPage > 0 ? requestedPage : 1, + query: this.search() ? (params.get('q') ?? '').trim() : '', + categoryId: this.categoryId() === null ? null : Number(routeParams.get('id')), + }; + }), + distinctUntilChanged((a, b) => a.page === b.page && a.query === b.query && a.categoryId === b.categoryId), + tap(({ query }) => { + this.query.set(query); + this.loading.set(true); + this.error.set(false); + this.results.set(null); + }), + switchMap(({ page, query, categoryId }) => this.eventService.list(page, query, categoryId ?? undefined).pipe( + catchError(() => { + this.error.set(true); + return of(null); + }), + )), + takeUntilDestroyed(this.destroyRef), + ).subscribe((results) => { + this.results.set(results); + this.loading.set(false); + }); + } + + protected changePage(page: number): void { + void this.router.navigate([], { + relativeTo: this.route, + queryParams: { page: page === 1 ? null : page }, + queryParamsHandling: 'merge', + }); + } +} diff --git a/src/app/features/store/components/menu-page-layout/menu-page-layout.component.html b/src/app/features/store/components/menu-page-layout/menu-page-layout.component.html index 7ba9834..5ca1fe1 100644 --- a/src/app/features/store/components/menu-page-layout/menu-page-layout.component.html +++ b/src/app/features/store/components/menu-page-layout/menu-page-layout.component.html @@ -14,6 +14,12 @@ } + + @if (sidebarContent.template(); as sidebarTemplate) { + + }
diff --git a/src/app/features/store/components/menu-page-layout/menu-page-layout.component.scss b/src/app/features/store/components/menu-page-layout/menu-page-layout.component.scss index 3db8125..b868e3c 100644 --- a/src/app/features/store/components/menu-page-layout/menu-page-layout.component.scss +++ b/src/app/features/store/components/menu-page-layout/menu-page-layout.component.scss @@ -40,6 +40,10 @@ gap: 1rem; } + &__context { + margin-top: 2rem; + } + &__link { color: #888888; font-size: 0.875rem; diff --git a/src/app/features/store/components/menu-page-layout/menu-page-layout.component.spec.ts b/src/app/features/store/components/menu-page-layout/menu-page-layout.component.spec.ts index 051c8bb..0c94ba6 100644 --- a/src/app/features/store/components/menu-page-layout/menu-page-layout.component.spec.ts +++ b/src/app/features/store/components/menu-page-layout/menu-page-layout.component.spec.ts @@ -1,11 +1,34 @@ -import { signal } from '@angular/core'; +import { Component, signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { ActivatedRoute, provideRouter } from '@angular/router'; +import { RouterTestingHarness } from '@angular/router/testing'; import { describe, expect, it } from 'vitest'; import { Tenant } from '../../../../core/services/tenant.interface'; import { TenantService } from '../../../../core/services/tenant.service'; import { MenuPageLayoutComponent } from './menu-page-layout.component'; +import { MenuPageSidebarContentDirective } from './menu-page-sidebar-content.directive'; + +@Component({ + selector: 'app-sidebar-test-badge', + template: 'Componente contextual', +}) +class SidebarTestBadgeComponent {} + +@Component({ + imports: [MenuPageSidebarContentDirective, SidebarTestBadgeComponent], + template: ` +

Contenido principal

+ + + + + + `, +}) +class SidebarTestPageComponent { + protected readonly contextualCopy = 'HTML declarado por la página'; +} function createTenant(): Tenant { return { @@ -133,4 +156,44 @@ describe('MenuPageLayoutComponent', () => { 'Consultas habituales', ); }); + + it('renders page-owned HTML and components below the sidebar navigation', async () => { + const tenant = signal(createTenant()); + + await TestBed.configureTestingModule({ + providers: [ + provideRouter([ + { + path: 'mi-cuenta', + component: MenuPageLayoutComponent, + data: { + menuCode: 'account', + navigationLabel: 'Secciones de mi cuenta', + }, + children: [{ path: 'tickets', component: SidebarTestPageComponent }], + }, + ]), + { + provide: TenantService, + useValue: { tenant: tenant.asReadonly() }, + }, + ], + }).compileComponents(); + + const harness = await RouterTestingHarness.create('/mi-cuenta/tickets'); + const element = harness.routeNativeElement as HTMLElement; + const navigation = element.querySelector('.menu-page-layout__nav'); + const context = element.querySelector('.menu-page-layout__context'); + + expect(context?.previousElementSibling).toBe(navigation); + expect(context?.querySelector('.sidebar-context-copy')?.textContent?.trim()).toBe( + 'HTML declarado por la página', + ); + expect(context?.querySelector('.sidebar-test-badge')?.textContent?.trim()).toBe( + 'Componente contextual', + ); + expect(element.querySelector('.routed-page-content')?.textContent?.trim()).toBe( + 'Contenido principal', + ); + }); }); diff --git a/src/app/features/store/components/menu-page-layout/menu-page-layout.component.ts b/src/app/features/store/components/menu-page-layout/menu-page-layout.component.ts index 0640f32..4818432 100644 --- a/src/app/features/store/components/menu-page-layout/menu-page-layout.component.ts +++ b/src/app/features/store/components/menu-page-layout/menu-page-layout.component.ts @@ -1,18 +1,22 @@ +import { NgTemplateOutlet } from '@angular/common'; import { Component, computed, inject } from '@angular/core'; import { ActivatedRoute, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; import { findMenu } from '../../../../core/services/menu.utils'; import { TenantService } from '../../../../core/services/tenant.service'; +import { MenuPageSidebarContentService } from './menu-page-sidebar-content.service'; @Component({ selector: 'app-menu-page-layout', - imports: [RouterLink, RouterLinkActive, RouterOutlet], + imports: [NgTemplateOutlet, RouterLink, RouterLinkActive, RouterOutlet], + providers: [MenuPageSidebarContentService], templateUrl: './menu-page-layout.component.html', styleUrl: './menu-page-layout.component.scss', }) export class MenuPageLayoutComponent { private readonly route = inject(ActivatedRoute); private readonly tenantService = inject(TenantService); + protected readonly sidebarContent = inject(MenuPageSidebarContentService); protected readonly navigationLabel = (this.route.snapshot.data['navigationLabel'] as string | undefined) ?? 'Secciones'; diff --git a/src/app/features/store/components/menu-page-layout/menu-page-sidebar-content.directive.ts b/src/app/features/store/components/menu-page-layout/menu-page-sidebar-content.directive.ts new file mode 100644 index 0000000..3a1ac91 --- /dev/null +++ b/src/app/features/store/components/menu-page-layout/menu-page-sidebar-content.directive.ts @@ -0,0 +1,24 @@ +import { Directive, inject, OnDestroy, OnInit, TemplateRef } from '@angular/core'; + +import { MenuPageSidebarContentService } from './menu-page-sidebar-content.service'; + +/** + * Projects page-owned content into the sidebar of the closest menu page layout. + * The template keeps the declaration context of the routed page, so it can use + * that page's fields, bindings and imported components. + */ +@Directive({ + selector: 'ng-template[menuPageSidebarContent]', +}) +export class MenuPageSidebarContentDirective implements OnInit, OnDestroy { + private readonly template = inject(TemplateRef); + private readonly sidebarContent = inject(MenuPageSidebarContentService); + + ngOnInit(): void { + this.sidebarContent.register(this.template); + } + + ngOnDestroy(): void { + this.sidebarContent.unregister(this.template); + } +} diff --git a/src/app/features/store/components/menu-page-layout/menu-page-sidebar-content.service.ts b/src/app/features/store/components/menu-page-layout/menu-page-sidebar-content.service.ts new file mode 100644 index 0000000..e5f10ac --- /dev/null +++ b/src/app/features/store/components/menu-page-layout/menu-page-sidebar-content.service.ts @@ -0,0 +1,18 @@ +import { Injectable, signal, TemplateRef } from '@angular/core'; + +@Injectable() +export class MenuPageSidebarContentService { + private readonly templateState = signal | null>(null); + + readonly template = this.templateState.asReadonly(); + + register(template: TemplateRef): void { + this.templateState.set(template); + } + + unregister(template: TemplateRef): void { + if (this.templateState() === template) { + this.templateState.set(null); + } + } +} diff --git a/src/app/features/store/pages/account-page/pages/purchase-detail-page/purchase-detail-page.ts b/src/app/features/store/pages/account-page/pages/purchase-detail-page/purchase-detail-page.ts index ee0c7a0..871b6ae 100644 --- a/src/app/features/store/pages/account-page/pages/purchase-detail-page/purchase-detail-page.ts +++ b/src/app/features/store/pages/account-page/pages/purchase-detail-page/purchase-detail-page.ts @@ -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, diff --git a/src/app/features/store/pages/account-page/pages/ticket-events-page/components/ticket-event-list-item/ticket-event-list-item.html b/src/app/features/store/pages/account-page/pages/ticket-events-page/components/ticket-event-list-item/ticket-event-list-item.html new file mode 100644 index 0000000..26d83df --- /dev/null +++ b/src/app/features/store/pages/account-page/pages/ticket-events-page/components/ticket-event-list-item/ticket-event-list-item.html @@ -0,0 +1,30 @@ + + + + @if (event().image; as image) { + + } + + + + {{ event().title }} + + + + + diff --git a/src/app/features/store/pages/account-page/pages/ticket-events-page/components/ticket-event-list-item/ticket-event-list-item.scss b/src/app/features/store/pages/account-page/pages/ticket-events-page/components/ticket-event-list-item/ticket-event-list-item.scss new file mode 100644 index 0000000..1114327 --- /dev/null +++ b/src/app/features/store/pages/account-page/pages/ticket-events-page/components/ticket-event-list-item/ticket-event-list-item.scss @@ -0,0 +1,60 @@ +:host { + display: block; + width: 100%; +} + +.ticket-event { + min-height: 84px; + padding: 12px 8px; + color: #666666; + text-decoration: none; + transition: + box-shadow 0.2s, + border-radius 0.2s; +} + +.ticket-event:hover, +.ticket-event:focus-visible { + border-radius: 8px; + box-shadow: 0 0 30px rgba(0, 0, 0, 0.07); +} + +.ticket-event:focus-visible { + outline: 2px solid var(--color-primary, var(--tenant-primary, #5b75ff)); + outline-offset: 2px; +} + +.ticket-event__image-wrapper { + display: block; + width: 100%; + aspect-ratio: 19 / 13; + overflow: hidden; + border-radius: 3px; + background: #dddddd; +} + +.ticket-event__image { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.ticket-event__title { + overflow: hidden; + font-size: 13px; + font-weight: 700; + line-height: 1.3; + text-overflow: ellipsis; + text-transform: uppercase; +} + +.ticket-event__arrow { + color: #888888; + transition: color 0.2s; +} + +.ticket-event:hover .ticket-event__arrow, +.ticket-event:focus-visible .ticket-event__arrow { + color: var(--color-primary, var(--tenant-primary, #5b75ff)); +} diff --git a/src/app/features/store/pages/account-page/pages/ticket-events-page/components/ticket-event-list-item/ticket-event-list-item.ts b/src/app/features/store/pages/account-page/pages/ticket-events-page/components/ticket-event-list-item/ticket-event-list-item.ts new file mode 100644 index 0000000..1796b2f --- /dev/null +++ b/src/app/features/store/pages/account-page/pages/ticket-events-page/components/ticket-event-list-item/ticket-event-list-item.ts @@ -0,0 +1,14 @@ +import { Component, input } from '@angular/core'; +import { RouterLink } from '@angular/router'; + +import { TicketEventResponse } from '../../../tickets-page/ticket.service'; + +@Component({ + selector: 'app-ticket-event-list-item', + imports: [RouterLink], + templateUrl: './ticket-event-list-item.html', + styleUrl: './ticket-event-list-item.scss', +}) +export class TicketEventListItem { + readonly event = input.required(); +} diff --git a/src/app/features/store/pages/account-page/pages/ticket-events-page/ticket-events-page.html b/src/app/features/store/pages/account-page/pages/ticket-events-page/ticket-events-page.html new file mode 100644 index 0000000..6934ace --- /dev/null +++ b/src/app/features/store/pages/account-page/pages/ticket-events-page/ticket-events-page.html @@ -0,0 +1,35 @@ + +
+ @if (isLoading()) { + @for (item of [1, 2, 3, 4]; track item) { + + } + } @else if (events().length) { + @for (event of events(); track event.id; let last = $last) { + + @if (!last) { +
+ } + } + } @else { +

Aún no tenés eventos con tickets.

+ } +
+
diff --git a/src/app/features/store/pages/account-page/pages/ticket-events-page/ticket-events-page.scss b/src/app/features/store/pages/account-page/pages/ticket-events-page/ticket-events-page.scss new file mode 100644 index 0000000..4c0d23d --- /dev/null +++ b/src/app/features/store/pages/account-page/pages/ticket-events-page/ticket-events-page.scss @@ -0,0 +1,66 @@ +:host { + display: block; + width: 100%; +} + +.ticket-events-page { + display: block; + width: 100%; + max-width: 600px; +} + +.ticket-events-list { + display: flex; + flex-direction: column; + width: 100%; +} + +.ticket-events-list__divider { + width: 100%; + height: 1px; + background: var(--border-color, #dddddd); +} + +.ticket-events-list__empty { + margin: 0; + padding: 2rem 0; + color: #666666; + font-size: 1rem; + text-align: center; +} + +.ticket-events-list__skeleton { + min-height: 84px; + padding: 12px 8px; + border-bottom: 1px solid var(--border-color, #dddddd); +} + +.ticket-events-list__skeleton-image, +.ticket-events-list__skeleton-title, +.ticket-events-list__skeleton-arrow { + display: block; + border-radius: 4px; + background: #eeeeee; + animation: ticket-events-pulse 1.5s infinite ease-in-out; +} + +.ticket-events-list__skeleton-image { + width: 100%; + aspect-ratio: 19 / 13; +} + +.ticket-events-list__skeleton-title { + width: min(250px, 80%); + height: 14px; +} + +.ticket-events-list__skeleton-arrow { + width: 8px; + height: 14px; +} + +@keyframes ticket-events-pulse { + 50% { + background-color: #e0e0e0; + } +} diff --git a/src/app/features/store/pages/account-page/pages/ticket-events-page/ticket-events-page.spec.ts b/src/app/features/store/pages/account-page/pages/ticket-events-page/ticket-events-page.spec.ts new file mode 100644 index 0000000..69d5ec8 --- /dev/null +++ b/src/app/features/store/pages/account-page/pages/ticket-events-page/ticket-events-page.spec.ts @@ -0,0 +1,99 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { TenantService } from '../../../../../../core/services/tenant.service'; +import { ToastService } from '../../../../../../core/services/toast.service'; +import { TicketService } from '../tickets-page/ticket.service'; +import { TicketEventsPage } from './ticket-events-page'; + +function withCustomLoading(service: T): T & { withCustomLoading: () => T } { + return Object.assign(service, { withCustomLoading: () => service }); +} + +describe('TicketEventsPage', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideRouter([]), + { + provide: TenantService, + useValue: { + tenant: signal({ + menues: [ + { + id: 4, + code: 'account.tickets', + label: 'Tickets', + parent_menu_code: 'account', + content_type: 'dynamic', + route: '/mi-cuenta/tickets', + submenues: [], + }, + ], + }).asReadonly(), + }, + }, + { provide: ToastService, useValue: { danger: vi.fn() } }, + ], + }); + }); + + it('shows the users events with image, title and detail link', async () => { + await TestBed.configureTestingModule({ imports: [TicketEventsPage] }) + .overrideComponent(TicketEventsPage, { + set: { + providers: [ + { + provide: TicketService, + useValue: withCustomLoading({ + getTicketEvents: () => + Promise.resolve([ + { id: 27, title: 'Copa del mundo', image: 'https://example.com/event.jpg' }, + ]), + }), + }, + ], + }, + }) + .compileComponents(); + + const fixture = TestBed.createComponent(TicketEventsPage); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + const page = fixture.nativeElement as HTMLElement; + const link = page.querySelector('.ticket-event'); + + expect(page.textContent).toContain('TICKETS'); + expect(page.textContent).toContain('Copa del mundo'); + expect(link?.getAttribute('href')).toBe('/27'); + expect(link?.querySelector('img')?.getAttribute('src')).toBe('https://example.com/event.jpg'); + }); + + it('shows the empty state when the user has no ticket events', async () => { + await TestBed.configureTestingModule({ imports: [TicketEventsPage] }) + .overrideComponent(TicketEventsPage, { + set: { + providers: [ + { + provide: TicketService, + useValue: withCustomLoading({ getTicketEvents: () => Promise.resolve([]) }), + }, + ], + }, + }) + .compileComponents(); + + const fixture = TestBed.createComponent(TicketEventsPage); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect((fixture.nativeElement as HTMLElement).textContent).toContain( + 'Aún no tenés eventos con tickets.', + ); + }); +}); diff --git a/src/app/features/store/pages/account-page/pages/ticket-events-page/ticket-events-page.ts b/src/app/features/store/pages/account-page/pages/ticket-events-page/ticket-events-page.ts new file mode 100644 index 0000000..186b19c --- /dev/null +++ b/src/app/features/store/pages/account-page/pages/ticket-events-page/ticket-events-page.ts @@ -0,0 +1,31 @@ +import { Component, inject, OnInit, signal } from '@angular/core'; + +import { ToastService } from '../../../../../../core/services/toast.service'; +import { MenuContentSectionComponent } from '../../../../components/menu-content-section/menu-content-section.component'; +import { TicketEventResponse, TicketService } from '../tickets-page/ticket.service'; +import { TicketEventListItem } from './components/ticket-event-list-item/ticket-event-list-item'; + +@Component({ + selector: 'app-ticket-events-page', + imports: [MenuContentSectionComponent, TicketEventListItem], + providers: [TicketService], + templateUrl: './ticket-events-page.html', + styleUrl: './ticket-events-page.scss', +}) +export class TicketEventsPage implements OnInit { + private readonly ticketService = inject(TicketService); + private readonly toastService = inject(ToastService); + + protected readonly events = signal([]); + protected readonly isLoading = signal(true); + + async ngOnInit(): Promise { + try { + this.events.set(await this.ticketService.withCustomLoading().getTicketEvents()); + } catch { + this.toastService.danger('Hubo un error al cargar los eventos'); + } finally { + this.isLoading.set(false); + } + } +} diff --git a/src/app/features/store/pages/account-page/pages/tickets-page/components/ticket-event-context/ticket-event-context.component.html b/src/app/features/store/pages/account-page/pages/tickets-page/components/ticket-event-context/ticket-event-context.component.html new file mode 100644 index 0000000..c290a5d --- /dev/null +++ b/src/app/features/store/pages/account-page/pages/tickets-page/components/ticket-event-context/ticket-event-context.component.html @@ -0,0 +1,9 @@ +
+ @if (event().image; as image) { + + } + +

+ {{ event().title }} +

+
diff --git a/src/app/features/store/pages/account-page/pages/tickets-page/components/ticket-event-context/ticket-event-context.component.scss b/src/app/features/store/pages/account-page/pages/tickets-page/components/ticket-event-context/ticket-event-context.component.scss new file mode 100644 index 0000000..8be7147 --- /dev/null +++ b/src/app/features/store/pages/account-page/pages/tickets-page/components/ticket-event-context/ticket-event-context.component.scss @@ -0,0 +1,24 @@ +:host { + display: block; +} + +.ticket-event-context { + padding-top: 1.5rem; + border-top: 1px solid var(--border-color); +} + +.ticket-event-context__image { + display: block; + width: 100%; + height: auto; + border-radius: 4px; +} + +.ticket-event-context__title { + margin: 1rem 0 0; + color: #666666; + font-size: 13px; + font-weight: 700; + line-height: 1.4; + text-transform: uppercase; +} diff --git a/src/app/features/store/pages/account-page/pages/tickets-page/components/ticket-event-context/ticket-event-context.component.ts b/src/app/features/store/pages/account-page/pages/tickets-page/components/ticket-event-context/ticket-event-context.component.ts new file mode 100644 index 0000000..d4df605 --- /dev/null +++ b/src/app/features/store/pages/account-page/pages/tickets-page/components/ticket-event-context/ticket-event-context.component.ts @@ -0,0 +1,15 @@ +import { Component, input } from '@angular/core'; + +export interface TicketEventContext { + title: string; + image: string | null; +} + +@Component({ + selector: 'app-ticket-event-context', + templateUrl: './ticket-event-context.component.html', + styleUrl: './ticket-event-context.component.scss', +}) +export class TicketEventContextComponent { + readonly event = input.required(); +} diff --git a/src/app/features/store/pages/account-page/pages/tickets-page/ticket.service.ts b/src/app/features/store/pages/account-page/pages/tickets-page/ticket.service.ts index 2e73168..95f10ee 100644 --- a/src/app/features/store/pages/account-page/pages/tickets-page/ticket.service.ts +++ b/src/app/features/store/pages/account-page/pages/tickets-page/ticket.service.ts @@ -7,6 +7,7 @@ import { TenantService } from '../../../../../../core/services/tenant.service'; export interface TicketResponse { id: number; tenant_code: string; + event_id: number | null; ticket: string; name: string; description: string | null; @@ -22,13 +23,29 @@ export interface TicketResponse { is_used: boolean; } +export interface TicketEventResponse { + id: number; + title: string; + image: string | null; +} + @Injectable() export class TicketService extends BaseApiService { private readonly tenantService = inject(TenantService); - getTickets(): Promise { + getTickets(eventId?: number): Promise { return firstValueFrom( - this.http.get<{ data: TicketResponse[] }>(`${this.tenantService.getTenantApiUrl()}/tickets`), + this.http.get<{ data: TicketResponse[] }>(`${this.tenantService.getTenantApiUrl()}/tickets`, { + params: eventId === undefined ? {} : { event_id: eventId }, + }), + ).then((response) => response.data ?? []); + } + + getTicketEvents(): Promise { + return firstValueFrom( + this.http.get<{ data: TicketEventResponse[] }>( + `${this.tenantService.getTenantApiUrl()}/tickets/events`, + ), ).then((response) => response.data ?? []); } diff --git a/src/app/features/store/pages/account-page/pages/tickets-page/tickets-page.html b/src/app/features/store/pages/account-page/pages/tickets-page/tickets-page.html index 55f990b..953287e 100644 --- a/src/app/features/store/pages/account-page/pages/tickets-page/tickets-page.html +++ b/src/app/features/store/pages/account-page/pages/tickets-page/tickets-page.html @@ -109,3 +109,9 @@ } + +@if (eventId !== undefined) { + + + +} diff --git a/src/app/features/store/pages/account-page/pages/tickets-page/tickets-page.spec.ts b/src/app/features/store/pages/account-page/pages/tickets-page/tickets-page.spec.ts index cebca14..c73cbd1 100644 --- a/src/app/features/store/pages/account-page/pages/tickets-page/tickets-page.spec.ts +++ b/src/app/features/store/pages/account-page/pages/tickets-page/tickets-page.spec.ts @@ -1,24 +1,29 @@ import { signal } from '@angular/core'; import { By } from '@angular/platform-browser'; import { TestBed } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { TenantService } from '../../../../../../core/services/tenant.service'; import { ToastService } from '../../../../../../core/services/toast.service'; import { ModalService } from '../../../../../../core/services/modal.service'; +import { MenuPageSidebarContentService } from '../../../../components/menu-page-layout/menu-page-sidebar-content.service'; import { TicketComponent } from './components/ticket/ticket.component'; import { TicketResponse, TicketService } from './ticket.service'; import { TicketsPage } from './tickets-page'; function withCustomLoading(service: T): T & { withCustomLoading: () => T } { - return Object.assign(service, { - withCustomLoading: () => service, + const enhancedService = Object.assign({ getTicketEvents: () => Promise.resolve([]) }, service); + + return Object.assign(enhancedService, { + withCustomLoading: () => enhancedService, }); } const ticket = (overrides: Partial): TicketResponse => ({ id: 1, tenant_code: 'tenant', + event_id: null, ticket: 'ticket-1', name: 'Entrada general', description: null, @@ -299,4 +304,69 @@ describe('TicketsPage', () => { ticket: 'ticket-1', }); }); + + it('declares contextual test data when tickets belong to an event', async () => { + const getTickets = vi.fn().mockResolvedValue([]); + const getTicketEvents = vi.fn().mockResolvedValue([ + { + id: 42, + title: 'XX Campeonato Mundial de Pelota Vasca', + image: 'https://example.com/pelota-vasca.jpg', + }, + ]); + + await TestBed.configureTestingModule({ + imports: [TicketsPage], + providers: [ + MenuPageSidebarContentService, + { provide: ToastService, useValue: { danger: vi.fn() } }, + { + provide: ActivatedRoute, + useValue: { snapshot: { paramMap: { get: () => '42' } } }, + }, + ], + }) + .overrideComponent(TicketsPage, { + set: { + providers: [ + { + provide: TicketService, + useValue: withCustomLoading({ getTickets, getTicketEvents }), + }, + ], + }, + }) + .compileComponents(); + + const fixture = TestBed.createComponent(TicketsPage); + fixture.detectChanges(); + await fixture.whenStable(); + await vi.waitFor(() => expect(getTicketEvents).toHaveBeenCalledOnce()); + await vi.waitFor(() => + expect( + ( + fixture.componentInstance as unknown as { eventContext: () => { image: string | null } } + ).eventContext().image, + ).toBe('https://example.com/pelota-vasca.jpg'), + ); + + const sidebarContent = TestBed.inject(MenuPageSidebarContentService); + const sidebarView = sidebarContent.template()?.createEmbeddedView({}); + sidebarView?.detectChanges(); + + const container = document.createElement('div'); + sidebarView?.rootNodes.forEach((node) => container.appendChild(node)); + + expect(getTickets).toHaveBeenCalledWith(42); + expect(container.querySelector('.ticket-event-context')?.textContent).toContain( + 'XX Campeonato Mundial de Pelota Vasca', + ); + expect(container.querySelector('.ticket-event-context__image')?.src).toBe( + 'https://example.com/pelota-vasca.jpg', + ); + expect(container.querySelector('.ticket-event-context__details')).toBeNull(); + expect(container.querySelector('.ticket-event-context__recommendation')).toBeNull(); + + sidebarView?.destroy(); + }); }); diff --git a/src/app/features/store/pages/account-page/pages/tickets-page/tickets-page.ts b/src/app/features/store/pages/account-page/pages/tickets-page/tickets-page.ts index affdf19..92ccff8 100644 --- a/src/app/features/store/pages/account-page/pages/tickets-page/tickets-page.ts +++ b/src/app/features/store/pages/account-page/pages/tickets-page/tickets-page.ts @@ -1,15 +1,27 @@ import { Component, computed, inject, OnInit, signal } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; import { ModalService } from '../../../../../../core/services/modal.service'; import { ToastService } from '../../../../../../core/services/toast.service'; import { IconButtonComponent } from '../../../../../../shared/components/icon-button/icon-button.component'; import { MenuContentSectionComponent } from '../../../../components/menu-content-section/menu-content-section.component'; +import { MenuPageSidebarContentDirective } from '../../../../components/menu-page-layout/menu-page-sidebar-content.directive'; +import { + TicketEventContext, + TicketEventContextComponent, +} from './components/ticket-event-context/ticket-event-context.component'; import { TicketComponent } from './components/ticket/ticket.component'; import { TicketResponse, TicketService } from './ticket.service'; @Component({ selector: 'app-tickets-page', - imports: [IconButtonComponent, TicketComponent, MenuContentSectionComponent], + imports: [ + IconButtonComponent, + TicketComponent, + TicketEventContextComponent, + MenuContentSectionComponent, + MenuPageSidebarContentDirective, + ], providers: [TicketService], templateUrl: './tickets-page.html', styleUrl: './tickets-page.scss', @@ -18,7 +30,13 @@ export class TicketsPage implements OnInit { private readonly ticketService = inject(TicketService); private readonly toastService = inject(ToastService); private readonly modalService = inject(ModalService); + private readonly route = inject(ActivatedRoute, { optional: true }); + protected readonly eventId = this.eventIdFromRoute(); + protected readonly eventContext = signal({ + title: 'XX Campeonato Mundial de Pelota Vasca', + image: null, + }); protected readonly tickets = signal([]); protected readonly activeTickets = computed(() => this.tickets().filter((ticket) => !this.isInactive(ticket)), @@ -36,7 +54,25 @@ export class TicketsPage implements OnInit { async ngOnInit(): Promise { try { - this.tickets.set(await this.ticketService.withCustomLoading().getTickets()); + const service = this.ticketService.withCustomLoading(); + this.tickets.set(await service.getTickets(this.eventId)); + + if (this.eventId !== undefined) { + void service + .getTicketEvents() + .then((events) => { + const event = events.find((candidate) => candidate.id === this.eventId); + + if (event) { + this.eventContext.update((context) => ({ + ...context, + title: event.title, + image: event.image, + })); + } + }) + .catch(() => undefined); + } } catch { this.toastService.danger('Hubo un error al cargar los tickets'); } finally { @@ -44,6 +80,13 @@ export class TicketsPage implements OnInit { } } + private eventIdFromRoute(): number | undefined { + const value = this.route?.snapshot.paramMap.get('eventId'); + const eventId = value === null || value === undefined ? Number.NaN : Number(value); + + return Number.isInteger(eventId) && eventId > 0 ? eventId : undefined; + } + protected toggleAll(selected: boolean): void { this.selectedIds.set( selected ? new Set(this.activeTickets().map((ticket) => ticket.id)) : new Set(), diff --git a/src/app/features/store/pages/checkout-page/checkout-page.component.html b/src/app/features/store/pages/checkout-page/checkout-page.component.html index 27c6d57..7207835 100644 --- a/src/app/features/store/pages/checkout-page/checkout-page.component.html +++ b/src/app/features/store/pages/checkout-page/checkout-page.component.html @@ -47,7 +47,26 @@
- @if (checkoutRemainingTime(); as remainingTime) { + @if (usesEventCheckoutSummary()) { + @if (checkoutRemainingTime(); as remainingTime) { +
+ Tiempo restante de compra: + {{ remainingTime }} +
+ } + + @if (checkoutEvent(); as event) { + @if (event.image) { +
+ +
+ } + } + } @else if (checkoutRemainingTime(); as remainingTime) {
{ 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 }); diff --git a/src/app/features/store/pages/checkout-page/checkout-page.component.ts b/src/app/features/store/pages/checkout-page/checkout-page.component.ts index 25201a7..909324e 100644 --- a/src/app/features/store/pages/checkout-page/checkout-page.component.ts +++ b/src/app/features/store/pages/checkout-page/checkout-page.component.ts @@ -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 = [ @@ -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), diff --git a/src/app/features/store/pages/event-category-page/event-category-page.component.ts b/src/app/features/store/pages/event-category-page/event-category-page.component.ts new file mode 100644 index 0000000..6ba68b6 --- /dev/null +++ b/src/app/features/store/pages/event-category-page/event-category-page.component.ts @@ -0,0 +1,33 @@ +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { TenantService } from '../../../../core/services/tenant.service'; +import { EventListComponent } from '../../components/event-list/event-list.component'; + +@Component({ + selector: 'app-event-category-page', + imports: [EventListComponent], + template: ` + @if (category(); as selectedCategory) { +
+

{{ selectedCategory.nombre }}

+ +
+ } @else { +

La categoría no existe.

+ } + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class EventCategoryPageComponent { + private readonly route = inject(ActivatedRoute); + private readonly tenant = inject(TenantService).tenant; + private readonly params = toSignal(this.route.paramMap, { + initialValue: this.route.snapshot.paramMap, + }); + + protected readonly category = computed(() => { + const id = Number(this.params().get('id')); + return this.tenant()?.event_categories?.find((category) => category.id === id) ?? null; + }); +} diff --git a/src/app/features/store/pages/event-detail-page/event-detail-page.component.html b/src/app/features/store/pages/event-detail-page/event-detail-page.component.html new file mode 100644 index 0000000..57ce4a7 --- /dev/null +++ b/src/app/features/store/pages/event-detail-page/event-detail-page.component.html @@ -0,0 +1,113 @@ +@if (event(); as item) { +
+ + +
+
+

{{ item.title }}

+ @if (item.subtitle) {

{{ item.subtitle }}

} +
+ @if (item.date_text) { +

+ {{ item.date_text }}@if (item.dates?.length === 1 && item.start_time) { · {{ item.start_time.slice(0, 5) }} h } +

+ } + @if (item.location) { +

{{ item.location }}

+ } +
+
+ +
+

Productos

+ + @if ((item.dates?.length ?? 0) > 1) { + + + } + + @if (item.catalog_items?.length) { +
+ @for (product of item.catalog_items; track product.id) { +
+

{{ product.name }}

+
+ @if (needsSelection(product)) { + Elegir opciones + } @else { + + } +
+ {{ priceFor(product) }} +
+ } +
+
Total{{ formatPrice(total()) }}
+
+ Cancelar + + {{ submitting() ? 'Procesando…' : 'Comprar' }} + +
+ } @else { +

Por ahora no hay productos disponibles para este evento.

+ } +
+
+ + +
+} @else if (!loading()) { +

No encontramos este evento.

+} diff --git a/src/app/features/store/pages/event-detail-page/event-detail-page.component.scss b/src/app/features/store/pages/event-detail-page/event-detail-page.component.scss new file mode 100644 index 0000000..78f4da4 --- /dev/null +++ b/src/app/features/store/pages/event-detail-page/event-detail-page.component.scss @@ -0,0 +1,76 @@ +:host { display: block; color: #666666; } +.event-detail { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); column-gap: 1.5rem; margin-bottom: 3rem; } +.event-detail__sidebar, .event-detail__main { min-width: 0; } +.event-detail__sidebar { grid-column: 1; grid-row: 1; } +.event-detail__main { grid-column: 2; grid-row: 1 / span 2; } +.event-detail__details { min-width: 0; grid-column: 1; grid-row: 2; } +.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: 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; } +.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 (min-width: 992px) { + .event-detail { column-gap: 3rem; } +} +@media (max-width: 575.98px) { + .event-detail { grid-template-columns: minmax(0, 1fr); } + .event-detail__sidebar, .event-detail__main, .event-detail__details { grid-column: 1; grid-row: auto; } + .event-detail__main { margin-top: 2rem; } + .event-detail__date-select { width: 100%; } + .event-detail__description, .event-detail__social { margin-top: 1.5rem; } + .event-detail__header { min-height: 0; margin-bottom: 2rem; } + .event-detail__header h1 { font-size: 20px; } + .event-detail__product-list { gap: 42px; } + .event-detail__product { + display: grid; + grid-template-columns: minmax(0, 1fr) 96px 72px; + column-gap: 26px; + margin-inline: 0; + } + .event-detail__product > * { width: auto; margin-top: 0; padding-inline: 0; } + .event-detail__product h3 { align-self: center; font-size: 13px; } + .event-detail__product-action { margin-left: 0 !important; } + .event-detail__product-action app-quantity-selector { + --quantity-selector-width: 96px; + --quantity-selector-height: 32px; + --quantity-selector-button-width: 28px; + --quantity-selector-button-font-size: 14px; + --quantity-selector-value-font-size: 14px; + } + .event-detail__price { align-self: center; font-size: 14px; } + .event-detail__total { margin-top: 44px; padding-top: 25px; font-size: 13px; } + .event-detail__total strong { font-size: 20px; } + .event-detail__buttons { gap: 10px; margin-top: 44px; } + .event-detail__buttons app-button { + --app-button-min-height: 50px; + flex: 1 1 0; + } +} diff --git a/src/app/features/store/pages/event-detail-page/event-detail-page.component.ts b/src/app/features/store/pages/event-detail-page/event-detail-page.component.ts new file mode 100644 index 0000000..e2fc382 --- /dev/null +++ b/src/app/features/store/pages/event-detail-page/event-detail-page.component.ts @@ -0,0 +1,196 @@ +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, Router, RouterLink } from '@angular/router'; +import { catchError, map, of, switchMap } from 'rxjs'; + +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[number]; + +@Component({ + selector: 'app-event-detail-page', + 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(null); + protected readonly loading = signal(true); + protected readonly selectedDateId = signal(null); + protected readonly quantities = signal>({}); + protected readonly submitting = signal(false); + protected readonly mapLocations = computed(() => { + 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)); + protected readonly hasSelectedItems = computed(() => + Object.values(this.quantities()).some((quantity) => quantity > 0), + ); + + constructor() { + this.route.paramMap.pipe( + map((params) => params.get('slug')?.trim() ?? ''), + switchMap((slug) => slug + ? this.events.get(slug).pipe(map((response) => response.data), catchError(() => of(null))) + : of(null)), + 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 = { + 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 { + this.quantities.set({}); + } + + protected async buyNow(): Promise { + 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, + }; + } +} diff --git a/src/app/features/store/pages/event-home-page/event-home-page.component.ts b/src/app/features/store/pages/event-home-page/event-home-page.component.ts new file mode 100644 index 0000000..643423b --- /dev/null +++ b/src/app/features/store/pages/event-home-page/event-home-page.component.ts @@ -0,0 +1,10 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { EventListComponent } from '../../components/event-list/event-list.component'; + +@Component({ + selector: 'app-event-home-page', + imports: [EventListComponent], + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class EventHomePageComponent {} diff --git a/src/app/features/store/pages/event-search-page/event-search-page.component.ts b/src/app/features/store/pages/event-search-page/event-search-page.component.ts new file mode 100644 index 0000000..49071ad --- /dev/null +++ b/src/app/features/store/pages/event-search-page/event-search-page.component.ts @@ -0,0 +1,10 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { EventListComponent } from '../../components/event-list/event-list.component'; + +@Component({ + selector: 'app-event-search-page', + imports: [EventListComponent], + template: '

Resultados de búsqueda

', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class EventSearchPageComponent {} diff --git a/src/app/features/store/pages/help-page/pages/contact-page/contact-page.html b/src/app/features/store/pages/help-page/pages/contact-page/contact-page.html index 102e263..39f84e1 100644 --- a/src/app/features/store/pages/help-page/pages/contact-page/contact-page.html +++ b/src/app/features/store/pages/help-page/pages/contact-page/contact-page.html @@ -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" /> } diff --git a/src/app/features/store/pages/help-page/pages/contact-page/contact-page.spec.ts b/src/app/features/store/pages/help-page/pages/contact-page/contact-page.spec.ts index 0ac1315..92982df 100644 --- a/src/app/features/store/pages/help-page/pages/contact-page/contact-page.spec.ts +++ b/src/app/features/store/pages/help-page/pages/contact-page/contact-page.spec.ts @@ -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'); diff --git a/src/app/features/store/pages/store-home-page/store-home-page.component.spec.ts b/src/app/features/store/pages/store-home-page/store-home-page.component.spec.ts index 47e3c14..2cf8555 100644 --- a/src/app/features/store/pages/store-home-page/store-home-page.component.spec.ts +++ b/src/app/features/store/pages/store-home-page/store-home-page.component.spec.ts @@ -107,16 +107,7 @@ function createTenant(extras: Tenant['extras'] = {}): Tenant { footer_bg_color: '#ffffff', header_logo: '/header.png', footer_logo: '/footer.png', - website_type_code: 'shopit', - website_type: { - codigo: 'shopit', - nombre: 'ShopIt', - primary_color: null, - secondary_color: null, - danger_color: null, - success_color: null, - site_logo: null, - }, + storefront_website_type_code: 'shopit', extras, categories: [], }; @@ -225,8 +216,20 @@ describe('StoreHomePageComponent', () => { }, }); tenant.event = { + id: 1, title: 'Fiesta Fútbol Infantil', + subtitle: null, + description: null, location: 'Rosario, Santa Fe', + date_text: '5 y 6 de Diciembre 2026', + social_media: [ + { + code: 'whatsapp', + icon: 'fa-brands fa-whatsapp', + name: 'WhatsApp', + url: 'https://wa.me/5493410000000', + }, + ], dates: [ { id: 20, @@ -246,25 +249,7 @@ describe('StoreHomePageComponent', () => { }, ], }; - tenant.event_date_text = '5 y 6 de Diciembre 2026'; - tenant.website_type_code = 'onticket'; - tenant.social_media = [ - { - code: 'whatsapp', - icon: 'fa-brands fa-whatsapp', - name: 'WhatsApp', - url: 'https://wa.me/5493410000000', - }, - ]; - tenant.website_type = { - codigo: 'onticket', - nombre: 'OnTicket', - primary_color: null, - secondary_color: null, - danger_color: null, - success_color: null, - site_logo: null, - }; + tenant.storefront_website_type_code = 'onticket'; await TestBed.configureTestingModule({ imports: [StoreHomePageComponent], @@ -297,7 +282,7 @@ describe('StoreHomePageComponent', () => { expect(hero.textContent).toContain('5 y 6 de Diciembre 2026'); const heroComponent = fixture.debugElement.query(By.directive(HeroBannerComponent)) .componentInstance as HeroBannerComponent; - expect(heroComponent.eventConfig?.contact).toEqual(tenant.social_media); + expect(heroComponent.eventConfig?.contact).toEqual(tenant.event.social_media); }); it('renders additional information as an HTML section', async () => { @@ -365,8 +350,13 @@ describe('StoreHomePageComponent', () => { it('renders only the active event data when the tenant does not have the hero extra', async () => { const tenant = createTenant(); tenant.event = { + id: 2, title: 'Fiesta Fútbol Infantil', + subtitle: null, + description: null, location: 'Sunchales, Santa Fe', + date_text: null, + social_media: [], dates: [ { id: 25, diff --git a/src/app/features/store/pages/store-home-page/store-home-page.component.ts b/src/app/features/store/pages/store-home-page/store-home-page.component.ts index 45e9c93..e33f14f 100644 --- a/src/app/features/store/pages/store-home-page/store-home-page.component.ts +++ b/src/app/features/store/pages/store-home-page/store-home-page.component.ts @@ -75,15 +75,14 @@ export class StoreHomePageComponent implements OnInit, OnDestroy { ); protected readonly eventConfig = computed(() => { const tenant = this.tenant(); - const contact = tenant?.social_media ?? []; const event = tenant?.event; if (event) { return { - id: tenant.id, + id: event.id, title: event.title, location: event.location, - dates_text: tenant.event_date_text, + dates_text: event.date_text, dates: event.dates.map((eventDate) => ({ id: eventDate.id, date: eventDate.date, @@ -92,7 +91,7 @@ export class StoreHomePageComponent implements OnInit, OnDestroy { info_text: eventDate.info_text, isCanceled: eventDate.isCanceled, })), - contact, + contact: event.social_media, }; } diff --git a/src/app/features/store/store.routes.ts b/src/app/features/store/store.routes.ts index dc5719e..ff49c0c 100644 --- a/src/app/features/store/store.routes.ts +++ b/src/app/features/store/store.routes.ts @@ -1,4 +1,5 @@ -import { Routes } from '@angular/router'; +import { inject } from '@angular/core'; +import { CanMatchFn, Routes } from '@angular/router'; import { SimpleLayoutComponent } from '../../core/layout/simple-layout/simple-layout.component'; import { StoreLayoutComponent } from '../../core/layout/store-layout/store-layout.component'; @@ -13,6 +14,10 @@ import { RegisterPageComponent } from './pages/register-page/register-page.compo import { StoreHomePageComponent } from './pages/store-home-page/store-home-page.component'; import { storeHomeCatalogResolver } from './pages/store-home-page/store-home-page.resolver'; import { checkoutPendingPurchaseGuard } from './pages/checkout-page/checkout-page.guard'; +import { TenantService } from '../../core/services/tenant.service'; + +const isMultiEventStorefront: CanMatchFn = () => + inject(TenantService).tenant()?.storefront_website_type_code === 'onticket_multi_event'; export const routes: Routes = [ { @@ -21,6 +26,17 @@ export const routes: Routes = [ children: [ { path: '', + pathMatch: 'full', + canMatch: [isMultiEventStorefront], + canActivate: [hasMenuGuard('event.index')], + loadComponent: () => + import('./pages/event-home-page/event-home-page.component').then( + (m) => m.EventHomePageComponent, + ), + }, + { + path: '', + pathMatch: 'full', component: StoreHomePageComponent, canActivate: [hasMenuGuard('index')], resolve: { @@ -68,13 +84,40 @@ export const routes: Routes = [ }, ], }, + { + path: 'buscar', + canMatch: [isMultiEventStorefront], + loadComponent: () => + import('./pages/event-search-page/event-search-page.component').then( + (m) => m.EventSearchPageComponent, + ), + }, { path: 'buscar', loadComponent: () => import('./pages/search-page/search-page.component').then((m) => m.SearchPageComponent), }, + { + path: 'evento/:slug', + canMatch: [isMultiEventStorefront], + canActivate: [hasMenuGuard('event.detail')], + loadComponent: () => + import('./pages/event-detail-page/event-detail-page.component').then( + (m) => m.EventDetailPageComponent, + ), + }, + { + path: 'eventos/categoria/:id', + canMatch: [isMultiEventStorefront], + canActivate: [hasMenuGuard('event.category')], + loadComponent: () => + import('./pages/event-category-page/event-category-page.component').then( + (m) => m.EventCategoryPageComponent, + ), + }, { path: 'categoria/:id', + canActivate: [hasMenuGuard('product.category')], loadComponent: () => import('./pages/category-items-page/category-items-page.component').then( (m) => m.CategoryItemsPageComponent, @@ -188,6 +231,24 @@ export const routes: Routes = [ (m) => m.PurchaseDetailPage, ), }, + { + path: 'tickets/:eventId', + canMatch: [isMultiEventStorefront], + canActivate: [hasMenuGuard('account.tickets')], + loadComponent: () => + import('./pages/account-page/pages/tickets-page/tickets-page').then( + (m) => m.TicketsPage, + ), + }, + { + path: 'tickets', + canMatch: [isMultiEventStorefront], + canActivate: [hasMenuGuard('account.tickets')], + loadComponent: () => + import('./pages/account-page/pages/ticket-events-page/ticket-events-page').then( + (m) => m.TicketEventsPage, + ), + }, { path: 'tickets', canActivate: [hasMenuGuard('account.tickets')], diff --git a/src/app/shared/components/button/button.component.scss b/src/app/shared/components/button/button.component.scss index 6a50f05..fc4f1cc 100644 --- a/src/app/shared/components/button/button.component.scss +++ b/src/app/shared/components/button/button.component.scss @@ -4,12 +4,18 @@ } .btn { - min-height: 40px; + min-height: var(--app-button-min-height, 40px); font-weight: 700; font-size: 15px; width: 100%; } +@media (max-width: 767.98px) { + .btn { + font-size: 11px; + } +} + .btn-primary, .btn-secondary, .btn-danger { diff --git a/src/app/shared/components/continuous-image-carousel/continuous-image-carousel.component.html b/src/app/shared/components/continuous-image-carousel/continuous-image-carousel.component.html new file mode 100644 index 0000000..9806bdd --- /dev/null +++ b/src/app/shared/components/continuous-image-carousel/continuous-image-carousel.component.html @@ -0,0 +1,20 @@ +@if (trackImages().length) { + +} diff --git a/src/app/shared/components/continuous-image-carousel/continuous-image-carousel.component.scss b/src/app/shared/components/continuous-image-carousel/continuous-image-carousel.component.scss new file mode 100644 index 0000000..6d1f6e9 --- /dev/null +++ b/src/app/shared/components/continuous-image-carousel/continuous-image-carousel.component.scss @@ -0,0 +1,73 @@ +: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; + margin-left: var(--carousel-offset, 0px); + animation: continuous-carousel-scroll var(--duration) linear infinite; +} + +.continuous-carousel__set { + display: flex; + flex: none; + gap: var(--carousel-gap, var(--gap)); + padding-right: var(--carousel-gap, var(--gap)); +} + +.continuous-carousel__image { + display: block; + flex: none; + width: var(--carousel-card-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; + margin-left: 0; + } + .continuous-carousel__set[aria-hidden='true'] { + display: none; + } +} diff --git a/src/app/shared/components/continuous-image-carousel/continuous-image-carousel.component.ts b/src/app/shared/components/continuous-image-carousel/continuous-image-carousel.component.ts new file mode 100644 index 0000000..8ae99c5 --- /dev/null +++ b/src/app/shared/components/continuous-image-carousel/continuous-image-carousel.component.ts @@ -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 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)); +} diff --git a/src/app/shared/components/counter-to/counter-to.component.html b/src/app/shared/components/counter-to/counter-to.component.html new file mode 100644 index 0000000..f90194d --- /dev/null +++ b/src/app/shared/components/counter-to/counter-to.component.html @@ -0,0 +1,12 @@ +@if (countdown(); as timer) { +
+ @if (timer.finished) { + El evento comenzó + } @else { +
{{ timer.days }}DÍAS
+
{{ timer.hours }}HORAS
+
{{ timer.minutes }}MINUTOS
+
{{ timer.seconds }}SEGUNDOS
+ } +
+} diff --git a/src/app/shared/components/counter-to/counter-to.component.scss b/src/app/shared/components/counter-to/counter-to.component.scss new file mode 100644 index 0000000..18e82b4 --- /dev/null +++ b/src/app/shared/components/counter-to/counter-to.component.scss @@ -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 #a0a0a0; 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; } diff --git a/src/app/shared/components/counter-to/counter-to.component.ts b/src/app/shared/components/counter-to/counter-to.component.ts new file mode 100644 index 0000000..05eb62b --- /dev/null +++ b/src/app/shared/components/counter-to/counter-to.component.ts @@ -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(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)); + } + } +} diff --git a/src/app/shared/components/horizontal-carousel/horizontal-carousel.component.html b/src/app/shared/components/horizontal-carousel/horizontal-carousel.component.html new file mode 100644 index 0000000..ebc1f8b --- /dev/null +++ b/src/app/shared/components/horizontal-carousel/horizontal-carousel.component.html @@ -0,0 +1,63 @@ + diff --git a/src/app/shared/components/horizontal-carousel/horizontal-carousel.component.scss b/src/app/shared/components/horizontal-carousel/horizontal-carousel.component.scss new file mode 100644 index 0000000..3486d86 --- /dev/null +++ b/src/app/shared/components/horizontal-carousel/horizontal-carousel.component.scss @@ -0,0 +1,71 @@ +:host { + display: block; + min-width: 0; +} +.carousel { + display: flex; + align-items: center; + min-width: 0; +} +.carousel__viewport { + container-type: inline-size; + flex: 1; + min-width: 0; + overflow-x: auto; + scrollbar-width: none; + overflow-anchor: none; + scroll-snap-type: var(--horizontal-carousel-snap, none); +} +.carousel__viewport::-webkit-scrollbar { + display: none; +} +.carousel__track { + display: flex; + width: max-content; + min-width: 100%; + justify-content: center; +} +.carousel__set { + position: relative; + display: flex; + flex: none; + align-items: center; + gap: var(--horizontal-carousel-gap, 1.5rem); +} +.carousel--looping .carousel__set { + padding-right: var(--horizontal-carousel-gap, 1.5rem); +} +.carousel__item, +.carousel__arrow { + flex: none; + min-height: 44px; + border: 0; + padding: 0; + background: transparent; + color: inherit; + font: inherit; + cursor: pointer; +} +.carousel__item { + width: var(--horizontal-carousel-item-width, auto); + white-space: var(--horizontal-carousel-white-space, nowrap); + overflow-wrap: anywhere; + scroll-snap-align: start; + text-transform: var(--horizontal-carousel-text-transform, none); +} +.carousel__arrow { + width: 32px; + font-size: 10px; +} +.carousel__arrow:disabled { + opacity: 0.35; + cursor: default; +} +.carousel__item:hover { + text-decoration: underline; +} +.carousel__item:focus-visible, +.carousel__arrow:focus-visible { + outline: 2px solid currentColor; + outline-offset: -2px; +} diff --git a/src/app/shared/components/horizontal-carousel/horizontal-carousel.component.spec.ts b/src/app/shared/components/horizontal-carousel/horizontal-carousel.component.spec.ts new file mode 100644 index 0000000..fffe88e --- /dev/null +++ b/src/app/shared/components/horizontal-carousel/horizontal-carousel.component.spec.ts @@ -0,0 +1,180 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { HorizontalCarouselComponent } from './horizontal-carousel.component'; + +describe('HorizontalCarouselComponent', () => { + let fixture: ComponentFixture; + let host: HTMLElement; + let available: number; + let resize: () => void; + const disconnect = vi.fn(); + const items = Array.from({ length: 5 }, (_, index) => ({ value: index, label: `Item ${index}` })); + const viewport = () => host.querySelector('.carousel__viewport')!; + const period = () => + host.querySelector('.carousel__set')!.getBoundingClientRect().width; + + beforeEach(async () => { + available = 320; + vi.stubGlobal( + 'ResizeObserver', + class { + constructor(callback: () => void) { + resize = callback; + } + observe() {} + disconnect = disconnect; + }, + ); + vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(function ( + this: HTMLElement, + ) { + return this.classList.contains('carousel__viewport') ? available - 64 : available; + }); + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function ( + this: HTMLElement, + ) { + const index = this.parentElement ? Array.from(this.parentElement.children).indexOf(this) : 0; + const left = this.classList.contains('carousel__item') ? index * 124.25 : 0; + const width = this.classList.contains('carousel__set') + ? Math.max( + 0, + this.children.length * 124.25 - (this.closest('.carousel--looping') ? 0 : 24.25), + ) + : 100; + return { + left, + right: left + width, + width, + top: 0, + bottom: 44, + height: 44, + x: left, + y: 0, + toJSON() {}, + }; + }); + vi.stubGlobal( + 'matchMedia', + vi.fn(() => ({ matches: true })), + ); + // jsdom has no layout or scrolling implementation. + Object.defineProperty(HTMLElement.prototype, 'scrollTo', { + configurable: true, + value: vi.fn(function (this: HTMLElement, options: ScrollToOptions) { + this.scrollLeft = options.left ?? 0; + }), + }); + Object.defineProperty(HTMLElement.prototype, 'scrollBy', { + configurable: true, + value: vi.fn(function (this: HTMLElement, options: ScrollToOptions) { + this.scrollLeft += options.left ?? 0; + }), + }); + Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { + configurable: true, + value: vi.fn(), + }); + await TestBed.configureTestingModule({ + imports: [HorizontalCarouselComponent], + }).compileComponents(); + fixture = TestBed.createComponent(HorizontalCarouselComponent); + fixture.componentRef.setInput('items', items); + fixture.componentRef.setInput('ariaLabel', 'Opciones'); + host = fixture.nativeElement; + fixture.detectChanges(); + await fixture.whenStable(); + }); + + afterEach(() => { + fixture.destroy(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('starts in the middle with only one accessible set', () => { + expect(host.querySelectorAll('.carousel__set')).toHaveLength(3); + expect(host.querySelectorAll('[aria-hidden="true"] .carousel__item')).toHaveLength(10); + expect(host.querySelectorAll('.carousel__item:not([tabindex="-1"])')).toHaveLength(5); + expect(viewport().scrollLeft).toBe(period()); + }); + + it('wraps both directions preserving the exact fractional offset', () => { + const width = period(); + viewport().scrollLeft = width - 30.5; + viewport().dispatchEvent(new Event('scrollend')); + expect(viewport().scrollLeft).toBe(2 * width - 30.5); + viewport().scrollLeft = 2 * width + 42.5; + viewport().dispatchEvent(new Event('scrollend')); + expect(viewport().scrollLeft).toBe(width + 42.5); + }); + + it('uses the idle fallback after a gesture', () => { + vi.useFakeTimers(); + viewport().scrollLeft = 12; + viewport().dispatchEvent(new Event('scroll')); + vi.advanceTimersByTime(180); + expect(viewport().scrollLeft).toBe(period() + 12); + vi.useRealTimers(); + }); + + it('arrows respect reduced motion', () => { + host.querySelectorAll('.carousel__arrow')[1].click(); + expect(viewport().scrollBy).toHaveBeenCalledWith({ + left: (available - 64) * 0.8, + behavior: 'instant', + }); + }); + + it('selects the original value when clicking a copy', () => { + const selected = vi.fn(); + fixture.componentInstance.itemSelect.subscribe(selected); + host.querySelector('[aria-hidden="true"] .carousel__item')!.click(); + expect(selected).toHaveBeenCalledWith(items[0]); + }); + + it('removes copies and arrows when resized to fit, then restores them', async () => { + available = 1000; + resize(); + fixture.detectChanges(); + await fixture.whenStable(); + expect(host.querySelectorAll('.carousel__set')).toHaveLength(1); + expect(host.querySelectorAll('.carousel__arrow')).toHaveLength(0); + available = 320; + resize(); + fixture.detectChanges(); + await fixture.whenStable(); + expect(host.querySelectorAll('.carousel__set')).toHaveLength(3); + expect(viewport().scrollLeft).toBe(period()); + }); + + it('keeps arrows with three visible slots and advances one item', async () => { + host.style.setProperty('--horizontal-carousel-visible-items', '3'); + resize(); + fixture.detectChanges(); + await fixture.whenStable(); + host.querySelectorAll('.carousel__set').forEach((set) => { + set.style.columnGap = '8px'; + }); + host.querySelectorAll('.carousel__arrow')[1].click(); + expect(viewport().scrollBy).toHaveBeenCalledWith({ left: 108, behavior: 'instant' }); + + fixture.componentRef.setInput('items', items.slice(0, 3)); + fixture.detectChanges(); + await fixture.whenStable(); + expect(host.querySelectorAll('.carousel__set')).toHaveLength(1); + expect(host.querySelectorAll('.carousel__arrow:disabled')).toHaveLength(2); + }); + + it('supports noncircular overflow and empty lists', async () => { + fixture.componentRef.setInput('circular', false); + fixture.detectChanges(); + await fixture.whenStable(); + expect(host.querySelectorAll('.carousel__set')).toHaveLength(1); + expect(host.querySelectorAll('.carousel__arrow')).toHaveLength(2); + fixture.componentRef.setInput('items', []); + fixture.detectChanges(); + await fixture.whenStable(); + expect(host.querySelectorAll('.carousel__arrow')).toHaveLength(0); + expect(host.querySelectorAll('.carousel__item')).toHaveLength(0); + }); +}); diff --git a/src/app/shared/components/horizontal-carousel/horizontal-carousel.component.ts b/src/app/shared/components/horizontal-carousel/horizontal-carousel.component.ts new file mode 100644 index 0000000..3ddee4e --- /dev/null +++ b/src/app/shared/components/horizontal-carousel/horizontal-carousel.component.ts @@ -0,0 +1,154 @@ +import { + afterNextRender, + afterRenderEffect, + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + ElementRef, + inject, + input, + output, + signal, + viewChild, +} from '@angular/core'; + +export interface HorizontalCarouselItem { + /** Unique within the carousel. */ + value: string | number; + label: string; +} + +@Component({ + selector: 'app-horizontal-carousel', + templateUrl: './horizontal-carousel.component.html', + styleUrl: './horizontal-carousel.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class HorizontalCarouselComponent { + readonly items = input.required(); + readonly circular = input(true); + readonly ariaLabel = input.required(); + readonly itemSelect = output(); + + protected readonly overflowing = signal(false); + protected readonly visibleItems = signal(null); + protected readonly showArrows = computed( + () => this.overflowing() || (this.visibleItems() !== null && this.items().length > 0), + ); + protected readonly looping = computed(() => this.circular() && this.overflowing()); + protected readonly blocks = computed(() => (this.looping() ? [-1, 0, 1] : [0])); + private readonly host = inject>(ElementRef); + private readonly viewport = viewChild.required>('viewport'); + private readonly canonical = viewChild.required>('canonical'); + private readonly destroyRef = inject(DestroyRef); + private settleTimer?: ReturnType; + private observer?: ResizeObserver; + private initializedLoop = false; + private transferringFocus = false; + private lastItems?: readonly HorizontalCarouselItem[]; + + constructor() { + afterRenderEffect(() => { + const items = this.items(); + const looping = this.looping(); + const set = this.canonical().nativeElement; + this.measure(); + if (this.lastItems !== items || this.initializedLoop !== looping) { + this.viewport().nativeElement.scrollLeft = looping ? set.getBoundingClientRect().width : 0; + this.lastItems = items; + this.initializedLoop = looping; + } + }); + afterNextRender(() => { + this.observer = new ResizeObserver(() => { + this.measure(); + this.normalize(); + }); + this.observer.observe(this.host.nativeElement); + this.observer.observe(this.canonical().nativeElement); + }); + this.destroyRef.onDestroy(() => { + this.observer?.disconnect(); + clearTimeout(this.settleTimer); + }); + } + + private measure(): void { + const slots = Number( + this.host.nativeElement.ownerDocument.defaultView + ?.getComputedStyle(this.host.nativeElement) + .getPropertyValue('--horizontal-carousel-visible-items'), + ); + this.visibleItems.set(slots > 0 ? slots : null); + if (slots > 0) { + this.overflowing.set(this.items().length > slots); + return; + } + const set = this.canonical().nativeElement; + const children = set.children; + const first = children.item(0) as HTMLElement | null; + const last = children.item(children.length - 1) as HTMLElement | null; + const width = + first && last ? last.getBoundingClientRect().right - first.getBoundingClientRect().left : 0; + const available = this.host.nativeElement.clientWidth; + this.overflowing.set(available > 0 && width > available + 1); + } + + protected move(direction: number): void { + const viewport = this.viewport().nativeElement; + const set = this.canonical().nativeElement; + const first = set.children.item(0) as HTMLElement | null; + const gap = + parseFloat(viewport.ownerDocument.defaultView?.getComputedStyle(set).columnGap ?? '0') || 0; + const distance = + this.visibleItems() && first + ? first.getBoundingClientRect().width + gap + : viewport.clientWidth * 0.8; + const reduceMotion = viewport.ownerDocument.defaultView?.matchMedia( + '(prefers-reduced-motion: reduce)', + ).matches; + viewport.scrollBy({ + left: direction * distance, + behavior: reduceMotion ? 'instant' : 'smooth', + }); + } + + protected onScroll(): void { + clearTimeout(this.settleTimer); + // Fallback for browsers without scrollend; momentum keeps resetting the timer. + this.settleTimer = setTimeout(() => this.normalize(), 180); + } + + protected normalize(): void { + clearTimeout(this.settleTimer); + if (!this.looping()) return; + const viewport = this.viewport().nativeElement; + const width = this.canonical().nativeElement.getBoundingClientRect().width; + if (!width) return; + const left = viewport.scrollLeft; + if (left < width || left >= 2 * width) { + viewport.scrollTo({ left: width + (((left % width) + width) % width), behavior: 'instant' }); + } + } + + protected reveal(event: FocusEvent): void { + // Only the canonical buttons participate in keyboard / screen-reader navigation. + if (this.transferringFocus) return; + (event.target as HTMLElement).scrollIntoView({ + block: 'nearest', + inline: 'nearest', + behavior: 'instant', + }); + } + + protected selectCopy(item: HorizontalCarouselItem): void { + const index = this.items().findIndex((candidate) => candidate.value === item.value); + this.transferringFocus = true; + (this.canonical().nativeElement.children.item(index) as HTMLElement | null)?.focus({ + preventScroll: true, + }); + this.transferringFocus = false; + this.itemSelect.emit(item); + } +} diff --git a/src/app/shared/components/location-map/location-map.component.scss b/src/app/shared/components/location-map/location-map.component.scss index b05de76..18aa974 100644 --- a/src/app/shared/components/location-map/location-map.component.scss +++ b/src/app/shared/components/location-map/location-map.component.scss @@ -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); } } diff --git a/src/app/shared/components/location-map/location-map.component.ts b/src/app/shared/components/location-map/location-map.component.ts index 7b9ab19..237437e 100644 --- a/src/app/shared/components/location-map/location-map.component.ts +++ b/src/app/shared/components/location-map/location-map.component.ts @@ -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: - '© OpenStreetMap contributors © CARTO', - 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 }) diff --git a/src/app/shared/components/quantity-selector/quantity-selector.component.scss b/src/app/shared/components/quantity-selector/quantity-selector.component.scss index 73e8dac..93e6275 100644 --- a/src/app/shared/components/quantity-selector/quantity-selector.component.scss +++ b/src/app/shared/components/quantity-selector/quantity-selector.component.scss @@ -4,16 +4,16 @@ .quantity-selector { border: 1px solid var(--border-color); - width: 78px; - height: 40px; - min-height: 40px; + width: var(--quantity-selector-width, 78px); + height: var(--quantity-selector-height, 40px); + min-height: var(--quantity-selector-height, 40px); background-color: #ffffff; box-sizing: border-box; &__button { - width: 24px; + width: var(--quantity-selector-button-width, 24px); height: 100%; - font-size: 20px; + font-size: var(--quantity-selector-button-font-size, 20px); color: #6f6f6f; background-color: #ffffff; transition: @@ -34,7 +34,7 @@ } &__value { - font-size: 16px; + font-size: var(--quantity-selector-value-font-size, 16px); color: #6f6f6f; } diff --git a/src/app/shared/components/simple-modal/simple-modal.component.scss b/src/app/shared/components/simple-modal/simple-modal.component.scss index bc280b3..09308fb 100644 --- a/src/app/shared/components/simple-modal/simple-modal.component.scss +++ b/src/app/shared/components/simple-modal/simple-modal.component.scss @@ -11,6 +11,11 @@ line-height: 1.5; } +h2.simple-modal__content { + font-size: 15px; + font-weight: bold; +} + .simple-modal__actions { display: flex; justify-content: center; diff --git a/src/app/shared/components/toast-container/toast-container.component.scss b/src/app/shared/components/toast-container/toast-container.component.scss index 12eb3f5..68df65d 100644 --- a/src/app/shared/components/toast-container/toast-container.component.scss +++ b/src/app/shared/components/toast-container/toast-container.component.scss @@ -2,7 +2,7 @@ z-index: 1500; display: flex; flex-direction: column; - gap: 0.5rem; + gap: 0.25rem; pointer-events: none; width: 90vw; max-width: 800px; @@ -43,4 +43,4 @@ opacity: 1; transform: translateY(0) scale(1); } -} \ No newline at end of file +}