70 Commits

Author SHA1 Message Date
db97de6711 feat(event-detail): disable cancel button when no items are selected 2026-09-21 11:19:38 -03:00
c876b2b171 feat(store-header): enhance immersive header with compact mode and dynamic background 2026-09-21 11:18:24 -03:00
f6ff91ff3b refactor(map): change map style from minimal-light to standard across components 2026-09-21 11:08:53 -03:00
acac2858c3 style(event-detail): enlarge social icons 2026-09-21 11:00:00 -03:00
cd1c99f487 feat(checkout): show event purchase summary 2026-09-21 10:59:51 -03:00
607892173d Reapply "feat(header): refactor immersive store header for improved search functionality and styling"
This reverts commit 52f7b98162.
2026-09-21 10:16:57 -03:00
52f7b98162 Revert "feat(header): refactor immersive store header for improved search functionality and styling"
This reverts commit a06f8146e5.
2026-09-21 10:08:55 -03:00
a06f8146e5 feat(header): refactor immersive store header for improved search functionality and styling 2026-09-21 10:07:49 -03:00
fd60880812 feat(event-category): add event category page and update event listing with category filtering 2026-09-21 09:40:50 -03:00
e0b1f27b97 feat(categories): separate event and product navigation 2026-09-21 09:24:24 -03:00
e7d24607a6 feat(menus): guard public event routes 2026-09-21 09:16:44 -03:00
865893b96b feat(events): add category-filtered event pages 2026-09-21 09:16:44 -03:00
14bda25c56 feat(event-detail): enhance event detail page with improved layout and additional features
feat(counter-to): add countdown component for event timing
feat(fonts): include Gotham Book font for better typography
fix(location-map): make map height responsive with CSS variables
2026-09-18 16:37:39 -03:00
6997550767 feat(events): add multi-event storefront pages 2026-09-18 14:33:49 -03:00
657ef1d89f feat(events): add storefront event API client 2026-09-18 14:33:42 -03:00
018b1d8ec7 chore(storefront): log tenant bootstrap data in development 2026-09-18 11:42:33 -03:00
93a9ca7e5c fix(storefront): read active event social media from bootstrap 2026-09-18 11:32:29 -03:00
6ce734b2c2 feat(store-header): implement immersive categories and update header properties 2026-09-18 09:49:51 -03:00
7f4aa81d4b feat: add immersive store header with carousel and Smooch font
- Implemented a new immersive store header component with a background image, logo, hero content, and a continuous image carousel.
- Integrated the Smooch font for enhanced typography in the header.
- Updated the store layout to support the new immersive header type.
- Added necessary styles and templates for the immersive header and carousel components.
- Enhanced unit tests to cover the new immersive header functionality.
2026-09-18 09:11:09 -03:00
6927da6342 feat(store-header): implement standard store header with dynamic properties 2026-09-17 16:49:12 -03:00
2f98fd6234 feat(tenant): add header_type property to Tenant interface 2026-09-17 16:02:21 -03:00
de5c533d85 refactor(storefront): consume storefront website type 2026-09-17 14:26:04 -03:00
90a0ac3e36 Merge pull request 'feature/refund' (#6) from feature/refund into homologacion
Reviewed-on: https://gitea.quo.ar/tbianchini/shopit-front/pulls/6
2026-09-17 15:19:23 +00:00
de10de2376 feat(cart): prevent automatic variant replacement and handle selection errors 2026-09-17 11:39:43 -03:00
4923010afb refactor(event): replace bootstrap notices with claimed notices 2026-09-16 08:19:28 -03:00
f87f7f55a9 feat(event): display pending date change notices 2026-09-15 17:10:27 -03:00
17dbf52181 feat(event): show date status information in storefront 2026-09-15 17:10:00 -03:00
a7e1d8995f fix(modal): preserve sequential dialogs 2026-09-14 14:50:15 -03:00
b5fae81795 feat(storefront): show event date notices 2026-09-14 14:50:11 -03:00
8664c86915 refactor(purchases): remove duplicated refund amount 2026-09-14 12:29:52 -03:00
9e72ec0c60 feat(tenant): add ticket refund configuration options 2026-09-11 16:27:23 -03:00
e9aae7a4c3 feat(tickets): handle refunded ticket states 2026-09-10 16:10:54 -03:00
6545be498f feat(tenant): add refund configuration fields 2026-09-10 15:29:47 -03:00
8c15ad7cc1 refactor(modal): replace 'content' with 'description' and update related tests 2026-09-09 08:50:05 -03:00
f859eba8a8 fix(logout): enhance logout handling with success and error toasts 2026-09-07 10:14:11 -03:00
8ba34c6c7a fix(auth): update logout response to include success message and adjust types
fix(tenant): add asset_url to Tenant interface and implement preconnect logic
2026-09-07 09:30:06 -03:00
d7c015e71e fix(product-list): prioritize first image in non-carousel layouts and update tests 2026-09-07 09:04:03 -03:00
d8d608c435 fix(product-carousel): correct track variable in thumbnail loop 2026-09-07 09:03:34 -03:00
985316d3b4 fix(product-attribute-selector): improve variant availability logic and add tests for maximum quantity handling 2026-09-04 11:36:21 -03:00
d601cc1984 fix(product-attribute-selector): update variant values type and normalize value extraction 2026-09-04 11:36:13 -03:00
70d47ac310 Merge pull request 'homologacion' (#4) from homologacion into main
Reviewed-on: https://gitea.quo.ar/tbianchini/shopit-front/pulls/4
2026-09-04 12:01:45 +00:00
5d1d7086c7 Merge branch 'develop' into homologacion 2026-09-03 13:39:41 -03:00
9a6ec3d1f2 feat(hero-banner): add conditional class for media presence and adjust styles 2026-09-03 13:39:23 -03:00
38ba5f4195 Merge branch 'develop' into homologacion 2026-09-03 08:55:50 -03:00
4bd74d95cb Merge branch 'fix/zoom_block' of https://gitea.quo.ar/tbianchini/shopit-front into fix/zoom_block 2026-09-03 08:37:54 -03:00
6f87aae939 feat(modal): enhance modal behavior and viewport handling for iOS devices 2026-09-03 08:37:51 -03:00
71ef09b413 feat(image-modal): enhance gesture handling and improve pinch zoom functionality 2026-09-03 08:37:51 -03:00
1d54cb1c48 feat(image-modal): enhance swipe-to-dismiss functionality with settling animation 2026-09-03 08:37:51 -03:00
7d52e11bca feat(image-modal): add swipe-to-dismiss functionality for zoomed images 2026-09-03 08:37:51 -03:00
ebae063a0f feat(ticket-selector): implement zoomable image feature for seating map 2026-09-03 08:37:51 -03:00
f0c1e74c1b fix(viewport): update viewport meta tag to disable user scaling 2026-09-03 08:37:51 -03:00
709859e731 feat(componentes-test): add image modal demo 2026-09-03 08:37:51 -03:00
e95a7612c2 test(modal): cover image viewer behavior 2026-09-03 08:37:51 -03:00
8bc0e165c7 feat(modal): add zoomable image viewer 2026-09-03 08:37:51 -03:00
158b28ec1a feat(checkout-page): implement countdown expiration check and purchase validation 2026-09-03 08:36:41 -03:00
8c22485d49 feat(modal): enhance modal behavior and viewport handling for iOS devices 2026-09-02 15:17:32 -03:00
2462f25f2f feat(image-modal): enhance gesture handling and improve pinch zoom functionality 2026-09-02 14:52:55 -03:00
63b01f366a feat(image-modal): enhance swipe-to-dismiss functionality with settling animation 2026-09-02 14:45:36 -03:00
ddd0b1b56d feat(image-modal): add swipe-to-dismiss functionality for zoomed images 2026-09-02 14:39:10 -03:00
2cad0c39b4 feat(ticket-selector): implement zoomable image feature for seating map 2026-09-02 14:26:12 -03:00
3089873232 fix(viewport): update viewport meta tag to disable user scaling 2026-09-02 14:14:27 -03:00
3fa0235163 feat(componentes-test): add image modal demo 2026-09-02 14:13:51 -03:00
0910d90363 test(modal): cover image viewer behavior 2026-09-02 14:13:45 -03:00
f3d446d012 feat(modal): add zoomable image viewer 2026-09-02 14:13:38 -03:00
71c462aaba Merge pull request 'homologacion' (#3) from homologacion into main
Reviewed-on: https://gitea.quo.ar/tbianchini/shopit-front/pulls/3
2026-08-31 11:36:15 +00:00
481eb30795 Merge branch 'main' into homologacion 2026-08-31 11:36:09 +00:00
afebc7f639 Merge pull request 'homo_experimental' (#2) from homo_experimental into homologacion
Reviewed-on: https://gitea.quo.ar/tbianchini/shopit-front/pulls/2
2026-08-31 11:25:31 +00:00
1ec48f7e21 Merge branch 'homo_experimental' into develop 2026-08-28 12:39:01 -03:00
c387a01d62 Merge branch 'fix/TL-1860' into develop 2026-08-28 09:27:59 -03:00
520397baa5 Merge pull request 'homologacion' (#1) from homologacion into main
Reviewed-on: https://gitea.quo.ar/tbianchini/shopit-front/pulls/1
2026-08-26 18:10:12 +00:00
122 changed files with 4740 additions and 862 deletions

Binary file not shown.

View File

@@ -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.

View File

@@ -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.

Binary file not shown.

View File

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

View File

@@ -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'],

View File

@@ -17,7 +17,7 @@ import { TenantUrlSerializer } from './core/services/tenant-url.serializer';
export function isStoreCatalogRequest(request: HttpRequest<unknown>): 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,
)
);

View File

@@ -1,10 +1,7 @@
import '@angular/compiler';
import { signal } from '@angular/core';
import { TestBed, getTestBed } from '@angular/core/testing';
import {
BrowserTestingModule,
platformBrowserTesting
} from '@angular/platform-browser/testing';
import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
import { provideRouter } from '@angular/router';
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
@@ -27,7 +24,7 @@ const tenant: Tenant = {
footer_bg_color: '#313131',
header_logo: 'https://example.com/header.png',
footer_logo: 'https://example.com/footer.png',
categories: []
categories: [],
};
function createTenantServiceStub(status: 'ready' | 'not-found', currentTenant: Tenant | null) {
@@ -38,17 +35,14 @@ function createTenantServiceStub(status: 'ready' | 'not-found', currentTenant: T
tenant: tenantState.asReadonly(),
status: statusState.asReadonly(),
getTenant: () => tenantState(),
bootstrap: vi.fn().mockResolvedValue(undefined)
bootstrap: vi.fn().mockResolvedValue(undefined),
};
}
describe('App', () => {
beforeAll(() => {
try {
getTestBed().initTestEnvironment(
BrowserTestingModule,
platformBrowserTesting()
);
getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
} catch {
// Test environment may already be initialized by another setup entrypoint.
}
@@ -65,9 +59,9 @@ describe('App', () => {
provideRouter([]),
{
provide: TenantService,
useValue: createTenantServiceStub('ready', tenant)
}
]
useValue: createTenantServiceStub('ready', tenant),
},
],
}).compileComponents();
const fixture = TestBed.createComponent(App);
@@ -75,26 +69,27 @@ describe('App', () => {
expect(fixture.componentInstance).toBeTruthy();
expect(fixture.nativeElement.style.getPropertyValue('--tenant-primary')).toBe(
tenant.primary_color
tenant.primary_color,
);
expect(fixture.nativeElement.style.getPropertyValue('--tenant-secondary')).toBe(
tenant.secondary_color
tenant.secondary_color,
);
expect(fixture.nativeElement.style.getPropertyValue('--tenant-danger')).toBe(
tenant.danger_color
tenant.danger_color,
);
expect(fixture.nativeElement.style.getPropertyValue('--tenant-primary-rgb')).toBe(
'99, 118, 243'
'99, 118, 243',
);
expect(fixture.nativeElement.style.getPropertyValue('--tenant-secondary-rgb')).toBe(
'160, 160, 160'
'160, 160, 160',
);
expect(fixture.nativeElement.style.getPropertyValue('--tenant-danger-rgb')).toBe(
'255, 136, 136'
'255, 136, 136',
);
expect(document.title).toBe(tenant.site_title);
expect(document.head.querySelector<HTMLLinkElement>('link[rel~="icon"]')?.getAttribute('href'))
.toBe(tenant.favicon);
expect(
document.head.querySelector<HTMLLinkElement>('link[rel~="icon"]')?.getAttribute('href'),
).toBe(tenant.favicon);
});
it('renders the tenant not found screen when the tenant is missing', async () => {
@@ -104,17 +99,21 @@ describe('App', () => {
provideRouter([]),
{
provide: TenantService,
useValue: createTenantServiceStub('not-found', null)
}
]
useValue: createTenantServiceStub('not-found', null),
},
],
}).compileComponents();
const fixture = TestBed.createComponent(App);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('No encontramos una tienda para este dominio');
expect(fixture.nativeElement.textContent).toContain(
'No encontramos una tienda para este dominio',
);
expect(document.title).toBe('ShopitFront');
expect(document.head.querySelector<HTMLLinkElement>('link[rel~="icon"]')?.getAttribute('href'))
.toBe("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'/%3E");
expect(
document.head.querySelector<HTMLLinkElement>('link[rel~="icon"]')?.getAttribute('href'),
).toBe("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'/%3E");
});
});

View File

@@ -13,7 +13,9 @@ const EMPTY_FAVICON = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/s
function hexToRgb(hex: string): string {
const cleanHex = hex.replace('#', '').trim();
let r = 0, g = 0, b = 0;
let r = 0,
g = 0,
b = 0;
if (cleanHex.length === 3) {
r = parseInt(cleanHex[0] + cleanHex[0], 16);
g = parseInt(cleanHex[1] + cleanHex[1], 16);
@@ -28,12 +30,7 @@ function hexToRgb(hex: string): string {
@Component({
selector: 'app-root',
imports: [
RouterOutlet,
ToastContainerComponent,
ModalHostComponent,
GlobalLoadingComponent
],
imports: [RouterOutlet, ToastContainerComponent, ModalHostComponent, GlobalLoadingComponent],
templateUrl: './app.html',
styleUrl: './app.scss',
host: {
@@ -56,8 +53,8 @@ function hexToRgb(hex: string): string {
'[style.--tenant-header-bg]': 'tenantHeaderBgColor()',
'[style.--tenant-footer-bg]': 'tenantFooterBgColor()',
'[style.--color-header-bg]': 'tenantHeaderBgColor()',
'[style.--color-footer-bg]': 'tenantFooterBgColor()'
}
'[style.--color-footer-bg]': 'tenantFooterBgColor()',
},
})
export class App {
private readonly tenantService = inject(TenantService);
@@ -87,37 +84,27 @@ export class App {
protected readonly status = this.tenantService.status;
protected readonly tenantPrimaryColor = computed(
() => this.tenantService.tenant()?.primary_color ?? DEFAULT_TENANT_BRANDING.primaryColor
() => this.tenantService.tenant()?.primary_color ?? DEFAULT_TENANT_BRANDING.primaryColor,
);
protected readonly tenantSecondaryColor = computed(
() => this.tenantService.tenant()?.secondary_color ?? DEFAULT_TENANT_BRANDING.secondaryColor
() => this.tenantService.tenant()?.secondary_color ?? DEFAULT_TENANT_BRANDING.secondaryColor,
);
protected readonly tenantDangerColor = computed(
() => this.tenantService.tenant()?.danger_color ?? DEFAULT_TENANT_BRANDING.dangerColor
() => this.tenantService.tenant()?.danger_color ?? DEFAULT_TENANT_BRANDING.dangerColor,
);
protected readonly tenantSuccessColor = computed(
() => this.tenantService.tenant()?.success_color ?? DEFAULT_TENANT_BRANDING.successColor
);
protected readonly tenantPrimaryColorRgb = computed(() =>
hexToRgb(this.tenantPrimaryColor())
() => this.tenantService.tenant()?.success_color ?? DEFAULT_TENANT_BRANDING.successColor,
);
protected readonly tenantPrimaryColorRgb = computed(() => hexToRgb(this.tenantPrimaryColor()));
protected readonly tenantSecondaryColorRgb = computed(() =>
hexToRgb(this.tenantSecondaryColor())
);
protected readonly tenantDangerColorRgb = computed(() =>
hexToRgb(this.tenantDangerColor())
);
protected readonly tenantSuccessColorRgb = computed(() =>
hexToRgb(this.tenantSuccessColor())
hexToRgb(this.tenantSecondaryColor()),
);
protected readonly tenantDangerColorRgb = computed(() => hexToRgb(this.tenantDangerColor()));
protected readonly tenantSuccessColorRgb = computed(() => hexToRgb(this.tenantSuccessColor()));
protected readonly tenantHeaderBgColor = computed(
() =>
this.tenantService.tenant()?.header_bg_color ??
DEFAULT_TENANT_BRANDING.headerBgColor
() => this.tenantService.tenant()?.header_bg_color ?? DEFAULT_TENANT_BRANDING.headerBgColor,
);
protected readonly tenantFooterBgColor = computed(
() =>
this.tenantService.tenant()?.footer_bg_color ??
DEFAULT_TENANT_BRANDING.footerBgColor
() => this.tenantService.tenant()?.footer_bg_color ?? DEFAULT_TENANT_BRANDING.footerBgColor,
);
}

View File

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

View File

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

View File

@@ -0,0 +1,106 @@
<header class="immersive-header" [class.immersive-header--compact]="!showHero()">
<div
class="immersive-header__backdrop"
[style.background-image]="backgroundImageUrl() ? 'url(' + backgroundImageUrl() + ')' : null"
></div>
<div class="immersive-header__content">
<nav
class="immersive-header__nav container-xl px-3 px-md-4"
aria-label="Navegación de la tienda"
>
<a routerLink="/" class="immersive-header__brand" aria-label="Ir al inicio">
@if (logoUrl()) {
<img [src]="logoUrl()" alt="Logo del comercio" />
}
</a>
<div class="immersive-header__actions">
@if (displaySearchBar()) {
<form class="immersive-header__search" role="search" (submit)="submitSearch($event)">
<label class="visually-hidden" for="immersive-search-input">Buscar productos</label>
<app-input
id="immersive-search-input"
placeholder="Buscar"
type="search"
[(value)]="searchTerm"
[invalid]="showSearchError()"
(valueChange)="showSearchError.set(false)"
/>
@if (showSearchError()) {
<span id="immersive-search-error" class="immersive-header__search-error" role="alert"
>Ingresá al menos 3 caracteres.</span
>
}
</form>
}
@if (ticketsMenu(); as menu) {
<app-button
variant="secondary"
hostClass="immersive-header__tickets"
(click)="ticketsClick.emit()"
>
{{ menu.label }}
</app-button>
}
@if (displayCart()) {
<app-cart-icon
[quantity]="cartDisabled() ? null : cartQuantity()"
[disabled]="cartDisabled()"
[ariaLabel]="
cartDisabled() ? 'Carrito no disponible durante el checkout' : 'Carrito de compras'
"
(click)="onCartClick()"
/>
}
<div class="immersive-header__account">
<app-icon-button
variant="user"
ariaLabel="Mi cuenta"
ariaControls="immersive-user-dropdown"
[ariaExpanded]="isUserDropdownOpen()"
(clicked)="toggleUserDropdown()"
/>
@if (isUserDropdownOpen()) {
<app-user-dropdown
id="immersive-user-dropdown"
[user]="user()"
[accountNavigationMenus]="accountNavigationMenus()"
(logoutClick)="onLogoutClick()"
/>
}
</div>
</div>
</nav>
@if (checkoutRemainingTime(); as remainingTime) {
<div
class="immersive-header__timer container-xl px-3 px-md-4"
role="timer"
aria-label="Tiempo restante de compra"
>
Tiempo restante de compra: <strong>{{ remainingTime }}</strong>
</div>
}
@if (showHero()) {
@if (heroContent(); as content) {
<div class="immersive-header__copy">
<p class="immersive-header__eyebrow">{{ content.eyebrow }}</p>
<h1 class="immersive-header__title">{{ content.title }}</h1>
<p class="immersive-header__description">{{ content.description }}</p>
</div>
}
@if (carouselImages().length) {
<app-continuous-image-carousel
[images]="carouselImages()"
ariaLabel="Imágenes del evento"
/>
}
}
</div>
</header>

View File

@@ -0,0 +1,185 @@
:host {
display: block;
}
.immersive-header {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr);
isolation: isolate;
min-height: 640px;
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: 0.5rem;
}
.immersive-header__backdrop {
grid-area: 1 / 1;
position: relative;
z-index: 0;
background-color: #101010;
background-position: center 42%;
background-size: cover;
}
.immersive-header__backdrop::before,
.immersive-header__backdrop::after {
content: '';
position: absolute;
inset: 0;
}
.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;
z-index: 1;
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;
display: flex;
}
.immersive-header__account app-user-dropdown {
position: absolute;
top: 100%;
right: 0;
z-index: 20;
color: #222;
}
:host ::ng-deep .immersive-header__actions .icon-btn,
:host ::ng-deep .immersive-header__actions .cart-icon {
color: var(--tenant-primary, #ff7006);
}
.immersive-header__copy {
position: relative;
z-index: 1;
margin: clamp(2.1rem, 5vw, 3.25rem) auto 0;
padding-inline: 1rem;
text-align: center;
}
.immersive-header__eyebrow,
.immersive-header__title {
font-family: 'Smooch', cursive;
font-weight: 400;
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.55);
}
.immersive-header__eyebrow {
margin: 0;
font-size: 50px;
line-height: 1;
}
.immersive-header__title {
margin: -0.15rem 0 0;
font-size: 140px;
line-height: 1.05;
}
.immersive-header__description {
margin: 1.15rem 0 0;
font-size: clamp(0.75rem, 1.4vw, 0.95rem);
text-shadow: 0 1px 5px rgba(0, 0, 0, 0.6);
}
.immersive-header__timer {
position: relative;
z-index: 1;
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: 0;
padding-bottom: 1.5rem;
color: #666666;
background: transparent;
}
.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: #666666;
}
@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);
}
}

View File

@@ -0,0 +1,110 @@
import {
Component,
ElementRef,
HostListener,
computed,
inject,
input,
output,
signal,
} 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<HTMLElement>>(ElementRef);
readonly logoUrl = input<string | null>(null);
readonly backgroundImageUrl = input<string | null>(null);
readonly heroContent = input<WebsiteExtras['immersiveHero']>(undefined);
readonly carouselImages = input<readonly string[]>([]);
readonly showHero = input(true);
readonly cartQuantity = input(0);
readonly user = input<AuthUser | null>(null);
readonly isAuthenticated = input(false);
readonly accountNavigationMenus = input<readonly Menu[]>([]);
readonly ticketsMenu = input<Menu | null>(null);
readonly displaySearchBar = input(true);
readonly displayCart = input(true);
readonly cartDisabled = input(false);
readonly checkoutRemainingSeconds = input<number | null>(null);
readonly cartClick = output<void>();
readonly ticketsClick = output<void>();
readonly loginClick = output<void>();
readonly logoutClick = output<void>();
readonly searchSubmit = output<string>();
protected searchTerm = '';
protected readonly showSearchError = signal(false);
protected readonly isUserDropdownOpen = signal(false);
protected readonly checkoutRemainingTime = computed(() => {
const seconds = this.checkoutRemainingSeconds();
if (seconds === null) return null;
return `${Math.floor(seconds / 60)
.toString()
.padStart(2, '0')}:${(seconds % 60).toString().padStart(2, '0')}`;
});
protected submitSearch(event?: Event): void {
event?.preventDefault();
const term = this.searchTerm.trim();
if (term.length < 3) {
this.showSearchError.set(true);
return;
}
this.showSearchError.set(false);
this.searchSubmit.emit(term);
}
protected toggleUserDropdown(): void {
if (!this.isAuthenticated()) {
this.loginClick.emit();
return;
}
this.isUserDropdownOpen.update((open) => !open);
}
protected onCartClick(): void {
if (!this.cartDisabled()) this.cartClick.emit();
}
protected onLogoutClick(): void {
this.isUserDropdownOpen.set(false);
this.logoutClick.emit();
}
@HostListener('document:click', ['$event'])
protected onDocumentClick(event: MouseEvent): void {
if (event.target instanceof Node && !this.host.nativeElement.contains(event.target)) {
this.isUserDropdownOpen.set(false);
}
}
@HostListener('document:keydown.escape')
protected onEscapeKey(): void {
this.isUserDropdownOpen.set(false);
}
}

View File

@@ -0,0 +1,192 @@
<header
class="store-layout__header"
[style.background-image]="backgroundImageUrl() ? 'url(' + backgroundImageUrl() + ')' : null"
>
<div class="store-layout__header-main">
<div class="container-xl px-3 px-md-4 py-3 py-lg-4">
<div class="row align-items-center gy-3 gy-md-0">
<div class="col-auto col-md-auto order-1 store-layout__brand-column">
<a
routerLink="/"
class="store-layout__brand-slot d-inline-flex align-items-center gap-3 text-decoration-none"
data-testid="store-header-brand-slot"
aria-label="Logo del comercio"
>
@if (logoUrl()) {
<img class="store-layout__brand-logo" [src]="logoUrl()" alt="Logo del comercio" />
} @else {
<div class="store-layout__brand-logo-shell" aria-hidden="true">
<span class="store-layout__brand-logo-skeleton"></span>
</div>
}
</a>
</div>
@if (displaySeachBar()) {
<div class="col-12 px-0 d-md-none order-3">
<hr class="store-layout__mobile-separator" />
</div>
<div class="col-12 col-md order-4 order-md-2">
<div class="store-layout__search-wrapper ms-md-auto">
<div class="input-group store-layout__search" role="search">
<label class="visually-hidden" for="store-search-input">Buscar productos</label>
<input
id="store-search-input"
type="search"
class="form-control store-layout__search-input border-end-0 rounded-start"
placeholder="Buscar productos"
autocomplete="off"
[attr.aria-describedby]="showSearchError() ? 'store-search-error' : null"
[attr.aria-invalid]="showSearchError()"
[minlength]="minSearchLength"
[formControl]="searchControl"
(input)="clearSearchError()"
(keydown.enter)="submitSearch($event)"
/>
<button
type="button"
class="btn store-layout__search-button border-start-0 rounded-end px-3"
aria-label="Buscar"
(click)="submitSearch()"
>
<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
</button>
</div>
@if (showSearchError()) {
<p id="store-search-error" class="store-layout__search-error mb-0" role="alert">
Ingresá al menos {{ minSearchLength }} caracteres para buscar.
</p>
}
</div>
</div>
}
<div
class="store-layout__actions-column col-auto col-md-auto order-2 order-md-3 ms-auto d-flex flex-row align-items-center justify-content-end gap-2 gap-md-3"
>
@if (ticketsMenu(); as menu) {
<app-button
variant="primary"
hostClass="store-layout__tickets-btn-host"
data-testid="store-header-tickets"
(click)="ticketsClick.emit()"
>
{{ menu.label }}
</app-button>
}
@if (displayCart()) {
<app-cart-icon
[quantity]="cartDisabled() ? null : cartQuantity()"
[disabled]="cartDisabled()"
[ariaLabel]="
cartDisabled() ? 'Carrito no disponible durante el checkout' : 'Carrito de compras'
"
[title]="cartDisabled() ? 'Carrito no disponible durante el checkout' : 'Carrito'"
(click)="onCartClick()"
/>
}
<div class="store-layout__user-menu d-none d-md-inline-flex position-relative">
<app-icon-button
variant="user"
ariaLabel="Mi cuenta"
ariaControls="store-user-dropdown"
[ariaExpanded]="isUserDropdownOpen()"
title="Mi cuenta"
(clicked)="toggleUserDropdown()"
/>
@if (isUserDropdownOpen()) {
<app-user-dropdown
id="store-user-dropdown"
[user]="user()"
[accountNavigationMenus]="accountNavigationMenus()"
(logoutClick)="onLogoutClick()"
/>
}
</div>
<div class="store-layout__mobile-menu d-inline-flex d-md-none position-relative">
<app-icon-button
variant="bars"
ariaLabel="Menu"
ariaControls="store-mobile-menu"
[ariaExpanded]="isMobileMenuOpen()"
title="Menu"
(clicked)="toggleMobileMenu()"
/>
@if (isMobileMenuOpen()) {
<div
id="store-mobile-menu"
class="position-absolute end-0 bg-white rounded shadow mt-2 py-2 store-layout__mobile-dropdown-menu"
>
<a
class="dropdown-item d-flex align-items-center gap-2 px-3 py-2 text-decoration-none store-layout__mobile-dropdown-item"
(click)="onMobileAccountClick()"
>
<i class="fa-regular fa-circle-user fa-lg"></i>
<span class="fw-medium">Mi cuenta</span>
</a>
@if (displayCategories()) {
<hr class="my-1 border-secondary opacity-25" />
@for (category of categories(); track category.id) {
<a
class="dropdown-item px-3 py-2 text-decoration-none store-layout__mobile-dropdown-item"
(click)="onCategorySelect(category)"
>
<span class="fw-medium">{{ category.nombre }}</span>
</a>
}
}
</div>
}
</div>
</div>
</div>
</div>
</div>
<div class="store-layout__categories d-none d-md-block">
<div class="container-xl h-100 px-3 px-md-4 d-flex align-items-center">
@if (displayCategories()) {
<div class="store-layout__category-menu">
<button
type="button"
class="btn btn-link d-inline-flex align-items-center gap-2 px-0 py-3 text-uppercase fw-bold text-decoration-none store-layout__category-trigger"
aria-label="Categorias"
aria-controls="store-category-dropdown"
[attr.aria-expanded]="isCategoryDropdownOpen()"
(click)="toggleCategoryDropdown()"
>
<span>Categorias</span>
<i class="fa-solid fa-chevron-down" aria-hidden="true"></i>
</button>
<app-category-dropdown
id="store-category-dropdown"
[categories]="categories()"
[open]="isCategoryDropdownOpen()"
(categorySelect)="onCategorySelect($event)"
/>
</div>
}
@if (checkoutRemainingTime(); as remainingTime) {
<div
class="store-layout__checkout-timer ms-auto d-none d-md-flex align-items-baseline gap-3"
role="timer"
aria-label="Tiempo restante de compra"
>
<span class="store-layout__checkout-timer-label">Tiempo restante de compra:</span>
<span class="store-layout__checkout-timer-value">{{ remainingTime }}</span>
</div>
}
</div>
</div>
</header>

View File

@@ -0,0 +1,246 @@
.store-layout__header {
color: #ffffff;
background-color: var(--tenant-header-bg);
background-repeat: no-repeat;
background-position: center;
background-size: cover;
}
.store-layout__header-main {
border-bottom: 1px solid var(--border-color);
}
.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;
}
}

View File

@@ -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<HTMLElement>);
private readonly router = inject(Router);
readonly logoUrl = input<string | null>(null);
readonly backgroundImageUrl = input<string | null>(null);
readonly cartQuantity = input<number>(0);
readonly user = input<AuthUser | null>(null);
readonly isAuthenticated = input<boolean>(false);
readonly accountNavigationMenus = input<readonly Menu[]>([]);
readonly ticketsMenu = input<Menu | null>(null);
readonly categories = input<readonly Category[]>([]);
readonly displayCategories = input(true);
readonly displaySeachBar = input(true);
readonly displayCart = input(true);
readonly cartDisabled = input(false);
readonly checkoutRemainingSeconds = input<number | null>(null);
readonly cartClick = output<void>();
readonly ticketsClick = output<void>();
readonly loginClick = output<void>();
readonly logoutClick = output<void>();
readonly searchSubmit = output<string>();
readonly categorySelect = output<Category>();
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']);
}
}
}
}

View File

@@ -1,192 +1,68 @@
<header
class="store-layout__header"
[style.background-image]="backgroundImageUrl() ? 'url(' + backgroundImageUrl() + ')' : null"
>
<div class="store-layout__header-main">
<div class="container-xl px-3 px-md-4 py-3 py-lg-4">
<div class="row align-items-center gy-3 gy-md-0">
<div class="col-auto col-md-auto order-1 store-layout__brand-column">
<a
routerLink="/"
class="store-layout__brand-slot d-inline-flex align-items-center gap-3 text-decoration-none"
data-testid="store-header-brand-slot"
aria-label="Logo del comercio"
>
@if (logoUrl()) {
<img class="store-layout__brand-logo" [src]="logoUrl()" alt="Logo del comercio" />
} @else {
<div class="store-layout__brand-logo-shell" aria-hidden="true">
<span class="store-layout__brand-logo-skeleton"></span>
</div>
}
</a>
</div>
@switch (headerType()) {
@case ('immersive') {
<app-immersive-store-header
[logoUrl]="logoUrl()"
[backgroundImageUrl]="backgroundImageUrl()"
[heroContent]="heroContent()"
[carouselImages]="carouselImages()"
[showHero]="showHero()"
[cartQuantity]="cartQuantity()"
[user]="user()"
[isAuthenticated]="isAuthenticated()"
[accountNavigationMenus]="accountNavigationMenus()"
[ticketsMenu]="ticketsMenu()"
[displaySearchBar]="displaySeachBar()"
[displayCart]="displayCart()"
[cartDisabled]="cartDisabled()"
[checkoutRemainingSeconds]="checkoutRemainingSeconds()"
(cartClick)="cartClick.emit()"
(ticketsClick)="ticketsClick.emit()"
(loginClick)="loginClick.emit()"
(logoutClick)="logoutClick.emit()"
(searchSubmit)="searchSubmit.emit($event)"
/>
}
@default {
<ng-container *ngTemplateOutlet="standardHeader" />
}
}
@if (displaySeachBar()) {
<div class="col-12 px-0 d-md-none order-3">
<hr class="store-layout__mobile-separator" />
</div>
<ng-template #standardHeader>
<app-standard-store-header
[logoUrl]="logoUrl()"
[backgroundImageUrl]="backgroundImageUrl()"
[cartQuantity]="cartQuantity()"
[user]="user()"
[isAuthenticated]="isAuthenticated()"
[accountNavigationMenus]="accountNavigationMenus()"
[ticketsMenu]="ticketsMenu()"
[categories]="categories()"
[displayCategories]="displayCategories()"
[displaySeachBar]="displaySeachBar()"
[displayCart]="displayCart()"
[cartDisabled]="cartDisabled()"
[checkoutRemainingSeconds]="checkoutRemainingSeconds()"
(cartClick)="cartClick.emit()"
(ticketsClick)="ticketsClick.emit()"
(loginClick)="loginClick.emit()"
(logoutClick)="logoutClick.emit()"
(searchSubmit)="searchSubmit.emit($event)"
(categorySelect)="categorySelect.emit($event)"
/>
</ng-template>
<div class="col-12 col-md order-4 order-md-2">
<div class="store-layout__search-wrapper ms-md-auto">
<div class="input-group store-layout__search" role="search">
<label class="visually-hidden" for="store-search-input">Buscar productos</label>
<input
id="store-search-input"
type="search"
class="form-control store-layout__search-input border-end-0 rounded-start"
placeholder="Buscar productos"
autocomplete="off"
[attr.aria-describedby]="showSearchError() ? 'store-search-error' : null"
[attr.aria-invalid]="showSearchError()"
[minlength]="minSearchLength"
[formControl]="searchControl"
(input)="clearSearchError()"
(keydown.enter)="submitSearch($event)"
/>
<button
type="button"
class="btn store-layout__search-button border-start-0 rounded-end px-3"
aria-label="Buscar"
(click)="submitSearch()"
>
<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
</button>
</div>
@if (showSearchError()) {
<p id="store-search-error" class="store-layout__search-error mb-0" role="alert">
Ingresá al menos {{ minSearchLength }} caracteres para buscar.
</p>
}
</div>
</div>
}
<div
class="store-layout__actions-column col-auto col-md-auto order-2 order-md-3 ms-auto d-flex flex-row align-items-center justify-content-end gap-2 gap-md-3"
@if (headerType() === 'immersive' && displayCategories() && categories().length) {
<nav class="immersive-categories" aria-label="Categorías">
<div class="immersive-categories__list">
@for (category of categories(); track category.id) {
<button
type="button"
class="immersive-categories__item"
(click)="categorySelect.emit(category)"
>
@if (ticketsMenu(); as menu) {
<app-button
variant="primary"
hostClass="store-layout__tickets-btn-host"
data-testid="store-header-tickets"
(click)="ticketsClick.emit()"
>
{{ menu.label }}
</app-button>
}
@if (displayCart()) {
<app-cart-icon
[quantity]="cartDisabled() ? null : cartQuantity()"
[disabled]="cartDisabled()"
[ariaLabel]="
cartDisabled() ? 'Carrito no disponible durante el checkout' : 'Carrito de compras'
"
[title]="cartDisabled() ? 'Carrito no disponible durante el checkout' : 'Carrito'"
(click)="onCartClick()"
/>
}
<div class="store-layout__user-menu d-none d-md-inline-flex position-relative">
<app-icon-button
variant="user"
ariaLabel="Mi cuenta"
ariaControls="store-user-dropdown"
[ariaExpanded]="isUserDropdownOpen()"
title="Mi cuenta"
(clicked)="toggleUserDropdown()"
/>
@if (isUserDropdownOpen()) {
<app-user-dropdown
id="store-user-dropdown"
[user]="user()"
[accountNavigationMenus]="accountNavigationMenus()"
(logoutClick)="onLogoutClick()"
/>
}
</div>
<div class="store-layout__mobile-menu d-inline-flex d-md-none position-relative">
<app-icon-button
variant="bars"
ariaLabel="Menu"
ariaControls="store-mobile-menu"
[ariaExpanded]="isMobileMenuOpen()"
title="Menu"
(clicked)="toggleMobileMenu()"
/>
@if (isMobileMenuOpen()) {
<div
id="store-mobile-menu"
class="position-absolute end-0 bg-white rounded shadow mt-2 py-2 store-layout__mobile-dropdown-menu"
>
<a
class="dropdown-item d-flex align-items-center gap-2 px-3 py-2 text-decoration-none store-layout__mobile-dropdown-item"
(click)="onMobileAccountClick()"
>
<i class="fa-regular fa-circle-user fa-lg"></i>
<span class="fw-medium">Mi cuenta</span>
</a>
@if (displayCategories()) {
<hr class="my-1 border-secondary opacity-25" />
@for (category of categories(); track category.id) {
<a
class="dropdown-item px-3 py-2 text-decoration-none store-layout__mobile-dropdown-item"
(click)="onCategorySelect(category)"
>
<span class="fw-medium">{{ category.nombre }}</span>
</a>
}
}
</div>
}
</div>
</div>
</div>
</div>
</div>
<div class="store-layout__categories d-none d-md-block">
<div class="container-xl h-100 px-3 px-md-4 d-flex align-items-center">
@if (displayCategories()) {
<div class="store-layout__category-menu">
<button
type="button"
class="btn btn-link d-inline-flex align-items-center gap-2 px-0 py-3 text-uppercase fw-bold text-decoration-none store-layout__category-trigger"
aria-label="Categorias"
aria-controls="store-category-dropdown"
[attr.aria-expanded]="isCategoryDropdownOpen()"
(click)="toggleCategoryDropdown()"
>
<span>Categorias</span>
<i class="fa-solid fa-chevron-down" aria-hidden="true"></i>
</button>
<app-category-dropdown
id="store-category-dropdown"
[categories]="categories()"
[open]="isCategoryDropdownOpen()"
(categorySelect)="onCategorySelect($event)"
/>
</div>
}
@if (checkoutRemainingTime(); as remainingTime) {
<div
class="store-layout__checkout-timer ms-auto d-none d-md-flex align-items-baseline gap-3"
role="timer"
aria-label="Tiempo restante de compra"
>
<span class="store-layout__checkout-timer-label">Tiempo restante de compra:</span>
<span class="store-layout__checkout-timer-value">{{ remainingTime }}</span>
</div>
{{ category.nombre | uppercase }}
</button>
}
</div>
</div>
</header>
</nav>
}

View File

@@ -1,246 +1,48 @@
.store-layout__header {
color: #ffffff;
background-color: var(--tenant-header-bg);
background-repeat: no-repeat;
background-position: center;
:host {
display: block;
}
:host(.immersive-shell--compact) {
background-color: #f5f5f5;
background-position: center 42%;
background-size: cover;
}
.store-layout__header-main {
border-bottom: 1px solid var(--border-color);
:host(.immersive-shell--compact) .immersive-categories {
border-top: 1px solid rgba(255, 255, 255, 0.6);
background: transparent;
}
.store-layout__categories {
height: 3.5rem;
.immersive-categories {
width: 100%;
overflow-x: auto;
background: #f5f5f5;
}
.store-layout__checkout-timer {
color: var(--tenant-primary);
.immersive-categories__list {
display: flex;
align-items: center;
justify-content: center;
gap: clamp(1rem, 3vw, 2.5rem);
width: max-content;
min-width: 100%;
min-height: 3.5rem;
padding: 0.75rem 1rem;
}
.immersive-categories__item {
flex: none;
border: 0;
padding: 0;
background: transparent;
color: #666666;
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;
}
}
@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;
}
}
.immersive-categories__item:hover,
.immersive-categories__item:focus-visible {
text-decoration: underline;
}

View File

@@ -1,44 +1,40 @@
import {
Component,
computed,
ElementRef,
HostListener,
inject,
input,
output,
signal,
} from '@angular/core';
import { Router, RouterLink } from '@angular/router';
import { NgTemplateOutlet, UpperCasePipe } 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';
export type StoreHeaderType = 'standard' | 'immersive';
@Component({
selector: 'app-store-header',
imports: [
ButtonComponent,
CartIconComponent,
IconButtonComponent,
RouterLink,
ReactiveFormsModule,
UserDropdownComponent,
CategoryDropdownComponent,
],
imports: [NgTemplateOutlet, UpperCasePipe, StandardStoreHeaderComponent, ImmersiveStoreHeaderComponent],
templateUrl: './store-header.component.html',
styleUrl: './store-header.component.scss',
host: {
'[class.immersive-shell--compact]': 'isCompactImmersive()',
'[style.background-image]': 'compactBackground()',
},
})
export class StoreHeaderComponent {
private readonly elementRef = inject(ElementRef<HTMLElement>);
private readonly router = inject(Router);
readonly headerType = input<StoreHeaderType>('standard');
readonly logoUrl = input<string | null>(null);
readonly backgroundImageUrl = input<string | null>(null);
readonly heroContent = input<WebsiteExtras['immersiveHero']>(undefined);
readonly carouselImages = input<readonly string[]>([]);
readonly showHero = input(true);
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 100%)';
const image = this.backgroundImageUrl();
return image ? `${gradient}, url(${JSON.stringify(image)})` : gradient;
});
readonly cartQuantity = input<number>(0);
readonly user = input<AuthUser | null>(null);
readonly isAuthenticated = input<boolean>(false);
@@ -50,123 +46,11 @@ export class StoreHeaderComponent {
readonly displayCart = input(true);
readonly cartDisabled = input(false);
readonly checkoutRemainingSeconds = input<number | null>(null);
readonly cartClick = output<void>();
readonly ticketsClick = output<void>();
readonly loginClick = output<void>();
readonly logoutClick = output<void>();
readonly searchSubmit = output<string>();
readonly categorySelect = output<Category>();
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']);
}
}
}
}

View File

@@ -1,6 +1,10 @@
<div class="store-layout d-flex flex-column flex-grow-1">
<app-store-header
ngSkipHydration
[headerType]="tenant()?.header_type ?? 'standard'"
[heroContent]="tenant()?.extras?.immersiveHero"
[carouselImages]="tenant()?.extras?.immersiveHeroCarousel ?? []"
[showHero]="isHomeRoute()"
[cartQuantity]="cartQuantity()"
[logoUrl]="tenant()?.header_logo ?? null"
[backgroundImageUrl]="tenant()?.header_bg_image ?? null"
@@ -8,12 +12,12 @@
[isAuthenticated]="isAuthenticated()"
[accountNavigationMenus]="accountNavigationMenus()"
[ticketsMenu]="ticketsMenu() ?? null"
[categories]="tenant()?.categories ?? []"
[displayCategories]="tenant()?.display_categories ?? true"
[categories]="navigationCategories()"
[displayCategories]="displayCategories()"
[displaySeachBar]="tenant()?.display_seach_bar ?? true"
[displayCart]="displayCart()"
[cartDisabled]="isCheckoutRoute()"
[checkoutRemainingSeconds]="checkoutRemainingSeconds()"
[checkoutRemainingSeconds]="headerCheckoutRemainingSeconds()"
(cartClick)="onCartClick()"
(ticketsClick)="onTicketsClick()"
(loginClick)="onLoginClick()"

View File

@@ -12,7 +12,7 @@ import {
UrlSerializer,
} from '@angular/router';
import { BehaviorSubject, of } from 'rxjs';
import { BehaviorSubject, of, throwError } from 'rxjs';
import { Tenant } from '../../services/tenant.interface';
import { TenantService } from '../../services/tenant.service';
@@ -23,9 +23,11 @@ import { AuthService } from '../../services/auth/auth.service';
import { AuthUser } from '../../services/auth/auth.interfaces';
import { StoreLayoutComponent } from './store-layout.component';
import { StoreHeaderComponent } from './store-header/store-header.component';
import { StandardStoreHeaderComponent } from './store-header/standard-store-header.component';
import { CheckoutService } from '../../services/checkout.service';
import { CheckoutCountdownService } from '../../services/checkout-countdown.service';
import { TenantUrlSerializer } from '../../services/tenant-url.serializer';
import { EventDateNoticeService } from '../../services/event-date-notice.service';
const tenant: Tenant = {
id: 1,
@@ -211,7 +213,7 @@ describe('StoreLayoutComponent', () => {
useValue: {
user: authUserState,
isAuthenticated: isAuthenticatedState,
logout: vi.fn().mockReturnValue(of(void 0)),
logout: vi.fn().mockReturnValue(of({ message: 'Sesión cerrada correctamente.' })),
},
},
{
@@ -224,6 +226,12 @@ describe('StoreLayoutComponent', () => {
remainingSeconds: checkoutRemainingSecondsState.asReadonly(),
},
},
{
provide: EventDateNoticeService,
useValue: {
claim: vi.fn().mockReturnValue(of({ data: [] })),
},
},
],
}).compileComponents();
});
@@ -240,6 +248,130 @@ describe('StoreLayoutComponent', () => {
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',
);
expect(element.querySelectorAll('.continuous-carousel__set')).toHaveLength(2);
expect(element.querySelector('.continuous-carousel__image')?.getAttribute('src')).toBe(
'/first.jpg',
);
expect(element.querySelector('app-cart-icon')).not.toBeNull();
});
it('shows immersive categories below 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<HTMLButtonElement>('button');
expect(header).not.toBeNull();
expect(categories).not.toBeNull();
expect(header?.contains(categories)).toBe(false);
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<HTMLButtonElement>(
'.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();
@@ -296,6 +428,26 @@ describe('StoreLayoutComponent', () => {
);
});
it('moves the countdown out of the header on multi-event checkout', () => {
tenantState.set({
...tenant,
storefront_website_type_code: 'onticket_multi_event',
checkout_summary_type: 'event',
});
checkoutRemainingSecondsState.set(587);
const router = TestBed.inject(Router);
vi.spyOn(router, 'url', 'get').mockReturnValue('/checkout/25');
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const header = fixture.debugElement.query(By.directive(StoreHeaderComponent));
expect(header.componentInstance.checkoutRemainingSeconds()).toBeNull();
expect(element.querySelector('.store-layout__checkout-timer')).toBeNull();
});
it('hides the configured header elements when the tenant disables them', () => {
tenantState.set({
...tenant,
@@ -320,7 +472,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();
@@ -371,7 +523,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 ');
@@ -389,7 +541,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(),
@@ -410,7 +562,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'));
@@ -574,6 +726,20 @@ describe('StoreLayoutComponent', () => {
expect(authService.logout).toHaveBeenCalled();
expect(cartService.clearCart).toHaveBeenCalled();
expect(router.navigate).toHaveBeenCalledWith(['/']);
expect(TestBed.inject(ToastService).info).toHaveBeenCalledWith('Sesión cerrada correctamente.');
});
it('shows a danger toast when logout fails', async () => {
const authService = TestBed.inject(AuthService);
const message = 'La sesión no pudo cerrarse en el servidor.';
(authService.logout as any).mockReturnValue(throwError(() => ({ error: { message } })));
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
await (fixture.componentInstance as any).onLogoutClick();
expect(TestBed.inject(ToastService).danger).toHaveBeenCalledWith(message);
});
it('provides account actions from the footer', () => {

View File

@@ -1,5 +1,15 @@
import { isPlatformBrowser } from '@angular/common';
import { HttpErrorResponse } from '@angular/common/http';
import { Component, computed, DestroyRef, inject, OnInit, signal } from '@angular/core';
import {
Component,
computed,
DestroyRef,
effect,
inject,
OnInit,
PLATFORM_ID,
signal,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import {
ActivatedRoute,
@@ -8,7 +18,7 @@ import {
Router,
RouterOutlet,
} from '@angular/router';
import { filter } from 'rxjs';
import { filter, firstValueFrom } from 'rxjs';
import { TenantService } from '../../services/tenant.service';
import { CartService } from '../../services/cart/cart.service';
import { StoreFooterComponent, StoreFooterSection } from './store-footer/store-footer.component';
@@ -25,6 +35,9 @@ import {
import { ToastService } from '../../services/toast.service';
import { Category } from '../../services/tenant.interface';
import { CheckoutCountdownService } from '../../services/checkout-countdown.service';
import { EventDateNotice, EventDateNoticeService } from '../../services/event-date-notice.service';
import { ModalService } from '../../services/modal.service';
import { EventDateNoticeModalComponent } from '../../../shared/components/event-date-notice-modal/event-date-notice-modal.component';
@Component({
selector: 'app-store-layout',
@@ -48,11 +61,38 @@ export class StoreLayoutComponent implements OnInit {
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
private readonly destroyRef = inject(DestroyRef);
private readonly platformId = inject(PLATFORM_ID);
private readonly eventDateNoticeService = inject(EventDateNoticeService);
private readonly modalService = inject(ModalService);
private claimedNoticeUserId: number | null = null;
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(
@@ -68,6 +108,27 @@ export class StoreLayoutComponent implements OnInit {
() => this.cartEditingPolicy()?.allow_update_variant ?? false,
);
constructor() {
effect(() => {
const user = this.authService.user();
if (!isPlatformBrowser(this.platformId) || !user) {
this.claimedNoticeUserId = null;
return;
}
if (this.claimedNoticeUserId === user.id) {
return;
}
this.claimedNoticeUserId = user.id;
this.eventDateNoticeService.claim().subscribe({
next: ({ data }) => void this.showEventDateNotices(data, user.id),
error: (error) => console.error('Error loading event date notices', error),
});
});
}
protected readonly cartSubtotal = computed(() => {
const cart = this.cartService.cart();
return cart ? parseFloat(cart.subtotal) : 0;
@@ -172,6 +233,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);
@@ -195,6 +257,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']);
}
@@ -207,6 +274,28 @@ export class StoreLayoutComponent implements OnInit {
this.isCartOpen.update((isOpen) => !isOpen);
}
private async showEventDateNotices(notices: EventDateNotice[], userId: number): Promise<void> {
for (const notice of notices) {
if (this.authService.user()?.id !== userId) {
return;
}
const modalRef = this.modalService.open<EventDateNoticeModalComponent, void, EventDateNotice>(
EventDateNoticeModalComponent,
{
size: 'sm',
data: notice,
},
);
await firstValueFrom(modalRef.afterClosed$);
if (modalRef.dismissReason() === 'replaced') {
return;
}
}
}
protected onSearch(term: string): void {
void this.router.navigate(['/buscar'], {
queryParams: { q: term, page: 1 },
@@ -214,7 +303,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 },
});
}
@@ -236,23 +327,39 @@ export class StoreLayoutComponent implements OnInit {
const navigationSucceeded = await this.router.navigate(['/']);
if (!navigationSucceeded) {
this.toastService.danger('No se pudo cerrar la sesión. Intentá nuevamente.');
return;
}
}
this.authService.logout().subscribe({
next: () => {
next: ({ message }) => {
this.cartService.clearCart();
this.isCartOpen.set(false);
this.toastService.info(message || 'Sesión cerrada correctamente.');
if (!isLeavingCheckout) {
void this.router.navigate(['/']);
}
},
error: (err) => console.error('Error logging out', err),
error: (error: unknown) => {
this.toastService.danger(this.resolveLogoutErrorMessage(error));
console.error('Error logging out', error);
},
});
}
private resolveLogoutErrorMessage(error: unknown): string {
const payload =
typeof error === 'object' && error !== null && 'error' in error
? (error as { error?: { message?: unknown } }).error
: undefined;
return typeof payload?.message === 'string' && payload.message.trim()
? payload.message
: 'No se pudo cerrar la sesión. Intentá nuevamente.';
}
protected async onCheckoutClick(): Promise<void> {
if (this.isCreatingPurchase()) {
return;

View File

@@ -33,6 +33,10 @@ export interface LoginResponse {
user: AuthUser;
}
export interface LogoutResponse {
message: string;
}
export interface RegisterResponse {
message: string;
data: AuthUser;

View File

@@ -10,6 +10,7 @@ import {
AuthUser,
LoginPayload,
LoginResponse,
LogoutResponse,
RegisterPayload,
RegisterResponse,
ResetPasswordPayload,
@@ -155,14 +156,14 @@ export class AuthService extends BaseApiService {
.pipe(tap((user) => this.userState.set(user)));
}
logout(): Observable<void> {
logout(): Observable<LogoutResponse> {
if (!this.tokenState()) {
this.clearSession();
return of(void 0);
return of({ message: 'Sesión cerrada correctamente.' });
}
return this.http
.post<void>(`${environment.url}logout`, {})
.post<LogoutResponse>(`${environment.url}logout`, {})
.pipe(tap(() => this.clearSession()));
}

View File

@@ -72,7 +72,7 @@ export interface CatalogItemVariant {
maximum_use_date?: string | null;
effective_minimum_use_date?: string | null;
effective_maximum_use_date?: string | null;
values: Record<string, string | string[]>;
values: Record<string, CatalogVariantValue>;
}
export interface SelectedCatalogItemVariant extends CatalogItemVariant {

View File

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

View File

@@ -0,0 +1,59 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { environment } from '../../../environments/environment';
import { EventDateNotice, EventDateNoticeService } from './event-date-notice.service';
import { ApiResponse } from './api-response.interface';
import { TenantService } from './tenant.service';
describe('EventDateNoticeService', () => {
let service: EventDateNoticeService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
EventDateNoticeService,
provideHttpClient(),
provideHttpClientTesting(),
{
provide: TenantService,
useValue: { getTenant: () => ({ codigo: 'festival' }) },
},
],
});
service = TestBed.inject(EventDateNoticeService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('claims pending notices for the active tenant', () => {
const response = {
data: [
{
type: 'suspended' as const,
change_ids: [10],
title: 'FECHA CANCELADA!',
message: [{ text: 'La fecha fue cancelada.', bold: false }],
},
],
};
let result: ApiResponse<EventDateNotice[]> | undefined;
service.claim().subscribe((value) => (result = value));
const request = httpMock.expectOne(
`${environment.url}tenants/festival/event-date-notices/claim`,
);
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual({});
request.flush(response);
expect(result).toEqual(response);
});
});

View File

@@ -0,0 +1,39 @@
import { inject, Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../../environments/environment';
import { ApiResponse } from './api-response.interface';
import { BaseApiService } from './base-api.service';
import { TenantService } from './tenant.service';
export type EventDateNoticeType = 'rescheduled' | 'suspended';
export interface EventDateNoticeSegment {
text: string;
bold: boolean;
}
export interface EventDateNotice {
type: EventDateNoticeType;
change_ids: number[];
title: string;
message: EventDateNoticeSegment[];
}
@Injectable({ providedIn: 'root' })
export class EventDateNoticeService extends BaseApiService {
private readonly tenantService = inject(TenantService);
claim(): Observable<ApiResponse<EventDateNotice[]>> {
const tenant = this.tenantService.getTenant();
if (!tenant) {
throw new Error('No se pudo resolver el tenant activo.');
}
return this.withoutLoading().http.post<ApiResponse<EventDateNotice[]>>(
`${environment.url}tenants/${tenant.codigo}/event-date-notices/claim`,
{},
);
}
}

View File

@@ -0,0 +1,35 @@
import { ApiPaginatedResponse } from '../api-paginated-response.interface';
export interface StoreEvent {
id: number;
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<StoreEvent[]>;

View File

@@ -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<StoreEventPage> {
return this.http.get<StoreEventPage>(`${this.tenantService.getTenantApiUrl()}/events`, {
params: { page, ...(query ? { q: query } : {}), ...(categoryId ? { category_id: categoryId } : {}) },
});
}
get(id: number): Observable<ApiResponse<StoreEvent>> {
return this.http.get<ApiResponse<StoreEvent>>(
`${this.tenantService.getTenantApiUrl()}/events/${id}`,
);
}
}

View File

@@ -7,6 +7,7 @@ import { firstValueFrom } from 'rxjs';
import { ConfirmDeleteModalComponent } from '../../shared/components/confirm-delete-modal/confirm-delete-modal.component';
import { ConfirmModalComponent } from '../../shared/components/confirm-modal/confirm-modal.component';
import { ImageModalComponent } from '../../shared/components/image-modal/image-modal.component';
import { QrModalComponent } from '../../shared/components/qr-modal/qr-modal.component';
import { SimpleModalComponent } from '../../shared/components/simple-modal/simple-modal.component';
import { MODAL_DATA, ModalRef, ModalService } from './modal.service';
@@ -79,6 +80,21 @@ describe('ModalService', () => {
expect(ref.dismissReason()).toBeNull();
});
it('keeps a modal opened synchronously from an afterClosed subscriber', () => {
const firstRef = service.open(FirstTestModalComponent);
let secondRef: ModalRef | undefined;
firstRef.afterClosed$.subscribe(() => {
secondRef = service.open(SecondTestModalComponent);
});
firstRef.close();
expect(secondRef).toBeDefined();
expect(service.activeModal()?.ref).toBe(secondRef);
expect(service.activeModal()?.component).toBe(SecondTestModalComponent);
});
it('tracks dismiss reasons when closing programmatically', () => {
const ref = service.open(FirstTestModalComponent);
const closedSpy = vi.fn();
@@ -121,7 +137,7 @@ describe('ModalService', () => {
it('opens the standard confirm modal with default labels', () => {
const result$ = service.openConfirm({
title: 'Confirmar compra',
content: 'Esto confirmara la compra actual.',
description: 'Esto confirmara la compra actual.',
});
const activeModal = service.activeModal();
@@ -129,9 +145,9 @@ describe('ModalService', () => {
expect(activeModal?.component).toBe(ConfirmModalComponent);
expect(result$).toBeDefined();
expect(activeModal?.config).toEqual({
title: 'Confirmar compra',
data: {
content: 'Esto confirmara la compra actual.',
title: 'Confirmar compra',
description: 'Esto confirmara la compra actual.',
confirmLabel: 'Confirmar',
cancelLabel: 'Cancelar',
},
@@ -145,7 +161,7 @@ describe('ModalService', () => {
it('maps the confirm modal close result to true', async () => {
const result$ = service.openConfirm({
title: 'Confirmar compra',
content: 'Esto confirmara la compra actual.',
description: 'Esto confirmara la compra actual.',
});
const activeModal = service.activeModal();
const resultPromise = firstValueFrom(result$);
@@ -158,7 +174,7 @@ describe('ModalService', () => {
it('maps dismissing a confirm modal to false', async () => {
const result$ = service.openConfirmDelete({
title: 'Eliminar producto',
content: 'Se eliminara el producto.',
description: 'Se eliminara el producto.',
});
const activeModal = service.activeModal();
const resultPromise = firstValueFrom(result$);
@@ -173,7 +189,7 @@ describe('ModalService', () => {
it('opens the delete confirm modal preserving modal overrides', () => {
service.openConfirmDelete({
title: 'Eliminar producto',
content: 'Se eliminara el producto.',
description: 'Se eliminara el producto.',
confirmLabel: 'Eliminar',
cancelLabel: 'Conservar',
size: 'lg',
@@ -186,9 +202,9 @@ describe('ModalService', () => {
expect(activeModal?.component).toBe(ConfirmDeleteModalComponent);
expect(activeModal?.config).toEqual({
title: 'Eliminar producto',
data: {
content: 'Se eliminara el producto.',
title: 'Eliminar producto',
description: 'Se eliminara el producto.',
confirmLabel: 'Eliminar',
cancelLabel: 'Conservar',
},
@@ -202,16 +218,16 @@ describe('ModalService', () => {
it('opens the simple modal with default button label', () => {
service.openSimple({
title: 'Aviso',
content: 'Este es un aviso simple.',
description: 'Este es un aviso simple.',
});
const activeModal = service.activeModal();
expect(activeModal?.component).toBe(SimpleModalComponent);
expect(activeModal?.config).toEqual({
title: 'Aviso',
data: {
content: 'Este es un aviso simple.',
title: 'Aviso',
description: 'Este es un aviso simple.',
buttonLabel: 'Entendido',
},
size: 'md',
@@ -224,7 +240,7 @@ describe('ModalService', () => {
it('maps the simple modal close result to undefined', async () => {
const result$ = service.openSimple({
title: 'Aviso',
content: 'Este es un aviso simple.',
description: 'Este es un aviso simple.',
});
const activeModal = service.activeModal();
const resultPromise = firstValueFrom(result$);
@@ -258,6 +274,36 @@ describe('ModalService', () => {
showCloseButton: true,
});
});
it('opens the image viewer fullscreen with normalized zoom limits', () => {
service.openImage({
title: 'Producto',
src: '/images/producto.webp',
alt: 'Producto visto de frente',
initialZoom: 8,
minZoom: 0,
maxZoom: 3,
});
const activeModal = service.activeModal();
expect(activeModal?.component).toBe(ImageModalComponent);
expect(activeModal?.config).toEqual({
title: 'Producto',
size: 'full',
presentation: 'fullscreen-media',
data: {
src: '/images/producto.webp',
alt: 'Producto visto de frente',
initialZoom: 3,
minZoom: 0.1,
maxZoom: 3,
},
closeOnBackdrop: true,
closeOnEscape: true,
showCloseButton: true,
});
});
});
@Component({

View File

@@ -2,16 +2,19 @@ import { InjectionToken, Injectable, Type, signal } from '@angular/core';
import { Observable, Subject, map } from 'rxjs';
import { ConfirmDeleteModalComponent } from '../../shared/components/confirm-delete-modal/confirm-delete-modal.component';
import { ConfirmModalComponent } from '../../shared/components/confirm-modal/confirm-modal.component';
import { ImageModalComponent } from '../../shared/components/image-modal/image-modal.component';
import { QrModalComponent } from '../../shared/components/qr-modal/qr-modal.component';
import { SimpleModalComponent } from '../../shared/components/simple-modal/simple-modal.component';
export type ModalSize = 'qr' | 'sm' | 'md' | 'lg' | 'xl' | 'full';
export type ModalDismissReason = 'backdrop' | 'escape' | 'programmatic' | 'replaced';
export type ModalDismissReason = 'backdrop' | 'escape' | 'programmatic' | 'replaced' | 'swipe';
export type ModalPresentation = 'dialog' | 'fullscreen-media';
export interface ModalConfig<TData = unknown> {
title?: string;
data?: TData;
size?: ModalSize;
presentation?: ModalPresentation;
closeOnBackdrop?: boolean;
closeOnEscape?: boolean;
showCloseButton?: boolean;
@@ -28,24 +31,28 @@ export interface NormalizedModalConfig<TData = unknown> extends Omit<
}
export interface ConfirmModalData {
content: string;
title: string;
description?: string;
confirmLabel: string;
cancelLabel: string;
}
export interface ConfirmModalConfig extends Omit<ModalConfig<ConfirmModalData>, 'data'> {
content: string;
title: string;
description?: string;
confirmLabel?: string;
cancelLabel?: string;
}
export interface SimpleModalData {
content: string;
title: string;
description?: string;
buttonLabel: string;
}
export interface SimpleModalConfig extends Omit<ModalConfig<SimpleModalData>, 'data'> {
content: string;
title: string;
description?: string;
buttonLabel?: string;
}
@@ -62,6 +69,25 @@ export interface QrModalConfig extends Omit<ModalConfig<QrModalData>, 'data' | '
ticket: string;
}
export interface ImageModalData {
src: string;
alt: string;
initialZoom: number;
minZoom: number;
maxZoom: number;
}
export interface ImageModalConfig extends Omit<
ModalConfig<ImageModalData>,
'data' | 'presentation' | 'size'
> {
src: string;
alt: string;
initialZoom?: number;
minZoom?: number;
maxZoom?: number;
}
export interface ActiveModalState<TResult = unknown, TData = unknown> {
component: Type<unknown>;
config: NormalizedModalConfig<TData>;
@@ -195,13 +221,40 @@ export class ModalService {
});
}
openImage(config: ImageModalConfig): Observable<void> {
return this.openImageRef(config).afterClosed$.pipe(map(() => undefined));
}
openImageRef(config: ImageModalConfig): ModalRef<void> {
const { src, alt, initialZoom = 1, minZoom = 1, maxZoom = 4, ...modalConfig } = config;
const normalizedMinZoom = Math.max(0.1, minZoom);
const normalizedMaxZoom = Math.max(normalizedMinZoom, maxZoom);
const normalizedInitialZoom = Math.min(
normalizedMaxZoom,
Math.max(normalizedMinZoom, initialZoom),
);
return this.open(ImageModalComponent, {
...modalConfig,
size: 'full',
presentation: 'fullscreen-media',
data: {
src,
alt,
initialZoom: normalizedInitialZoom,
minZoom: normalizedMinZoom,
maxZoom: normalizedMaxZoom,
},
});
}
private close<TResult>(ref: ModalRef<TResult>, result?: TResult): void {
if (this.activeModalState()?.ref !== ref) {
return;
}
ref.finalize(result);
this.activeModalState.set(null);
ref.finalize(result);
}
private dismiss<TResult>(
@@ -212,8 +265,8 @@ export class ModalService {
return;
}
ref.finalize(undefined, reason);
this.activeModalState.set(null);
ref.finalize(undefined, reason);
}
private normalizeConfig<TData>(config: ModalConfig<TData>): NormalizedModalConfig<TData> {
@@ -225,7 +278,8 @@ export class ModalService {
private buildConfirmModalConfig(config: ConfirmModalConfig): ModalConfig<ConfirmModalData> {
const {
content,
title,
description,
confirmLabel = DEFAULT_CONFIRM_MODAL_LABELS.confirmLabel,
cancelLabel = DEFAULT_CONFIRM_MODAL_LABELS.cancelLabel,
...modalConfig
@@ -234,7 +288,8 @@ export class ModalService {
return {
...modalConfig,
data: {
content,
title,
description,
confirmLabel,
cancelLabel,
},
@@ -242,12 +297,13 @@ export class ModalService {
}
private buildSimpleModalConfig(config: SimpleModalConfig): ModalConfig<SimpleModalData> {
const { content, buttonLabel = 'Entendido', ...modalConfig } = config;
const { title, description, buttonLabel = 'Entendido', ...modalConfig } = config;
return {
...modalConfig,
data: {
content,
title,
description,
buttonLabel,
},
};

View File

@@ -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;
@@ -51,6 +41,8 @@ export interface EventDate {
date: string;
start_time: string;
end_time: string;
info_text: string | null;
isCanceled: boolean;
}
export interface EventConfig {
@@ -67,16 +59,29 @@ export interface ActiveEventDate {
date: string;
time_start: string;
time_end: string;
info_text: string | null;
isCanceled: boolean;
}
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;
@@ -113,6 +118,7 @@ export interface Tenant {
dominio: string;
base_path?: string;
site_title?: string | null;
asset_url?: string | null;
address?: string | null;
phone?: string | null;
favicon?: string | null;
@@ -126,9 +132,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;
@@ -142,9 +148,14 @@ export interface Tenant {
cart_editing_policy?: CartEditingPolicy;
checkout_editing_policy?: CartEditingPolicy;
display_cart_item_images?: boolean;
allow_ticket_refund?: boolean;
allow_ticket_total_refund?: boolean;
allow_ticket_partial_refund?: boolean;
ticket_partial_refund_percentage?: string;
social_media?: SocialMedia[];
menues?: Menu[];
categories: Category[];
categories?: Category[];
event_categories?: Category[];
}
export type TenantBootstrapResponse = ApiResponse<Tenant>;

View File

@@ -15,6 +15,7 @@ const tenant: Tenant = {
codigo: 'test',
nombre: 'Test Tenant',
dominio: 'localhost',
asset_url: 'https://s3.example.com/assets',
primary_color: '#6376F3',
secondary_color: '#A0A0A0',
danger_color: '#FF8888',
@@ -44,6 +45,7 @@ const tenantResponse: TenantBootstrapResponse = {
describe('TenantService', () => {
beforeEach(() => {
document.head.querySelectorAll('link[rel="preconnect"]').forEach((link) => link.remove());
try {
window.history.replaceState({}, '', 'http://localhost:4200/');
} catch (e) {
@@ -72,6 +74,9 @@ describe('TenantService', () => {
expect(service.status()).toBe('ready');
expect(service.tenant()).toEqual(tenant);
expect(service.getTenant()).toEqual(tenant);
expect(
document.head.querySelector('link[rel="preconnect"][href="https://s3.example.com/"]'),
).not.toBeNull();
httpController.verify();
});

View File

@@ -1,4 +1,4 @@
import { isPlatformBrowser, isPlatformServer } from '@angular/common';
import { DOCUMENT, isPlatformBrowser, isPlatformServer } from '@angular/common';
import { HttpErrorResponse } from '@angular/common/http';
import {
inject,
@@ -26,6 +26,7 @@ import {
providedIn: 'root',
})
export class TenantService extends BaseApiService {
private readonly document = inject(DOCUMENT);
private readonly platformId = inject(PLATFORM_ID);
private readonly request = inject(REQUEST, { optional: true });
private readonly responseInit = inject(RESPONSE_INIT, { optional: true });
@@ -182,10 +183,44 @@ 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');
}
private ensureAssetPreconnect(assetUrl: string | null | undefined): void {
if (!assetUrl) {
return;
}
let origin: string;
try {
const url = new URL(assetUrl);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return;
}
origin = url.origin;
} catch {
return;
}
const existingPreconnects =
this.document.head.querySelectorAll<HTMLLinkElement>('link[rel="preconnect"]');
if ([...existingPreconnects].some((link) => link.href === `${origin}/`)) {
return;
}
const link = this.document.createElement('link');
link.rel = 'preconnect';
link.href = origin;
this.document.head.append(link);
}
private setNotFound(): void {
this.tenantState.set(null);
this.statusState.set('not-found');

View File

@@ -1,3 +1,4 @@
import { DOCUMENT } from '@angular/common';
import { TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import { ToastService } from './toast.service';
@@ -6,14 +7,16 @@ describe('ToastService', () => {
let service: ToastService;
beforeEach(() => {
vi.useFakeTimers();
TestBed.configureTestingModule({
providers: [ToastService]
});
TestBed.inject(DOCUMENT).defaultView?.sessionStorage.removeItem('shopit.pending-toast');
service = TestBed.inject(ToastService);
vi.useFakeTimers();
});
afterEach(() => {
TestBed.inject(DOCUMENT).defaultView?.sessionStorage.removeItem('shopit.pending-toast');
vi.useRealTimers();
});
@@ -98,4 +101,23 @@ describe('ToastService', () => {
expect(service.toasts().length).toBe(1);
});
it('should restore a toast queued for after a full page reload', () => {
service.showAfterReload('Sesión iniciada correctamente.', 'success', 0);
TestBed.resetTestingModule();
TestBed.configureTestingModule({ providers: [ToastService] });
service = TestBed.inject(ToastService);
expect(service.toasts()).toEqual([
expect.objectContaining({
message: 'Sesión iniciada correctamente.',
type: 'success',
duration: 0,
}),
]);
expect(
TestBed.inject(DOCUMENT).defaultView?.sessionStorage.getItem('shopit.pending-toast'),
).toBeNull();
});
});

View File

@@ -1,4 +1,7 @@
import { Injectable, signal } from '@angular/core';
import { DOCUMENT, isPlatformBrowser } from '@angular/common';
import { inject, Injectable, PLATFORM_ID, signal } from '@angular/core';
const PENDING_TOAST_STORAGE_KEY = 'shopit.pending-toast';
export interface Toast {
id: string;
@@ -11,9 +14,15 @@ export interface Toast {
providedIn: 'root'
})
export class ToastService {
private readonly document = inject(DOCUMENT);
private readonly platformId = inject(PLATFORM_ID);
private readonly toastsSignal = signal<Toast[]>([]);
readonly toasts = this.toastsSignal.asReadonly();
constructor() {
this.restorePendingToast();
}
show(message: string, type: 'success' | 'danger' | 'info' = 'info', duration = 3000): string {
const id = Math.random().toString(36).substring(2, 9);
const newToast: Toast = { id, message, type, duration };
@@ -41,7 +50,50 @@ export class ToastService {
return this.show(message, 'success', duration);
}
showAfterReload(
message: string,
type: Toast['type'] = 'info',
duration = 3000,
): void {
if (!isPlatformBrowser(this.platformId)) {
return;
}
this.document.defaultView?.sessionStorage.setItem(
PENDING_TOAST_STORAGE_KEY,
JSON.stringify({ message, type, duration }),
);
}
dismiss(id: string): void {
this.toastsSignal.update((toasts) => toasts.filter((t) => t.id !== id));
}
private restorePendingToast(): void {
if (!isPlatformBrowser(this.platformId)) {
return;
}
const storage = this.document.defaultView?.sessionStorage;
const pendingToast = storage?.getItem(PENDING_TOAST_STORAGE_KEY);
if (!pendingToast) {
return;
}
storage?.removeItem(PENDING_TOAST_STORAGE_KEY);
try {
const parsed = JSON.parse(pendingToast) as Partial<Toast>;
if (
typeof parsed.message === 'string' &&
(parsed.type === 'success' || parsed.type === 'danger' || parsed.type === 'info')
) {
this.show(parsed.message, parsed.type, parsed.duration);
}
} catch {
// Ignore malformed session data left by an older or interrupted client.
}
}
}

View File

@@ -83,6 +83,9 @@
Abrir modal ancho
</app-button>
<app-button (click)="openSimpleModal()"> Abrir simple modal </app-button>
<app-button variant="secondary" (click)="openImageModal()">
Abrir visor de imagen
</app-button>
</div>
<div class="modal-showcase__result" data-testid="modal-last-result">
{{ lastModalResult }}
@@ -703,7 +706,7 @@
<app-location-map
[locations]="mapLocations"
[markerColor]="tenant()?.primary_color ?? '#24a9e0'"
mapStyle="minimal-light"
mapStyle="standard"
ariaLabel="Mapa de puntos de atención en Rosario"
/>
</div>

View File

@@ -51,6 +51,7 @@ function createModalServiceStub() {
open: vi.fn(),
openConfirm: vi.fn().mockReturnValue(of(true)),
openConfirmDelete: vi.fn().mockReturnValue(of(false)),
openImage: vi.fn().mockReturnValue(of(undefined)),
};
}
@@ -281,6 +282,9 @@ describe('ReutilizablesTestPageComponent', () => {
const deleteButton = buttons.find((button) =>
button.textContent?.includes('Abrir confirm delete'),
) as HTMLButtonElement;
const imageButton = buttons.find((button) =>
button.textContent?.includes('Abrir visor de imagen'),
) as HTMLButtonElement;
expect(element.querySelector('[data-testid="modal-last-result"]')?.textContent).toContain(
'Todavia no se abrio ningun modal.',
@@ -290,18 +294,25 @@ describe('ReutilizablesTestPageComponent', () => {
fixture.detectChanges();
deleteButton.click();
fixture.detectChanges();
imageButton.click();
fixture.detectChanges();
expect(modalServiceStub.openConfirm).toHaveBeenCalledWith({
title: 'Confirmar accion',
content: 'Caso base para verificar apertura, cierre y devolucion de resultado.',
description: 'Caso base para verificar apertura, cierre y devolucion de resultado.',
confirmLabel: 'Confirmar',
});
expect(modalServiceStub.openConfirmDelete).toHaveBeenCalledWith({
title: 'Eliminar producto',
content:
description:
'Esta accion eliminara el producto del catalogo. Podras volver a crearlo manualmente.',
confirmLabel: 'Eliminar',
});
expect(modalServiceStub.openImage).toHaveBeenCalledWith({
title: 'Mochila urbana roja',
src: '/images/carousel-mochila-roja.webp',
alt: 'Mochila urbana roja vista de frente',
});
expect(element.querySelector('[data-testid="modal-last-result"]')?.textContent).toContain(
'Resultado: false',
);

View File

@@ -448,7 +448,7 @@ export class ReutilizablesTestPageComponent {
protected openBasicModal(): void {
this.openConfirmModal({
title: 'Confirmar accion',
content: 'Caso base para verificar apertura, cierre y devolucion de resultado.',
description: 'Caso base para verificar apertura, cierre y devolucion de resultado.',
confirmLabel: 'Confirmar',
});
}
@@ -456,7 +456,7 @@ export class ReutilizablesTestPageComponent {
protected openConfirmDeleteModal(): void {
this.openConfirmDelete({
title: 'Eliminar producto',
content:
description:
'Esta accion eliminara el producto del catalogo. Podras volver a crearlo manualmente.',
confirmLabel: 'Eliminar',
});
@@ -465,7 +465,7 @@ export class ReutilizablesTestPageComponent {
protected openLockedModal(): void {
this.openConfirmModal({
title: 'Modal bloqueado',
content: 'Este modal no se cierra tocando el backdrop ni con la tecla Escape.',
description: 'Este modal no se cierra tocando el backdrop ni con la tecla Escape.',
confirmLabel: 'Entendido',
closeOnBackdrop: false,
closeOnEscape: false,
@@ -475,7 +475,7 @@ export class ReutilizablesTestPageComponent {
protected openWideModal(): void {
this.openConfirmModal({
title: 'Modal ancho',
content: 'Demuestra una variante visual mas amplia para contenido mas pesado.',
description: 'Demuestra una variante visual mas amplia para contenido mas pesado.',
confirmLabel: 'Seguir',
size: 'xl',
});
@@ -485,7 +485,7 @@ export class ReutilizablesTestPageComponent {
this.modalService
.openSimple({
title: 'Mensaje del sistema',
content: 'Este es un mensaje simple del sistema que no requiere confirmación.',
description: 'Este es un mensaje simple del sistema que no requiere confirmación.',
buttonLabel: 'Entendido',
})
.subscribe(() => {
@@ -493,6 +493,14 @@ export class ReutilizablesTestPageComponent {
});
}
protected openImageModal(): void {
this.modalService.openImage({
title: 'Mochila urbana roja',
src: '/images/carousel-mochila-roja.webp',
alt: 'Mochila urbana roja vista de frente',
});
}
private openConfirmModal(config: Parameters<ModalService['openConfirm']>[0]): void {
this.modalService.openConfirm(config).subscribe((confirmed) => {
this.lastModalResult = `Resultado: ${confirmed}`;

View File

@@ -0,0 +1,19 @@
<a class="event-card" [routerLink]="['/evento', event().id]">
<div class="event-card__image">
@if (event().image) {
<img [src]="event().image" [alt]="event().title" />
}
</div>
<div class="event-card__body">
<h2 class="event-card__title">{{ event().title | uppercase }}</h2>
@if (event().subtitle) {
<p class="event-card__subtitle">{{ event().subtitle }}</p>
}
@if (event().date_text) {
<p class="event-card__meta"><i class="fa-regular fa-calendar" aria-hidden="true"></i>{{ event().date_text }}</p>
}
@if (event().location) {
<p class="event-card__meta"><i class="fa-solid fa-location-dot" aria-hidden="true"></i>{{ event().location }}</p>
}
</div>
</a>

View File

@@ -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; }

View File

@@ -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<StoreEvent>();
}

View File

@@ -0,0 +1,20 @@
@if (loading()) {
<p class="event-list__message">Cargando eventos...</p>
} @else if (error()) {
<p class="event-list__message" role="alert">No pudimos cargar los eventos.</p>
} @else if (results(); as page) {
@if (page.data.length) {
<div class="event-list__grid">
@for (event of page.data; track event.id) {
<app-event-card [event]="event" />
}
</div>
@if (page.meta.last_page > 1) {
<div class="event-list__pagination">
<app-paginator [page]="page.meta.current_page" [totalPages]="page.meta.last_page" (pageChange)="changePage($event)" />
</div>
}
} @else {
<p class="event-list__message">{{ search() ? 'No encontramos eventos para tu búsqueda.' : 'No hay eventos disponibles en este momento.' }}</p>
}
}

View File

@@ -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; } }

View File

@@ -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<number | null>(null);
protected readonly results = signal<StoreEventPage | null>(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',
});
}
}

View File

@@ -1,7 +1,10 @@
import { TestBed } from '@angular/core/testing';
import { beforeEach, describe, expect, it } from 'vitest';
import { ProductAttribute } from '../../../../core/services/catalog/catalog.interface';
import {
CatalogItemVariant,
ProductAttribute,
} from '../../../../core/services/catalog/catalog.interface';
import { ProductAttributeSelectorComponent } from './product-attribute-selector.component';
describe('ProductAttributeSelectorComponent', () => {
@@ -24,6 +27,63 @@ describe('ProductAttributeSelectorComponent', () => {
}).compileComponents();
});
it.each([{ size: { value: 'S', label: 'Small' } }, { size: [{ value: 'S', label: 'Small' }] }])(
'initializes and matches structured variant values: %j',
(values) => {
const fixture = TestBed.createComponent(ProductAttributeSelectorComponent);
const variant: CatalogItemVariant = { id: 1, maximum_addable_quantity: null, values };
const emittedIds: Array<number | null> = [];
fixture.componentInstance.variantChange.subscribe((selected) =>
emittedIds.push(selected?.id ?? null),
);
fixture.componentRef.setInput('attributes', [sizeAttribute]);
fixture.componentRef.setInput('inventoryPolicy', 'unlimited');
fixture.componentRef.setInput('variants', [variant]);
fixture.componentRef.setInput('selectedVariant', variant);
fixture.detectChanges();
const button = fixture.nativeElement.querySelector(
'.attribute-selector__text-option',
) as HTMLButtonElement;
expect(button.getAttribute('aria-pressed')).toBe('true');
expect(button.disabled).toBe(false);
expect(emittedIds.at(-1)).toBe(1);
},
);
it.each([0, 2, null])(
'uses maximum quantity %s for alternatives to a preselected option',
(maximum) => {
const fixture = TestBed.createComponent(ProductAttributeSelectorComponent);
const variants: CatalogItemVariant[] = [
{ id: 1, maximum_addable_quantity: 3, values: { size: { value: 'S', label: 'Small' } } },
{
id: 2,
maximum_addable_quantity: maximum,
values: { size: { value: 'M', label: 'Medium' } },
},
];
const emittedIds: Array<number | null> = [];
fixture.componentInstance.variantChange.subscribe((variant) =>
emittedIds.push(variant?.id ?? null),
);
fixture.componentRef.setInput('attributes', [sizeAttribute]);
fixture.componentRef.setInput('inventoryPolicy', maximum === null ? 'unlimited' : 'tracked');
fixture.componentRef.setInput('variants', variants);
fixture.componentRef.setInput('selectedVariant', variants[0]);
fixture.detectChanges();
const buttons = fixture.nativeElement.querySelectorAll(
'.attribute-selector__text-option',
) as NodeListOf<HTMLButtonElement>;
expect(buttons[1].disabled).toBe(maximum === 0);
buttons[1].click();
fixture.detectChanges();
expect(emittedIds.at(-1)).toBe(maximum === 0 ? 1 : 2);
expect(buttons[0].disabled).toBe(false);
},
);
it('keeps an unlimited option available when maximum quantity is null', () => {
const fixture = TestBed.createComponent(ProductAttributeSelectorComponent);
fixture.componentRef.setInput('attributes', [sizeAttribute]);
@@ -94,7 +154,16 @@ describe('ProductAttributeSelectorComponent', () => {
fixture.componentRef.setInput('variants', [
{ id: 1, maximum_addable_quantity: null, values: { event_date: '1' } },
{ id: 2, maximum_addable_quantity: null, values: { event_date: '2' } },
{ id: 3, maximum_addable_quantity: null, values: { event_date: ['1', '2'] } },
{
id: 3,
maximum_addable_quantity: null,
values: {
event_date: [
{ value: '1', label: '09/10/2026' },
{ value: '2', label: '10/10/2026' },
],
},
},
]);
fixture.detectChanges();

View File

@@ -51,22 +51,15 @@ export class ProductAttributeSelectorComponent {
const optionNormalized = this.normalizeText(option.value || option.label);
const selectedForAttribute = selections[attribute.codigo] ?? [];
if (
!attribute.allow_multi_select &&
selectedForAttribute.length >= 1 &&
!selectedForAttribute.includes(option.id)
) {
availability[attribute.codigo][option.id] = false;
continue;
}
const isAvailable = variants.some((variant) => {
if (!this.isVariantAvailable(variant)) return false;
const variantAttrValues = this.getVariantAttributeValues(attribute, variant.values);
const desiredOptionIds = selectedForAttribute.includes(option.id)
? selectedForAttribute
: [...selectedForAttribute, option.id];
const desiredOptionIds = !attribute.allow_multi_select
? [option.id]
: selectedForAttribute.includes(option.id)
? selectedForAttribute
: [...selectedForAttribute, option.id];
const desiredValues = desiredOptionIds
.map((id) => attribute.options.find((candidate) => candidate.id === id))
.filter((candidate): candidate is ProductAttributeOption => candidate !== undefined)
@@ -202,7 +195,7 @@ export class ProductAttributeSelectorComponent {
private getVariantAttributeValues(
attribute: ProductAttribute,
variantAttributes: Record<string, string | string[]>,
variantAttributes: CatalogItemVariant['values'],
): string[] {
const normalizedCodigo = this.normalizeText(attribute.codigo);
const normalizedNombre = this.normalizeText(attribute.nombre);
@@ -211,7 +204,9 @@ export class ProductAttributeSelectorComponent {
const normalizedKey = this.normalizeText(key);
if (normalizedKey === normalizedCodigo || normalizedKey === normalizedNombre) {
return (Array.isArray(value) ? value : [value]).map((item) => this.normalizeText(item));
return (Array.isArray(value) ? value : [value]).map((item) =>
this.normalizeText(typeof item === 'string' ? item : item.value || item.label),
);
}
}

View File

@@ -54,7 +54,7 @@
<!-- Thumbnails Row -->
@if (images().length > 1) {
<div class="product-carousel__thumbnails">
@for (image of images(); track image; let idx = $index) {
@for (image of images(); track $index; let idx = $index) {
<button
type="button"
class="product-carousel__thumbnail border-0 p-0 overflow-hidden bg-light"

View File

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

View File

@@ -15,6 +15,8 @@ export interface TicketResponse {
starts_at: string | null;
expires_at: string | null;
used_at: string | null;
status?: 'active' | 'expired' | 'used' | 'disabled' | 'cancelled' | 'refunded';
status_label?: string;
is_valid: boolean;
is_expired: boolean;
is_used: boolean;
@@ -26,9 +28,7 @@ export class TicketService extends BaseApiService {
getTickets(): Promise<TicketResponse[]> {
return firstValueFrom(
this.http.get<{ data: TicketResponse[] }>(
`${this.tenantService.getTenantApiUrl()}/tickets`,
),
this.http.get<{ data: TicketResponse[] }>(`${this.tenantService.getTenantApiUrl()}/tickets`),
).then((response) => response.data ?? []);
}

View File

@@ -63,7 +63,11 @@ export class TicketsPage implements OnInit {
}
protected isInactive(ticket: TicketResponse): boolean {
return ticket.is_expired || ticket.is_used;
return (
(ticket.status !== undefined && ticket.status !== 'active') ||
ticket.is_expired ||
ticket.is_used
);
}
protected formatDate(ticket: TicketResponse): string | null {

View File

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

View File

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

View File

@@ -39,6 +39,8 @@ describe('CheckoutPageComponent payment validation', () => {
let tenantState: ReturnType<
typeof signal<{
codigo: string;
storefront_website_type_code?: string | null;
checkout_summary_type?: 'default' | 'event';
checkout_editing_policy?: CartEditingPolicy;
}>
>;
@@ -159,6 +161,64 @@ describe('CheckoutPageComponent payment validation', () => {
expect(component.checkoutRemainingTime()).toBe('10:00');
});
it('checks the purchase when the countdown expires and redirects to the expired status', async () => {
const countdown = TestBed.inject(CheckoutCountdownService);
checkoutServiceStub.getPurchase.mockResolvedValue({ status: 'expired' });
const { fixture, component } = createComponent();
component.createdPurchase.set({
id: 25,
status: 'created',
expires_at: new Date(Date.now() + 1_000).toISOString(),
expires_in_seconds: 1,
server_time: new Date().toISOString(),
items: [],
subtotal: '0.00',
total: '0.00',
});
countdown.synchronize(component.createdPurchase());
fixture.detectChanges();
await vi.advanceTimersByTimeAsync(1_000);
fixture.detectChanges();
await Promise.resolve();
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledOnce();
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledWith('tenant-test', 25);
expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25], {
queryParams: { status: 'expired' },
});
});
it('checks the purchase only once when the expired countdown remains at zero', async () => {
const countdown = TestBed.inject(CheckoutCountdownService);
checkoutServiceStub.getPurchase.mockResolvedValue({
status: 'pending_payment',
expires_at: new Date(Date.now() - 1_000).toISOString(),
expires_in_seconds: 0,
server_time: new Date().toISOString(),
});
const { fixture, component } = createComponent();
component.createdPurchase.set({
id: 25,
status: 'pending_payment',
expires_at: new Date(Date.now() - 1_000).toISOString(),
expires_in_seconds: 0,
server_time: new Date().toISOString(),
items: [],
subtotal: '0.00',
total: '0.00',
});
countdown.synchronize(component.createdPurchase());
fixture.detectChanges();
await Promise.resolve();
fixture.detectChanges();
await vi.advanceTimersByTimeAsync(5_000);
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledOnce();
expect(routerStub.navigate).not.toHaveBeenCalled();
});
it('polls QR after five seconds and navigates only when payment is paid', async () => {
checkoutServiceStub.getPurchase
.mockResolvedValueOnce({ status: 'pending_payment' })
@@ -410,6 +470,49 @@ describe('CheckoutPageComponent payment validation', () => {
});
});
it('uses the event image and removes item images for multi-event checkout', () => {
tenantState.set({
...tenantState(),
checkout_summary_type: 'event',
});
const { component } = createComponent();
component.createdPurchase.set({
id: 25,
status: 'created',
event: {
id: 12,
title: 'Festival de prueba',
image: 'https://example.com/event.jpg',
},
items: [
{
id: 91,
quantity: 1,
unit_price: '10000.00',
line_total: '10000.00',
source_catalog_item_id: 8,
source_variant_id: null,
item_details: {
nombre: 'Entrada general',
descripcion: null,
slug: 'entrada-general',
imagen: 'https://example.com/product.jpg',
attributes: [],
},
},
],
subtotal: '10000.00',
total: '10000.00',
});
expect(component.checkoutEvent()).toEqual({
id: 12,
title: 'Festival de prueba',
image: 'https://example.com/event.jpg',
});
expect(component.mappedCartItems()[0].imageUrl).toBeNull();
});
it('keeps the checkout hidden while the purchase is loading', async () => {
let resolvePurchase!: (purchase: any) => void;
routeParamMap = convertToParamMap({ id: 25 });

View File

@@ -2,6 +2,7 @@ import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
OnDestroy,
OnInit,
@@ -86,6 +87,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
private paymentMethodRequestId = 0;
private navigationStarted = false;
private cancelPurchasePromise: Promise<boolean> | null = null;
private expirationCheckPurchaseId: number | null = null;
@ViewChild(StepperComponent) stepper!: StepperComponent;
@@ -136,6 +138,12 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
const purchase = this.createdPurchase();
return purchase ? purchase.items.map((item) => this.mapPurchaseItemToMock(item)) : [];
});
protected readonly usesEventCheckoutSummary = computed(
() => this.tenantService.tenant()?.checkout_summary_type === 'event',
);
protected readonly checkoutEvent = computed(() =>
this.usesEventCheckoutSummary() ? (this.createdPurchase()?.event ?? null) : null,
);
protected readonly isStep1Valid = signal(this.form.valid);
protected readonly paymentMethods: ReadonlyArray<PaymentMethodOption> = [
@@ -176,6 +184,28 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
);
constructor() {
effect(() => {
const remainingSeconds = this.checkoutCountdownService.remainingSeconds();
const purchaseId = this.createdPurchaseId() ?? this.createdPurchase()?.id ?? null;
if (remainingSeconds !== null && remainingSeconds > 0) {
this.expirationCheckPurchaseId = null;
return;
}
if (
remainingSeconds !== 0 ||
purchaseId === null ||
this.navigationStarted ||
this.expirationCheckPurchaseId === purchaseId
) {
return;
}
this.expirationCheckPurchaseId = purchaseId;
void this.checkPurchaseAfterCountdownExpiration(purchaseId);
});
this.form.statusChanges
.pipe(startWith(this.form.status))
.subscribe(() => this.isStep1Valid.set(this.form.valid));
@@ -211,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),
@@ -719,6 +749,35 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
void this.router.navigate(['/checkout/status', purchaseId]);
}
private async checkPurchaseAfterCountdownExpiration(purchaseId: number): Promise<void> {
const tenant = this.tenantService.tenant();
if (!tenant || this.navigationStarted) {
return;
}
try {
const purchase = await this.checkoutService
.withCustomLoading()
.getPurchase(tenant.codigo, purchaseId);
if (this.navigationStarted) {
return;
}
if (purchase.status === 'expired') {
this.navigateToExpiredPurchaseStatus();
return;
}
this.checkoutCountdownService.synchronize(purchase);
} catch (error) {
if (this.isPurchaseExpiredError(error)) {
this.navigateToExpiredPurchaseStatus();
}
}
}
private async loadPurchase(purchaseId: number): Promise<void> {
const tenant = this.tenantService.tenant();
if (!tenant) {

View File

@@ -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) {
<section aria-labelledby="event-category-title">
<h1 id="event-category-title" class="h3 mb-4">{{ selectedCategory.nombre }}</h1>
<app-event-list [categoryId]="selectedCategory.id" />
</section>
} @else {
<p role="alert">La categoría no existe.</p>
}
`,
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;
});
}

View File

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

View File

@@ -0,0 +1,43 @@
:host { display: block; color: #666666; }
.event-detail { margin-bottom: 3rem; }
.event-detail__sidebar, .event-detail__main { min-width: 0; }
.event-detail__image { background: #e7e8e9; }
.event-detail__image--placeholder { aspect-ratio: 1.43; display: grid; place-items: center; }
.event-detail__image img { width: 100%; height: auto; display: block; }
.event-detail__image span { font-size: 3rem; color: #aaa; }
.event-detail__description { margin-top: 1.5rem; }
.event-detail__description h2, .event-detail__social h2, .event-detail__section-heading h2 { margin: 0 0 .65rem; color: #666666; font-size: 15px; font-weight: 700; text-transform: uppercase; letter-spacing: .015em; }
.event-detail__description p { margin: 0; white-space: pre-line; line-height: 1.36; font-size: 14px; }
.event-detail__social { display: grid; gap: .35rem; margin-top: 2.3rem; }
.event-detail__social a { color: #666666; font-family: 'Gotham Book', sans-serif; font-size: 14px; font-weight: 400; display: flex; align-items: center; gap: .5rem; text-decoration: none; font-style: italic; }
.event-detail__social a:hover { color: var(--tenant-primary); text-decoration: 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 (max-width: 575.98px) {
.event-detail__main { margin-top: 2rem; }
.event-detail__description, .event-detail__social { margin-top: 1.5rem; }
.event-detail__header { min-height: 0; margin-bottom: 2rem; }
.event-detail__buttons { flex-wrap: wrap; }
.event-detail__buttons app-button { flex: 1 1 100%; }
}

View File

@@ -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<StoreEvent['catalog_items']>[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<StoreEvent | null>(null);
protected readonly loading = signal(true);
protected readonly selectedDateId = signal<number | null>(null);
protected readonly quantities = signal<Record<number, number>>({});
protected readonly submitting = signal(false);
protected readonly mapLocations = computed<MapLocation[]>(() => {
const event = this.event();
const coordinates = event?.exact_location;
if (!event || !coordinates) return [];
return [{
id: event.id,
label: event.title,
latitude: coordinates.latitude,
longitude: coordinates.longitude,
address: event.location ?? undefined,
}];
});
protected readonly total = computed(() => (this.event()?.catalog_items ?? []).reduce((sum, product) => {
const choice = this.resolveChoice(product);
return sum + (choice ? choice.price * this.quantityFor(product.id) : 0);
}, 0));
protected readonly hasSelectedItems = computed(() =>
Object.values(this.quantities()).some((quantity) => quantity > 0),
);
constructor() {
this.route.paramMap.pipe(
map((params) => Number(params.get('id'))),
switchMap((id) => Number.isInteger(id) && id > 0
? this.events.get(id).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<string, string> = {
instagram: 'Instagram', facebook: 'Facebook', whatsapp: 'WhatsApp',
youtube: 'YouTube', tiktok: 'TikTok', x: 'X', twitter: 'X',
};
return labels[code.toLowerCase()] ?? code;
}
protected socialIcon(code: string): string {
const brands = ['instagram', 'facebook', 'whatsapp', 'youtube', 'tiktok', 'x-twitter', 'linkedin'];
const normalized = code.toLowerCase() === 'twitter' || code.toLowerCase() === 'x'
? 'x-twitter'
: code.toLowerCase();
return brands.includes(normalized) ? `fa-brands fa-${normalized}` : 'fa-solid fa-link';
}
protected cancel(): void {
this.quantities.set({});
}
protected async buyNow(): Promise<void> {
const event = this.event();
if (!event || this.submitting()) return;
const items: DirectCheckoutItem[] = [];
for (const product of event.catalog_items ?? []) {
const quantity = this.quantityFor(product.id);
if (quantity === 0) continue;
const choice = this.resolveChoice(product);
if (!choice) return;
items.push({ catalog_item_id: product.id, variant_id: choice.variantId, cantidad: quantity });
}
if (items.length === 0) return;
if (!this.auth.user()) {
await this.router.navigate(['/login'], { queryParams: { returnUrl: this.router.url } });
return;
}
const tenant = this.tenant.tenant();
if (!tenant) {
this.toast.danger('No se pudo identificar la tienda.');
return;
}
this.submitting.set(true);
try {
const purchase = await this.checkout.startCheckout(tenant.codigo, { direct_items: items });
await this.router.navigate(['/checkout', purchase.id]);
} catch (error) {
const message = error instanceof HttpErrorResponse && typeof error.error?.message === 'string'
? error.error.message
: 'No se pudo iniciar la compra.';
this.toast.danger(message);
} finally {
this.submitting.set(false);
}
}
private resolveChoice(product: EventProduct): { variantId: number | null; price: number; maximumQuantity: number | null } | null {
if (product.requires_selection) return null;
if (product.variants.length === 0) {
return { variantId: null, price: Number(product.price), maximumQuantity: product.maximum_quantity };
}
const dateId = this.selectedDateId();
const candidates = product.variants.filter((variant) =>
dateId === null || variant.event_date_ids.length === 0 || variant.event_date_ids.includes(dateId));
if (candidates.length !== 1) return null;
return {
variantId: candidates[0].id,
price: Number(candidates[0].price),
maximumQuantity: candidates[0].maximum_quantity,
};
}
}

View File

@@ -0,0 +1,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: '<app-event-list />',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EventHomePageComponent {}

View File

@@ -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: '<section aria-labelledby="event-search-title"><h1 id="event-search-title" class="h3 mb-4">Resultados de búsqueda</h1><app-event-list [search]="true" /></section>',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EventSearchPageComponent {}

View File

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

View File

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

View File

@@ -3,11 +3,21 @@ import { provideRouter, Router } from '@angular/router';
import { of, throwError } from 'rxjs';
import { AuthService } from '../../../../core/services/auth/auth.service';
import { ToastService } from '../../../../core/services/toast.service';
import { LoginPageComponent } from './login-page.component';
describe('LoginPageComponent', () => {
let toastService: {
danger: ReturnType<typeof vi.fn>;
showAfterReload: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
TestBed.resetTestingModule();
toastService = {
danger: vi.fn(),
showAfterReload: vi.fn(),
};
});
it('submits credentials and redirects to home with a full page reload on success', async () => {
@@ -16,14 +26,18 @@ describe('LoginPageComponent', () => {
of({
id: 1,
nombre_apellido: 'Ada Lovelace',
email: 'ada@example.com'
})
)
email: 'ada@example.com',
}),
),
};
await TestBed.configureTestingModule({
imports: [LoginPageComponent],
providers: [provideRouter([]), { provide: AuthService, useValue: authService }]
providers: [
provideRouter([]),
{ provide: AuthService, useValue: authService },
{ provide: ToastService, useValue: toastService },
],
}).compileComponents();
const fixture = TestBed.createComponent(LoginPageComponent);
@@ -34,17 +48,56 @@ describe('LoginPageComponent', () => {
component.form.setValue({
email: 'ada@example.com',
password: 'secret123'
password: 'secret123',
});
component.onSubmit();
expect(authService.login).toHaveBeenCalledWith({
email: 'ada@example.com',
password: 'secret123'
password: 'secret123',
});
expect(redirectSpy).toHaveBeenCalled();
expect(navigateSpy).not.toHaveBeenCalled();
expect(toastService.showAfterReload).toHaveBeenCalledWith(
'Sesión iniciada correctamente.',
'success',
);
});
it('serializes a stored return URL before reloading so tenant base paths are restored', async () => {
await TestBed.configureTestingModule({
imports: [LoginPageComponent],
providers: [
provideRouter([]),
{ provide: AuthService, useValue: {} },
{ provide: ToastService, useValue: toastService },
],
}).compileComponents();
const fixture = TestBed.createComponent(LoginPageComponent);
const component = fixture.componentInstance as any;
const router = TestBed.inject(Router);
const assign = vi.fn();
const parsedUrl = router.parseUrl('/');
component.document = {
defaultView: {
sessionStorage: {
getItem: vi.fn().mockReturnValue('/'),
removeItem: vi.fn(),
},
},
location: { assign },
};
const parseUrlSpy = vi.spyOn(router, 'parseUrl').mockReturnValue(parsedUrl);
const serializeUrlSpy = vi.spyOn(router, 'serializeUrl').mockReturnValue('/sonder');
component.redirectToHome();
expect(parseUrlSpy).toHaveBeenCalledWith('/');
expect(serializeUrlSpy).toHaveBeenCalledWith(parsedUrl);
expect(assign).toHaveBeenCalledWith('/sonder');
});
it('surfaces backend login errors', async () => {
@@ -53,16 +106,20 @@ describe('LoginPageComponent', () => {
throwError(() => ({
error: {
errors: {
email: ['Las credenciales son invalidas.']
}
}
}))
)
email: ['Las credenciales son invalidas.'],
},
},
})),
),
};
await TestBed.configureTestingModule({
imports: [LoginPageComponent],
providers: [provideRouter([]), { provide: AuthService, useValue: authService }]
providers: [
provideRouter([]),
{ provide: AuthService, useValue: authService },
{ provide: ToastService, useValue: toastService },
],
}).compileComponents();
const fixture = TestBed.createComponent(LoginPageComponent);
@@ -70,22 +127,27 @@ describe('LoginPageComponent', () => {
component.form.setValue({
email: 'ada@example.com',
password: 'wrong-password'
password: 'wrong-password',
});
component.onSubmit();
expect(component.serverError()).toBe('Las credenciales son invalidas.');
expect(toastService.danger).toHaveBeenCalledWith('Las credenciales son invalidas.');
});
it('validates email length and password minimum length before submit', async () => {
const authService = {
login: vi.fn()
login: vi.fn(),
};
await TestBed.configureTestingModule({
imports: [LoginPageComponent],
providers: [provideRouter([]), { provide: AuthService, useValue: authService }]
providers: [
provideRouter([]),
{ provide: AuthService, useValue: authService },
{ provide: ToastService, useValue: toastService },
],
}).compileComponents();
const fixture = TestBed.createComponent(LoginPageComponent);
@@ -93,12 +155,15 @@ describe('LoginPageComponent', () => {
component.form.setValue({
email: `${'a'.repeat(250)}@example.com`,
password: '1234567'
password: '1234567',
});
component.onSubmit();
expect(authService.login).not.toHaveBeenCalled();
expect(component.getControlError('email')).toBe('No puede superar los 255 caracteres.');
expect(component.getControlError('password')).toBe('Debe tener al menos 8 caracteres.');
expect(toastService.danger).toHaveBeenCalledWith(
'Revisá los datos ingresados para iniciar sesión.',
);
});
});

View File

@@ -4,6 +4,7 @@ import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { AuthService } from '../../../../core/services/auth/auth.service';
import { ToastService } from '../../../../core/services/toast.service';
import { ButtonComponent } from '../../../../shared/components/button/button.component';
import { InputComponent } from '../../../../shared/components/input/input.component';
@@ -16,13 +17,14 @@ const POST_LOGIN_RETURN_URL_KEY = 'shopit.auth.return-url';
imports: [ReactiveFormsModule, InputComponent, ButtonComponent],
templateUrl: './login-page.component.html',
styleUrl: './login-page.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class LoginPageComponent {
private readonly formBuilder = inject(FormBuilder);
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
private readonly authService = inject(AuthService);
private readonly toastService = inject(ToastService);
private readonly document = inject(DOCUMENT);
private readonly platformId = inject(PLATFORM_ID);
@@ -32,7 +34,7 @@ export class LoginPageComponent {
protected readonly form = this.formBuilder.nonNullable.group({
email: ['', [Validators.required, Validators.email, Validators.maxLength(EMAIL_MAX_LENGTH)]],
password: ['', [Validators.required, Validators.minLength(PASSWORD_MIN_LENGTH)]]
password: ['', [Validators.required, Validators.minLength(PASSWORD_MIN_LENGTH)]],
});
protected readonly submitted = this.submittedState.asReadonly();
protected readonly serverError = this.serverErrorState.asReadonly();
@@ -64,6 +66,7 @@ export class LoginPageComponent {
if (this.form.invalid) {
this.form.markAllAsTouched();
this.toastService.danger('Revisá los datos ingresados para iniciar sesión.');
return;
}
@@ -72,12 +75,13 @@ export class LoginPageComponent {
this.authService.login(this.form.getRawValue()).subscribe({
next: () => {
this.isSubmittingState.set(false);
this.toastService.showAfterReload('Sesión iniciada correctamente.', 'success');
this.redirectToHome();
},
error: (error: unknown) => {
this.isSubmittingState.set(false);
this.serverErrorState.set(this.resolveErrorMessage(error));
}
this.showLoginError(error);
},
});
}
@@ -91,7 +95,7 @@ export class LoginPageComponent {
}
this.authService.loginWithGoogle();
} catch (error: unknown) {
this.serverErrorState.set(this.resolveErrorMessage(error));
this.showLoginError(error);
}
}
@@ -139,10 +143,9 @@ export class LoginPageComponent {
const requestedUrl =
this.route.snapshot.queryParamMap.get('returnUrl') ??
this.document.defaultView?.sessionStorage.getItem(POST_LOGIN_RETURN_URL_KEY);
const destination =
requestedUrl?.startsWith('/') && !requestedUrl.startsWith('//')
? requestedUrl
: this.router.serializeUrl(this.router.createUrlTree(['/']));
const internalDestination =
requestedUrl?.startsWith('/') && !requestedUrl.startsWith('//') ? requestedUrl : '/';
const destination = this.router.serializeUrl(this.router.parseUrl(internalDestination));
this.document.defaultView?.sessionStorage.removeItem(POST_LOGIN_RETURN_URL_KEY);
this.document.location.assign(destination);
@@ -155,15 +158,22 @@ export class LoginPageComponent {
this.authService.completeGoogleLogin(oauthCode).subscribe({
next: () => {
this.isSubmittingState.set(false);
this.toastService.showAfterReload('Sesión iniciada correctamente.', 'success');
this.redirectToHome();
},
error: (error: unknown) => {
this.isSubmittingState.set(false);
this.serverErrorState.set(this.resolveErrorMessage(error));
}
this.showLoginError(error);
},
});
}
private showLoginError(error: unknown): void {
const message = this.resolveErrorMessage(error);
this.serverErrorState.set(message);
this.toastService.danger(message);
}
private resolveErrorMessage(error: unknown): string {
const errorPayload =
typeof error === 'object' && error !== null && 'error' in error

View File

@@ -105,7 +105,7 @@ describe('RegisterPageComponent', () => {
password_confirmation: 'Secret!123'
});
expect(modalService.openSimple).toHaveBeenCalledWith({
content: 'Tu cuenta fue creada correctamente',
title: 'Tu cuenta fue creada correctamente',
buttonLabel: 'Cerrar'
});
expect(navigateSpy).toHaveBeenCalledWith(['/login']);

View File

@@ -93,7 +93,7 @@ export class RegisterPageComponent {
next: () => {
this.isSubmittingState.set(false);
this.modalService.openSimple({
content: 'Tu cuenta fue creada correctamente',
title: 'Tu cuenta fue creada correctamente',
buttonLabel: 'Cerrar'
}).subscribe(() => {
void this.router.navigate(['/login']);

View File

@@ -144,7 +144,7 @@ describe('ResetPasswordPageComponent', () => {
password_confirmation: 'Secret!123',
});
expect(modalService.openSimple).toHaveBeenCalledWith({
content: 'Contraseña modificada correctamente',
title: 'Contraseña modificada correctamente',
buttonLabel: 'Cerrar',
});
expect(navigateSpy).toHaveBeenCalledWith(['/login']);

View File

@@ -155,7 +155,7 @@ export class ResetPasswordPageComponent {
this.modalService
.openSimple({
content: 'Contraseña modificada correctamente',
title: 'Contraseña modificada correctamente',
buttonLabel: 'Cerrar',
})
.subscribe(() => {

View File

@@ -22,7 +22,7 @@
No hay productos disponibles en este momento.
</p>
} @else {
@for (group of catalog(); track group.id) {
@for (group of catalog(); track group.id; let first = $first) {
<app-store-section [attr.id]="group.code" [title]="group.title">
<app-product-list
[layout]="group.layout"
@@ -30,6 +30,7 @@
[items]="group.items"
[loading]="isGroupLoading(group.id)"
[loadImages]="!hasMainCarouselImages() || mainCarouselReady()"
[prioritizeFirstImage]="first && !hasMainCarouselImages()"
[unavailableVariantIds]="unavailableVariantIds()"
[savingProductIds]="savingProductIds()"
(buy)="onBuyProduct($event)"

View File

@@ -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,42 +216,40 @@ 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,
date: '2026-12-05',
time_start: '09:00:00',
time_end: '18:00:00',
info_text: null,
isCanceled: false,
},
{
id: 21,
date: '2026-12-06',
time_start: '09:00:00',
time_end: '18:00:00',
info_text: null,
isCanceled: false,
},
],
};
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],
@@ -293,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 () => {
@@ -361,14 +350,21 @@ 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,
date: '2026-10-09',
time_start: '09:00:00',
time_end: '18:00:00',
info_text: null,
isCanceled: false,
},
],
};

View File

@@ -75,22 +75,23 @@ 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,
start_time: eventDate.time_start,
end_time: eventDate.time_end,
info_text: eventDate.info_text,
isCanceled: eventDate.isCanceled,
})),
contact,
contact: event.social_media,
};
}

View File

@@ -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/:id',
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,

View File

@@ -31,8 +31,9 @@
class="cart-item-variant-selector"
[variants]="variants()"
[selectedVariant]="selectedVariant()"
[autoSelectFirst]="false"
[compact]="true"
(selectedVariantChange)="onVariantChange($event)"
(selectionValuesChange)="onVariantChange($event.selectedVariant)"
/>
} @else {
<div class="d-grid cart-item-attributes">

View File

@@ -3,13 +3,14 @@ import { signal } from '@angular/core';
import { TestBed, getTestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
import { of, throwError } from 'rxjs';
import { of, Subject, throwError } from 'rxjs';
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { CartService } from '../../../core/services/cart/cart.service';
import { ModalService } from '../../../core/services/modal.service';
import { ToastService } from '../../../core/services/toast.service';
import { CartComponent } from './cart.component';
import { VariantSelectorComponent } from '../variant-selector/variant-selector.component';
describe('CartComponent', () => {
beforeAll(() => {
@@ -22,6 +23,7 @@ describe('CartComponent', () => {
afterEach(() => {
TestBed.resetTestingModule();
vi.restoreAllMocks();
});
it('shows an empty cart message when there are no items', async () => {
@@ -115,7 +117,7 @@ describe('CartComponent', () => {
expect(openConfirmDelete).toHaveBeenCalledWith({
title: 'Eliminar producto',
content: 'Se eliminará “Producto de prueba” del carrito. Esta acción no se puede deshacer.',
description: 'Se eliminará “Producto de prueba” del carrito. Esta acción no se puede deshacer.',
confirmLabel: 'Eliminar',
cancelLabel: 'Cancelar',
});
@@ -565,6 +567,57 @@ describe('CartComponent', () => {
expect(quantityChange).not.toHaveBeenCalled();
});
it('does not automatically replace an unavailable variant or retry a rejected selection', async () => {
const response = new Subject<unknown>();
const updateItemVariant = vi.fn().mockReturnValue(response);
const danger = vi.fn();
vi.spyOn(console, 'error').mockImplementation(() => {});
await TestBed.configureTestingModule({
imports: [CartComponent],
providers: [
{ provide: CartService, useValue: { cart: signal(null), updateItemVariant } },
{ provide: ModalService, useValue: {} },
{ provide: ToastService, useValue: { success: vi.fn(), danger } },
],
}).compileComponents();
const fixture = TestBed.createComponent(CartComponent);
const item = {
cartItemId: 10,
imageUrl: null,
product: 'Entrada',
originalPrice: null,
discountedPrice: 1000,
discountPercentage: null,
attributes: [],
quantity: 2,
variantId: 20,
variants: [{ id: 20, values: { fecha: '09/10' } }],
};
fixture.componentRef.setInput('items', [item]);
fixture.detectChanges();
const rescheduledItem = {
...item, variants: [{ id: 21, values: { fecha: '20/10' } }],
};
fixture.componentRef.setInput('items', [rescheduledItem]);
fixture.detectChanges();
expect(updateItemVariant).not.toHaveBeenCalled();
const selector = fixture.debugElement.query(By.directive(VariantSelectorComponent));
(selector.componentInstance as any).onValueChange('fecha', '20/10');
fixture.detectChanges();
expect(updateItemVariant).toHaveBeenCalledExactlyOnceWith(10, 2, 21);
response.error({ status: 422, error: { message: 'Variante no disponible.' } });
fixture.detectChanges();
fixture.componentRef.setInput('items', [{
...rescheduledItem, variants: [...rescheduledItem.variants],
}]);
fixture.detectChanges();
expect(updateItemVariant).toHaveBeenCalledTimes(1);
expect(danger).toHaveBeenCalledOnce();
});
it('persists a variant selected from a cart row', async () => {
const updateItemVariant = vi.fn().mockReturnValue(
of({

View File

@@ -236,7 +236,7 @@ export class CartComponent {
this.modalService
.openConfirmDelete({
title: 'Eliminar producto',
content: `Se eliminará “${target.productName}” del carrito. Esta acción no se puede deshacer.`,
description: `Se eliminará “${target.productName}” del carrito. Esta acción no se puede deshacer.`,
confirmLabel: 'Eliminar',
cancelLabel: 'Cancelar',
})

View File

@@ -1,5 +1,8 @@
<div class="confirm-modal">
<p class="confirm-modal__content">{{ data.content }}</p>
<h2 class="confirm-modal__content">{{ data.title }}</h2>
@if (data.description) {
<p class="confirm-modal__content">{{ data.description }}</p>
}
<div class="confirm-modal__actions">
<app-button variant="danger-secondary" (click)="cancel()">

View File

@@ -15,7 +15,7 @@ import { ConfirmDeleteModalComponent } from './confirm-delete-modal.component';
describe('ConfirmDeleteModalComponent', () => {
const data: ConfirmModalData = {
content: 'Se eliminara el elemento seleccionado.',
title: 'Se eliminara el elemento seleccionado.',
confirmLabel: 'Eliminar',
cancelLabel: 'Cancelar'
};
@@ -58,7 +58,7 @@ describe('ConfirmDeleteModalComponent', () => {
const element = fixture.nativeElement as HTMLElement;
const buttons = element.querySelectorAll('button');
expect(element.textContent).toContain(data.content);
expect(element.textContent).toContain(data.title);
expect(element.textContent).toContain(data.confirmLabel);
expect(buttons[1].className).toContain('btn-danger');
});

View File

@@ -1,5 +1,8 @@
<div class="confirm-modal">
<p class="confirm-modal__content">{{ data.content }}</p>
<h2 class="confirm-modal__content">{{ data.title }}</h2>
@if (data.description) {
<p class="confirm-modal__content">{{ data.description }}</p>
}
<div class="confirm-modal__actions">
<app-button variant="secondary" (click)="cancel()">

View File

@@ -15,7 +15,7 @@ import { ConfirmModalComponent } from './confirm-modal.component';
describe('ConfirmModalComponent', () => {
const data: ConfirmModalData = {
content: 'Se confirmara la operacion seleccionada.',
title: 'Se confirmara la operacion seleccionada.',
confirmLabel: 'Aceptar',
cancelLabel: 'Volver'
};
@@ -57,7 +57,7 @@ describe('ConfirmModalComponent', () => {
const element = fixture.nativeElement as HTMLElement;
expect(element.textContent).toContain(data.content);
expect(element.textContent).toContain(data.title);
expect(element.textContent).toContain(data.confirmLabel);
expect(element.textContent).toContain(data.cancelLabel);
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,15 @@
<div class="event-date-notice-modal">
<h2>{{ notice.title }}</h2>
<p>
@for (segment of notice.message; track $index) {
@if (segment.bold) {
<b>{{ segment.text }}</b>
} @else {
<span>{{ segment.text }}</span>
}
}
</p>
<app-button (click)="close()">Cerrar</app-button>
</div>

View File

@@ -0,0 +1,28 @@
.event-date-notice-modal {
display: grid;
gap: 1.5rem;
justify-items: stretch;
text-align: center;
}
h2,
p {
margin: 0;
}
h2 {
color: var(--tenant-danger);
font-size: 16px;
font-weight: 700;
line-height: 1.25;
}
p {
color: #666666;
font-size: 15px;
line-height: 1.35;
}
b {
font-weight: 700;
}

View File

@@ -0,0 +1,58 @@
import '@angular/compiler';
import { TestBed, getTestBed } from '@angular/core/testing';
import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { EventDateNotice } from '../../../core/services/event-date-notice.service';
import { MODAL_DATA, ModalRef } from '../../../core/services/modal.service';
import { EventDateNoticeModalComponent } from './event-date-notice-modal.component';
describe('EventDateNoticeModalComponent', () => {
beforeAll(() => {
try {
getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
} catch {
// Test environment may already be initialized by another setup entrypoint.
}
});
afterEach(() => {
TestBed.resetTestingModule();
});
it('renders the precalculated message and emphasizes its marked parts', async () => {
const close = vi.fn();
const data: EventDateNotice = {
type: 'rescheduled',
change_ids: [1, 2],
title: 'FECHAS REPROGRAMADAS!',
message: [
{ text: 'Las fechas del ', bold: false },
{ text: '09 y 10 de Octubre de 2026', bold: true },
{ text: ' han sido reprogramadas, ', bold: false },
{ text: 'respectivamente', bold: true },
{ text: '.', bold: false },
],
};
await TestBed.configureTestingModule({
imports: [EventDateNoticeModalComponent],
providers: [
{ provide: MODAL_DATA, useValue: data },
{ provide: ModalRef, useValue: { close } },
],
}).compileComponents();
const fixture = TestBed.createComponent(EventDateNoticeModalComponent);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('h2')?.textContent).toContain(data.title);
expect(
[...element.querySelectorAll('b')].map((strong) => strong.textContent?.trim()),
).toEqual(['09 y 10 de Octubre de 2026', 'respectivamente']);
element.querySelector('button')?.click();
expect(close).toHaveBeenCalledOnce();
});
});

View File

@@ -0,0 +1,21 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { EventDateNotice } from '../../../core/services/event-date-notice.service';
import { MODAL_DATA, ModalRef } from '../../../core/services/modal.service';
import { ButtonComponent } from '../button/button.component';
@Component({
selector: 'app-event-date-notice-modal',
imports: [ButtonComponent],
templateUrl: './event-date-notice-modal.component.html',
styleUrl: './event-date-notice-modal.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EventDateNoticeModalComponent {
protected readonly notice = inject<EventDateNotice>(MODAL_DATA);
private readonly modalRef = inject<ModalRef<void>>(ModalRef);
protected close(): void {
this.modalRef.close();
}
}

View File

@@ -1,5 +1,8 @@
<div class="hero-banner-container">
<div class="hero-banner position-relative rounded">
<div
class="hero-banner position-relative rounded"
[class.hero-banner--with-media]="desktopImageUrl"
>
@if (desktopImageUrl) {
<picture class="hero-media" aria-hidden="true">
@if (mobileImageUrl) {
@@ -85,7 +88,12 @@
<td>
<span class="event-schedule-value">
<i class="fa-regular fa-calendar" aria-hidden="true"></i>
<span>{{ formatScheduleDate(eventDate.date) }}</span>
<span [class.event-schedule-date--canceled]="eventDate.isCanceled">
{{ formatScheduleDate(eventDate.date) }}
</span>
@if (eventDate.info_text) {
<app-tooltip [message]="eventDate.info_text" />
}
</span>
</td>
<td>

View File

@@ -7,23 +7,23 @@
min-height: 400px;
display: flex;
flex-direction: column;
&--with-media {
min-height: 0;
}
}
.hero-media {
position: absolute;
inset: 0;
position: relative;
display: block;
width: 100%;
overflow: hidden;
background-position: center;
background-repeat: no-repeat;
background-size: cover;
border-radius: inherit;
img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
height: auto;
}
&::after {
@@ -52,6 +52,11 @@
flex-grow: 1;
}
.hero-banner--with-media .hero-content {
position: absolute;
inset: 0;
}
::ng-deep .hero-title,
.hero-title {
color: #666666;
@@ -159,6 +164,10 @@
}
}
.event-schedule-date--canceled {
text-decoration: line-through;
}
@media (max-width: 767.98px) {
.hero-banner-container {
padding-bottom: 0;
@@ -179,24 +188,29 @@
inset: auto;
flex: 0 0 auto;
width: 100%;
height: clamp(8.75rem, 44vw, 211px);
background-position: center center;
background-size: 140% auto;
overflow: visible;
border-radius: 0;
}
.hero-media::after {
top: auto;
height: 48%;
bottom: -2px;
height: calc(48% + 2px);
background: linear-gradient(
to bottom,
rgba(245, 245, 245, 0) 0%,
rgba(245, 245, 245, 0.78) 55%,
#f5f5f5 96%,
#f5f5f5 100%
);
border-radius: 0;
}
.hero-banner--with-media .hero-content {
position: relative;
inset: auto;
}
.hero-content {
justify-content: center !important;
height: auto !important;

View File

@@ -22,6 +22,19 @@ describe('HeroBannerComponent', () => {
expect(element.querySelector('img')?.getAttribute('src')).toBe(
'https://example.com/desktop.jpg',
);
expect(element.querySelector('.hero-banner')?.classList).toContain('hero-banner--with-media');
});
it('keeps the fallback banner sizing when there is no image', async () => {
await TestBed.configureTestingModule({ imports: [HeroBannerComponent] }).compileComponents();
const fixture = TestBed.createComponent(HeroBannerComponent);
fixture.componentRef.setInput('heroConfig', { title_html: 'Banner sin imagen' });
fixture.detectChanges();
const banner = (fixture.nativeElement as HTMLElement).querySelector('.hero-banner');
expect(banner?.classList).not.toContain('hero-banner--with-media');
});
it('expands and collapses the event schedules', async () => {
@@ -37,12 +50,16 @@ describe('HeroBannerComponent', () => {
date: '2026-10-09',
start_time: '10:00:00',
end_time: '20:30:00',
info_text: 'Esta fecha fue cancelada.',
isCanceled: true,
},
{
id: 2,
date: '2026-10-10',
start_time: '10:00:00',
end_time: '22:00:00',
info_text: null,
isCanceled: false,
},
],
});
@@ -63,6 +80,10 @@ describe('HeroBannerComponent', () => {
expect(element.querySelectorAll('.event-schedules tbody tr')).toHaveLength(2);
expect(element.querySelector('.event-schedules')?.textContent).toContain('9 de Octubre 2026');
expect(element.querySelector('.event-schedules')?.textContent).toContain('10:00 - 20:30');
expect(element.querySelector('.event-schedule-date--canceled')).not.toBeNull();
expect(element.querySelector('app-tooltip')?.textContent).toContain(
'Esta fecha fue cancelada.',
);
toggle?.click();
fixture.detectChanges();

View File

@@ -3,11 +3,12 @@ import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { EventConfig, HeroConfig } from '../../../core/services/tenant.interface';
import { ButtonComponent } from '../button/button.component';
import { TooltipComponent } from '../tooltip/tooltip.component';
@Component({
selector: 'app-hero-banner',
standalone: true,
imports: [CommonModule, RouterModule, ButtonComponent],
imports: [CommonModule, RouterModule, ButtonComponent, TooltipComponent],
templateUrl: './hero-banner.component.html',
styleUrl: './hero-banner.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,

View File

@@ -0,0 +1,58 @@
<div
class="image-modal"
[class.image-modal--gesturing]="gestureActive()"
[class.image-modal--settling]="swipeSettling()"
[style.transform]="swipeTransform()"
>
<div
#viewport
class="image-modal__viewport"
[class.image-modal__viewport--zoomed]="zoom() > data.minZoom"
(wheel)="onWheel($event)"
(dblclick)="onDoubleClick($event)"
(pointerdown)="onPointerDown($event)"
(pointermove)="onPointerMove($event)"
(pointerup)="onPointerUp($event)"
(pointercancel)="onPointerUp($event)"
>
@if (!imageFailed()) {
<img
#image
class="image-modal__image"
[src]="data.src"
[alt]="data.alt"
[style.transform]="transform()"
decoding="async"
draggable="false"
(load)="onImageLoad()"
(error)="imageFailed.set(true)"
/>
} @else {
<p class="image-modal__error" role="alert">No se pudo cargar la imagen.</p>
}
</div>
<div class="image-modal__controls" aria-label="Controles de zoom">
<button
type="button"
class="image-modal__control"
aria-label="Alejar"
[disabled]="zoom() <= data.minZoom"
(click)="zoomOut()"
>
&minus;
</button>
<button type="button" class="image-modal__zoom" aria-label="Restablecer zoom" (click)="reset()">
{{ zoomLabel() }}
</button>
<button
type="button"
class="image-modal__control"
aria-label="Acercar"
[disabled]="zoom() >= data.maxZoom"
(click)="zoomIn()"
>
&plus;
</button>
</div>
</div>

View File

@@ -0,0 +1,146 @@
:host {
display: flex;
min-height: 0;
flex: 1;
}
.image-modal {
position: relative;
display: flex;
min-width: 0;
min-height: 0;
flex: 1;
flex-direction: column;
overflow: hidden;
background: black;
transform-origin: center top;
}
.image-modal--settling {
transition: transform 180ms ease-out;
}
.image-modal--gesturing {
will-change: transform;
}
.image-modal__viewport {
display: flex;
min-width: 0;
min-height: 0;
flex: 1;
align-items: center;
justify-content: center;
overflow: hidden;
cursor: zoom-in;
touch-action: none;
user-select: none;
}
.image-modal__viewport--zoomed {
cursor: grab;
}
.image-modal__viewport--zoomed:active {
cursor: grabbing;
}
.image-modal__image {
display: block;
max-width: 100%;
max-height: 100%;
object-fit: contain;
transform-origin: center;
transition: transform 120ms ease-out;
-webkit-user-drag: none;
}
.image-modal--gesturing .image-modal__image {
transition: none;
will-change: transform;
}
.image-modal__viewport:active .image-modal__image {
transition: none;
}
.image-modal__error {
margin: 1rem;
color: #ffffff;
text-align: center;
}
.image-modal__controls {
position: absolute;
right: 50%;
bottom: max(1.25rem, env(safe-area-inset-bottom));
z-index: 1;
display: flex;
align-items: center;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 2rem;
background: rgba(20, 20, 20, 0.78);
box-shadow: 0 0.25rem 1rem rgba(0, 0, 0, 0.3);
transform: translateX(50%);
backdrop-filter: blur(0.5rem);
}
.image-modal__control,
.image-modal__zoom {
display: inline-flex;
height: 2.75rem;
align-items: center;
justify-content: center;
border: 0;
color: #ffffff;
background: transparent;
}
.image-modal__control {
width: 2.75rem;
font-size: 1.5rem;
}
.image-modal__zoom {
min-width: 4rem;
padding: 0 0.5rem;
font-size: 0.875rem;
font-variant-numeric: tabular-nums;
}
.image-modal__control:hover:not(:disabled),
.image-modal__zoom:hover {
background: rgba(255, 255, 255, 0.12);
}
.image-modal__control:focus-visible,
.image-modal__zoom:focus-visible {
outline: 2px solid #ffffff;
outline-offset: -3px;
}
.image-modal__control:disabled {
opacity: 0.35;
}
@media (max-width: 576px) {
.image-modal__controls {
bottom: max(1rem, env(safe-area-inset-bottom));
background: rgba(20, 20, 20, 0.94);
backdrop-filter: none;
}
}
@media (pointer: coarse) {
.image-modal__controls {
background: rgba(20, 20, 20, 0.94);
backdrop-filter: none;
}
}
@media (prefers-reduced-motion: reduce) {
.image-modal__image {
transition: none;
}
}

View File

@@ -0,0 +1,165 @@
import '@angular/compiler';
import { ComponentFixture, TestBed, getTestBed } from '@angular/core/testing';
import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { ImageModalData, MODAL_DATA, ModalRef } from '../../../core/services/modal.service';
import { ImageModalComponent } from './image-modal.component';
describe('ImageModalComponent', () => {
let fixture: ComponentFixture<ImageModalComponent>;
const modalRef = { dismiss: vi.fn() };
const data: ImageModalData = {
src: '/images/producto.webp',
alt: 'Vista frontal del producto',
initialZoom: 1,
minZoom: 1,
maxZoom: 2,
};
beforeAll(() => {
try {
getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
} catch {
// Test environment may already be initialized by another setup entrypoint.
}
});
beforeEach(async () => {
modalRef.dismiss.mockReset();
await TestBed.configureTestingModule({
imports: [ImageModalComponent],
providers: [
{ provide: MODAL_DATA, useValue: data },
{ provide: ModalRef, useValue: modalRef },
],
}).compileComponents();
fixture = TestBed.createComponent(ImageModalComponent);
fixture.detectChanges();
});
afterEach(() => {
fixture.destroy();
TestBed.resetTestingModule();
});
it('renders the image and accessible zoom controls', () => {
const element = fixture.nativeElement as HTMLElement;
const image = element.querySelector('img') as HTMLImageElement;
expect(image.getAttribute('src')).toBe('/images/producto.webp');
expect(image.alt).toBe('Vista frontal del producto');
expect(element.querySelector('[aria-label="Acercar"]')).not.toBeNull();
expect(element.querySelector('[aria-label="Alejar"]')).not.toBeNull();
});
it('zooms with controls, respects limits, and resets', () => {
const element = fixture.nativeElement as HTMLElement;
const zoomIn = element.querySelector('[aria-label="Acercar"]') as HTMLButtonElement;
const zoomOut = element.querySelector('[aria-label="Alejar"]') as HTMLButtonElement;
const reset = element.querySelector('[aria-label="Restablecer zoom"]') as HTMLButtonElement;
zoomIn.click();
fixture.detectChanges();
expect(reset.textContent).toContain('150%');
expect(zoomOut.disabled).toBe(false);
zoomIn.click();
fixture.detectChanges();
expect(reset.textContent).toContain('200%');
expect(zoomIn.disabled).toBe(true);
reset.click();
fixture.detectChanges();
expect(reset.textContent).toContain('100%');
expect(zoomOut.disabled).toBe(true);
});
it('supports pinch zoom through touch pointer events', async () => {
const viewport = fixture.nativeElement.querySelector('.image-modal__viewport') as HTMLElement;
const zoom = fixture.nativeElement.querySelector(
'[aria-label="Restablecer zoom"]',
) as HTMLButtonElement;
viewport.dispatchEvent(pointerEvent('pointerdown', 1, 100, 100));
viewport.dispatchEvent(pointerEvent('pointerdown', 2, 200, 100));
viewport.dispatchEvent(pointerEvent('pointermove', 2, 250, 100));
await renderNextFrame();
fixture.detectChanges();
expect(zoom.textContent).toContain('150%');
});
it('dismisses with a downward swipe while the image is at its base zoom', async () => {
const viewport = fixture.nativeElement.querySelector('.image-modal__viewport') as HTMLElement;
const modal = fixture.nativeElement.querySelector('.image-modal') as HTMLElement;
viewport.dispatchEvent(pointerEvent('pointerdown', 1, 150, 100));
viewport.dispatchEvent(pointerEvent('pointermove', 1, 155, 230));
await renderNextFrame();
fixture.detectChanges();
expect(modal.style.transform).toBe('translate3d(0, 130px, 0)');
expect(Number(modal.style.opacity)).toBeLessThan(1);
viewport.dispatchEvent(pointerEvent('pointerup', 1, 155, 230));
expect(modalRef.dismiss).toHaveBeenCalledWith('swipe');
});
it('returns smoothly to its position when the swipe is too short', async () => {
const viewport = fixture.nativeElement.querySelector('.image-modal__viewport') as HTMLElement;
const modal = fixture.nativeElement.querySelector('.image-modal') as HTMLElement;
viewport.dispatchEvent(pointerEvent('pointerdown', 1, 150, 100));
viewport.dispatchEvent(pointerEvent('pointermove', 1, 150, 160));
await renderNextFrame();
fixture.detectChanges();
expect(modal.style.transform).toBe('translate3d(0, 60px, 0)');
viewport.dispatchEvent(pointerEvent('pointerup', 1, 150, 160));
fixture.detectChanges();
expect(modalRef.dismiss).not.toHaveBeenCalled();
expect(modal.classList.contains('image-modal--settling')).toBe(true);
expect(modal.style.transform).toBe('translate3d(0, 0px, 0)');
});
it('does not dismiss with a downward gesture while the image is zoomed', () => {
const element = fixture.nativeElement as HTMLElement;
const viewport = element.querySelector('.image-modal__viewport') as HTMLElement;
const zoomIn = element.querySelector('[aria-label="Acercar"]') as HTMLButtonElement;
zoomIn.click();
fixture.detectChanges();
viewport.dispatchEvent(pointerEvent('pointerdown', 1, 150, 100));
viewport.dispatchEvent(pointerEvent('pointermove', 1, 155, 230));
viewport.dispatchEvent(pointerEvent('pointerup', 1, 155, 230));
expect(modalRef.dismiss).not.toHaveBeenCalled();
});
it('shows a fallback when the image cannot be loaded', () => {
const image = fixture.nativeElement.querySelector('img') as HTMLImageElement;
image.dispatchEvent(new Event('error'));
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('[role="alert"]')?.textContent).toContain(
'No se pudo cargar la imagen',
);
});
});
function pointerEvent(type: string, pointerId: number, clientX: number, clientY: number): Event {
const event = new MouseEvent(type, { bubbles: true, clientX, clientY });
Object.defineProperties(event, {
pointerId: { value: pointerId },
pointerType: { value: 'touch' },
});
return event;
}
async function renderNextFrame(): Promise<void> {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
}

View File

@@ -0,0 +1,395 @@
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
ElementRef,
computed,
inject,
signal,
viewChild,
} from '@angular/core';
import { ImageModalData, MODAL_DATA, ModalRef } from '../../../core/services/modal.service';
const SWIPE_DISMISS_DISTANCE = 100;
const SWIPE_DIRECTION_RATIO = 1.25;
interface Point {
x: number;
y: number;
}
interface ViewerGeometry {
imageHeight: number;
imageWidth: number;
viewportHeight: number;
viewportLeft: number;
viewportTop: number;
viewportWidth: number;
}
@Component({
selector: 'app-image-modal',
templateUrl: './image-modal.component.html',
styleUrl: './image-modal.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ImageModalComponent {
protected readonly data = inject<ImageModalData>(MODAL_DATA);
private readonly modalRef = inject<ModalRef<void>>(ModalRef);
private readonly destroyRef = inject(DestroyRef);
private readonly viewport = viewChild.required<ElementRef<HTMLElement>>('viewport');
private readonly image = viewChild<ElementRef<HTMLImageElement>>('image');
private readonly pointers = new Map<number, Point>();
protected readonly zoom = signal(this.data.initialZoom);
protected readonly offsetX = signal(0);
protected readonly offsetY = signal(0);
protected readonly imageFailed = signal(false);
protected readonly swipeOffsetY = signal(0);
protected readonly swipeSettling = signal(false);
protected readonly gestureActive = signal(false);
protected readonly transform = computed(
() => `translate3d(${this.offsetX()}px, ${this.offsetY()}px, 0) scale(${this.zoom()})`,
);
protected readonly zoomLabel = computed(() => `${Math.round(this.zoom() * 100)}%`);
protected readonly swipeTransform = computed(() => `translate3d(0, ${this.swipeOffsetY()}px, 0)`);
private dragStart: Point | null = null;
private dragOffset: Point = { x: 0, y: 0 };
private pinchDistance = 0;
private pinchZoom = 1;
private pinchLocal: Point = { x: 0, y: 0 };
private pointerDownAt: Point | null = null;
private gestureMoved = false;
private hadMultiplePointers = false;
private lastTapAt = 0;
private swipeStart: Point | null = null;
private geometry: ViewerGeometry | null = null;
private animationFrameId: number | null = null;
constructor() {
this.destroyRef.onDestroy(() => this.cancelGestureFrame());
}
protected zoomIn(): void {
this.setZoomAt(Math.min(this.data.maxZoom, this.zoom() + 0.5));
}
protected zoomOut(): void {
this.setZoomAt(Math.max(this.data.minZoom, this.zoom() - 0.5));
}
protected reset(): void {
this.zoom.set(this.data.initialZoom);
this.offsetX.set(0);
this.offsetY.set(0);
}
protected onWheel(event: WheelEvent): void {
event.preventDefault();
const factor = event.deltaY < 0 ? 1.15 : 1 / 1.15;
this.setZoomAt(this.zoom() * factor, event.clientX, event.clientY);
}
protected onDoubleClick(event: MouseEvent): void {
this.toggleZoom(event.clientX, event.clientY);
}
protected onPointerDown(event: PointerEvent): void {
if (event.pointerType === 'mouse' && event.button !== 0) {
return;
}
event.preventDefault();
if (this.pointers.size === 0) {
this.refreshGeometry();
this.gestureActive.set(true);
}
this.viewport().nativeElement.setPointerCapture?.(event.pointerId);
const point = { x: event.clientX, y: event.clientY };
this.pointers.set(event.pointerId, point);
this.pointerDownAt = point;
this.gestureMoved = false;
if (this.pointers.size === 1) {
this.swipeSettling.set(false);
this.dragStart = point;
this.dragOffset = { x: this.offsetX(), y: this.offsetY() };
this.swipeStart =
event.pointerType === 'touch' && this.zoom() <= this.data.minZoom + 0.01 ? point : null;
} else if (this.pointers.size === 2) {
this.hadMultiplePointers = true;
this.swipeStart = null;
this.swipeOffsetY.set(0);
this.beginPinch();
}
}
protected onPointerMove(event: PointerEvent): void {
if (!this.pointers.has(event.pointerId)) {
return;
}
const point = { x: event.clientX, y: event.clientY };
this.pointers.set(event.pointerId, point);
if (this.pointerDownAt && this.distance(this.pointerDownAt, point) > 4) {
this.gestureMoved = true;
}
this.scheduleGestureFrame();
}
protected onPointerUp(event: PointerEvent): void {
const wasTouch = event.pointerType === 'touch';
const trackedPoint = this.pointers.get(event.pointerId);
const endPoint = trackedPoint ? { x: event.clientX, y: event.clientY } : null;
if (endPoint) {
this.pointers.set(event.pointerId, endPoint);
if (this.animationFrameId !== null) {
this.flushGestureFrame();
} else {
this.applyPointerMovement();
}
}
const shouldDismiss =
event.type === 'pointerup' &&
wasTouch &&
endPoint !== null &&
this.isSwipeDown(this.swipeStart, endPoint);
this.pointers.delete(event.pointerId);
const viewport = this.viewport().nativeElement;
if (viewport.hasPointerCapture?.(event.pointerId)) {
viewport.releasePointerCapture(event.pointerId);
}
if (shouldDismiss) {
this.resetGesture();
this.modalRef.dismiss('swipe');
return;
}
if (wasTouch && endPoint && !this.gestureMoved && !this.hadMultiplePointers) {
const now = Date.now();
if (now - this.lastTapAt < 300) {
this.toggleZoom(endPoint.x, endPoint.y);
this.lastTapAt = 0;
} else {
this.lastTapAt = now;
}
}
if (this.pointers.size === 1) {
const remaining = [...this.pointers.values()][0];
this.dragStart = remaining;
this.dragOffset = { x: this.offsetX(), y: this.offsetY() };
} else if (this.pointers.size === 0) {
this.dragStart = null;
this.pointerDownAt = null;
this.hadMultiplePointers = false;
this.swipeStart = null;
this.gestureActive.set(false);
if (this.swipeOffsetY() > 0) {
this.swipeSettling.set(true);
this.swipeOffsetY.set(0);
}
this.clampOffset();
}
}
protected onImageLoad(): void {
this.imageFailed.set(false);
this.refreshGeometry();
this.clampOffset();
}
private beginPinch(): void {
const [first, second] = [...this.pointers.values()];
const midpoint = this.midpoint(first, second);
const geometry = this.geometry ?? this.refreshGeometry();
if (!geometry) {
return;
}
this.pinchDistance = Math.max(1, this.distance(first, second));
this.pinchZoom = this.zoom();
this.pinchLocal = {
x:
(midpoint.x - (geometry.viewportLeft + geometry.viewportWidth / 2) - this.offsetX()) /
this.zoom(),
y:
(midpoint.y - (geometry.viewportTop + geometry.viewportHeight / 2) - this.offsetY()) /
this.zoom(),
};
}
private toggleZoom(clientX?: number, clientY?: number): void {
const target =
this.zoom() > this.data.minZoom + 0.01
? this.data.minZoom
: Math.min(this.data.maxZoom, Math.max(2, this.data.minZoom));
this.setZoomAt(target, clientX, clientY);
}
private setZoomAt(value: number, clientX?: number, clientY?: number): void {
const nextZoom = this.clampZoom(value);
const currentZoom = this.zoom();
const geometry = this.refreshGeometry();
if (clientX !== undefined && clientY !== undefined && currentZoom > 0 && geometry) {
const pointX = clientX - (geometry.viewportLeft + geometry.viewportWidth / 2);
const pointY = clientY - (geometry.viewportTop + geometry.viewportHeight / 2);
const localX = (pointX - this.offsetX()) / currentZoom;
const localY = (pointY - this.offsetY()) / currentZoom;
this.offsetX.set(pointX - localX * nextZoom);
this.offsetY.set(pointY - localY * nextZoom);
}
this.zoom.set(nextZoom);
if (nextZoom <= this.data.minZoom) {
this.offsetX.set(0);
this.offsetY.set(0);
}
this.clampOffset();
}
private clampZoom(value: number): number {
return Math.min(this.data.maxZoom, Math.max(this.data.minZoom, value));
}
private clampOffset(): void {
const geometry = this.geometry;
if (!geometry) {
return;
}
const maxX = Math.max(0, (geometry.imageWidth * this.zoom() - geometry.viewportWidth) / 2);
const maxY = Math.max(0, (geometry.imageHeight * this.zoom() - geometry.viewportHeight) / 2);
this.offsetX.set(Math.min(maxX, Math.max(-maxX, this.offsetX())));
this.offsetY.set(Math.min(maxY, Math.max(-maxY, this.offsetY())));
}
private midpoint(first: Point, second: Point): Point {
return { x: (first.x + second.x) / 2, y: (first.y + second.y) / 2 };
}
private distance(first: Point, second: Point): number {
return Math.hypot(second.x - first.x, second.y - first.y);
}
private isSwipeDown(start: Point | null, end: Point): boolean {
if (!start || this.hadMultiplePointers || this.zoom() > this.data.minZoom + 0.01) {
return false;
}
const deltaX = Math.abs(end.x - start.x);
const deltaY = end.y - start.y;
return deltaY >= SWIPE_DISMISS_DISTANCE && deltaY >= deltaX * SWIPE_DIRECTION_RATIO;
}
private resetGesture(): void {
this.cancelGestureFrame();
this.pointers.clear();
this.dragStart = null;
this.pointerDownAt = null;
this.swipeStart = null;
this.hadMultiplePointers = false;
this.gestureActive.set(false);
}
private scheduleGestureFrame(): void {
if (this.animationFrameId !== null) {
return;
}
this.animationFrameId = requestAnimationFrame(() => {
this.animationFrameId = null;
this.applyPointerMovement();
});
}
private flushGestureFrame(): void {
if (this.animationFrameId === null) {
return;
}
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
this.applyPointerMovement();
}
private cancelGestureFrame(): void {
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
}
private applyPointerMovement(): void {
if (this.pointers.size === 2) {
const [first, second] = [...this.pointers.values()];
const geometry = this.geometry;
if (!geometry) {
return;
}
const distance = this.distance(first, second);
const midpoint = this.midpoint(first, second);
const nextZoom = this.clampZoom(this.pinchZoom * (distance / this.pinchDistance));
this.zoom.set(nextZoom);
this.offsetX.set(
midpoint.x -
(geometry.viewportLeft + geometry.viewportWidth / 2) -
this.pinchLocal.x * nextZoom,
);
this.offsetY.set(
midpoint.y -
(geometry.viewportTop + geometry.viewportHeight / 2) -
this.pinchLocal.y * nextZoom,
);
this.clampOffset();
return;
}
if (this.pointers.size !== 1) {
return;
}
const point = [...this.pointers.values()][0];
if (this.swipeStart) {
const deltaX = Math.abs(point.x - this.swipeStart.x);
const deltaY = point.y - this.swipeStart.y;
this.swipeOffsetY.set(deltaY > 0 && deltaY >= deltaX ? deltaY : 0);
return;
}
if (this.dragStart && this.zoom() > this.data.minZoom) {
this.offsetX.set(this.dragOffset.x + point.x - this.dragStart.x);
this.offsetY.set(this.dragOffset.y + point.y - this.dragStart.y);
this.clampOffset();
}
}
private refreshGeometry(): ViewerGeometry | null {
const viewport = this.viewport().nativeElement;
const image = this.image()?.nativeElement;
if (!image) {
this.geometry = null;
return null;
}
const rect = viewport.getBoundingClientRect();
this.geometry = {
imageHeight: image.offsetHeight,
imageWidth: image.offsetWidth,
viewportHeight: viewport.clientHeight,
viewportLeft: rect.left,
viewportTop: rect.top,
viewportWidth: viewport.clientWidth,
};
return this.geometry;
}
}

Some files were not shown because too many files have changed in this diff Show More