21 Commits

Author SHA1 Message Date
c6ac974459 feat: adjust footer and cart dropdown styles for improved layout and responsiveness 2026-08-06 12:13:51 -03:00
50f986dd2f feat: refactor menu content section for improved structure and responsiveness across account and help pages 2026-08-06 09:22:24 -03:00
393aeec7a5 feat: add menu content section component for improved layout and responsiveness across help and FAQ pages 2026-08-06 09:15:34 -03:00
a012f94198 feat: implement menu page layout component and remove old sidebar components for improved structure and responsiveness 2026-08-06 09:06:19 -03:00
6b9087262a feat: improve responsiveness of checkout payment panel and help sidebar components 2026-08-06 08:53:23 -03:00
4b98df4058 feat: enhance checkout page layout for better responsiveness and adjust cart actions for mobile 2026-08-06 08:49:38 -03:00
2b3e054ccf feat: enhance footer layout for improved responsiveness and structure 2026-08-05 17:07:04 -03:00
1b238cb1bc feat: center align summary content and adjust variant selectors layout 2026-08-05 16:52:10 -03:00
c9f9e6cc3e feat: enhance product vertical card with variant selection and improve store header responsiveness 2026-08-05 16:48:34 -03:00
5da341dde2 feat: improve hero banner responsiveness and enhance event card layout 2026-08-05 16:16:46 -03:00
21786d7b34 feat: enhance store header layout for better responsiveness and add mobile menu functionality 2026-08-05 12:18:50 -03:00
8aea54959f fix: enforce padding and background styles for form control in recover password page 2026-08-05 11:09:30 -03:00
12ca45bdd8 feat: add storeName input to footer component and update copyright display 2026-08-05 10:49:33 -03:00
fa6fc53f8d fix: correct language attribute from 'e' to 'es' in HTML tag 2026-08-05 09:06:51 -03:00
8c2f00bcbc refactor: remove loading state management from category and search pages 2026-07-29 12:06:29 -03:00
da61067c6a feat: introduce loading context management across services
- Refactor AuthService, CartService, CatalogService, CheckoutService, TenantService, and TicketService to extend BaseApiService for consistent loading context handling.
- Implement loading modes ('global', 'custom', 'none') using HttpContext for API requests.
- Update existing service methods to accept loading mode parameters and propagate them to HTTP requests.
- Enhance global loading interceptor to respect loading modes and prevent global loading for specific requests.
- Add unit tests to verify loading mode propagation in services and interceptors.
- Update component interactions to utilize the new loading mode functionality.
2026-07-29 11:54:31 -03:00
125d9137fb feat: implement global loading state with interceptor and component 2026-07-29 11:03:35 -03:00
1fb9789abf fix: correct language attribute from 'en' to 'e' in HTML tag 2026-07-29 11:02:59 -03:00
7ebf383c89 refactor: change password input type to password-toggle and update icon class 2026-07-29 10:25:33 -03:00
c14f740dbb Merge branch 'refactor/purchase_screen' into develop 2026-07-29 09:37:40 -03:00
3ab73a395a Squashed commit of the following:
commit 9bb3374ba0
Author: ncoronel <ncoronel@quo.ar>
Date:   Wed Jul 29 09:20:17 2026 -0300

    feat: add submitPurchaseForReview method and update checkout flow to handle purchase review status

commit 76327d34ec
Author: ncoronel <ncoronel@quo.ar>
Date:   Wed Jul 29 08:33:54 2026 -0300

    refactor: update styles for category items page header and title

commit 4cb81ed70d
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Jul 28 17:02:12 2026 -0300

    feat: implement payment verification error component and update checkout flow

commit c233845ce0
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Jul 28 16:57:00 2026 -0300

    feat: add WhatsApp support and improve payment timeout handling in checkout process

commit 15a74bb56e
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Jul 28 16:26:45 2026 -0300

    feat: enhance purchase status page with ticket handling and WhatsApp integration

commit 58e9dc06b1
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Jul 28 16:03:17 2026 -0300

    refactor: simplify purchase status page by removing polling logic and updating messages
2026-07-29 09:30:24 -03:00
111 changed files with 2204 additions and 808 deletions

View File

@@ -10,6 +10,7 @@ import { provideClientHydration, withHttpTransferCacheOptions } from '@angular/p
import { routes } from './app.routes'; import { routes } from './app.routes';
import { authBootstrap } from './core/services/auth/auth-bootstrap'; import { authBootstrap } from './core/services/auth/auth-bootstrap';
import { authInterceptor } from './core/services/auth/auth.interceptor'; import { authInterceptor } from './core/services/auth/auth.interceptor';
import { globalLoadingInterceptor } from './core/services/global-loading/global-loading.interceptor';
import { tenantBootstrap } from './core/services/tenant-bootstrap'; import { tenantBootstrap } from './core/services/tenant-bootstrap';
function isStoreCatalogRequest(request: HttpRequest<unknown>): boolean { function isStoreCatalogRequest(request: HttpRequest<unknown>): boolean {
@@ -29,7 +30,10 @@ export const appConfig: ApplicationConfig = {
filter: isStoreCatalogRequest, filter: isStoreCatalogRequest,
}), }),
), ),
provideHttpClient(withFetch(), withInterceptors([authInterceptor])), provideHttpClient(
withFetch(),
withInterceptors([authInterceptor, globalLoadingInterceptor])
),
provideAppInitializer(authBootstrap), provideAppInitializer(authBootstrap),
provideAppInitializer(tenantBootstrap), provideAppInitializer(tenantBootstrap),
], ],

View File

@@ -2,6 +2,7 @@
<router-outlet /> <router-outlet />
<app-toast-container /> <app-toast-container />
<app-modal-host /> <app-modal-host />
<app-global-loading />
} @else if (status() === 'not-found') { } @else if (status() === 'not-found') {
<section class="tenant-status"> <section class="tenant-status">
<div class="tenant-status__card"> <div class="tenant-status__card">

View File

@@ -3,6 +3,7 @@ import { RouterOutlet } from '@angular/router';
import { TenantService } from './core/services/tenant.service'; import { TenantService } from './core/services/tenant.service';
import { DEFAULT_TENANT_BRANDING } from './core/services/tenant-ssr-cache.store'; import { DEFAULT_TENANT_BRANDING } from './core/services/tenant-ssr-cache.store';
import { GlobalLoadingComponent } from './shared/components/global-loading/global-loading.component';
import { ModalHostComponent } from './shared/components/modal-host/modal-host.component'; import { ModalHostComponent } from './shared/components/modal-host/modal-host.component';
import { ToastContainerComponent } from './shared/components/toast-container/toast-container.component'; import { ToastContainerComponent } from './shared/components/toast-container/toast-container.component';
@@ -23,7 +24,12 @@ function hexToRgb(hex: string): string {
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
imports: [RouterOutlet, ToastContainerComponent, ModalHostComponent], imports: [
RouterOutlet,
ToastContainerComponent,
ModalHostComponent,
GlobalLoadingComponent
],
templateUrl: './app.html', templateUrl: './app.html',
styleUrl: './app.scss', styleUrl: './app.scss',
host: { host: {
@@ -88,4 +94,4 @@ export class App {
this.tenantService.tenant()?.footer_bg_color ?? this.tenantService.tenant()?.footer_bg_color ??
DEFAULT_TENANT_BRANDING.footerBgColor DEFAULT_TENANT_BRANDING.footerBgColor
); );
} }

View File

@@ -1,7 +1,7 @@
<footer class="store-layout__footer mt-auto text-light"> <footer class="store-layout__footer mt-auto text-light">
<div class="container-xl px-3 px-md-4 py-4 py-md-5"> <div class="container-xl px-3 px-md-4 py-4 py-md-5 store-layout__footer-content">
<div class="row g-4"> <div class="row g-4 store-layout__footer-grid">
<div class="col-12"> <div class="col-12 store-layout__brand-column">
<div <div
class="store-layout__brand-slot d-inline-flex align-items-center gap-3" class="store-layout__brand-slot d-inline-flex align-items-center gap-3"
data-testid="store-footer-brand-slot" data-testid="store-footer-brand-slot"
@@ -17,30 +17,29 @@
</div> </div>
</div> </div>
<div class="col-12 col-md-6 col-xl-5 d-grid gap-3 align-content-start"> <div class="col-12 col-md-6 col-xl-5 d-grid gap-3 align-content-start store-layout__contact">
<div class="d-grid gap-2"> <div class="d-grid gap-2 store-layout__contact-details">
<div class="d-flex align-items-center gap-2 "> <div class="d-flex align-items-center gap-2">
<i class="fa-solid fa-location-dot" aria-hidden="true"></i> <i class="fa-solid fa-location-dot" aria-hidden="true"></i>
<span>Av. San Lorenzo 1542, Rosario</span> <span>Av. San Lorenzo 1542, Rosario</span>
</div> </div>
<div class="d-flex align-items-center gap-2 "> <div class="d-flex align-items-center gap-2">
<i class="fa-solid fa-phone" aria-hidden="true"></i> <i class="fa-solid fa-phone" aria-hidden="true"></i>
<span>54 9 (0341) 6658247</span> <span>54 9 (0341) 6658247</span>
</div> </div>
</div> </div>
<p class="mb-0 small store-layout__muted-text">Copyright &copy; {{ currentYear }} - Sonder</p> <p class="mb-0 small store-layout__muted-text store-layout__copyright">
Copyright &copy; {{ currentYear }} - {{ storeName || 'Sonder' }}
</p>
</div> </div>
@for (section of footerSections; track section.heading) { @for (section of footerSections; track section.heading) {
<div class="col-12 col-md-6 col-xl-2 d-grid align-content-start"> <div class="col-12 col-md-6 col-xl-2 d-grid align-content-start store-layout__links-column">
<div class="d-grid gap-2"> <div class="d-grid gap-2">
@for (link of section.links; track link.label) { @for (link of section.links; track link.label) {
@if (link.routerLink) { @if (link.routerLink) {
<a <a class="store-layout__footer-link" [routerLink]="link.routerLink">
class="store-layout__footer-link"
[routerLink]="link.routerLink"
>
{{ link.label }} {{ link.label }}
</a> </a>
} @else if (link.action === 'logout') { } @else if (link.action === 'logout') {
@@ -59,8 +58,10 @@
</div> </div>
} }
<div class="col-12 col-md-6 col-xl-3 d-grid align-content-start"> <div class="store-layout__mobile-divider" aria-hidden="true"></div>
<div class="d-flex align-items-center gap-3">
<div class="col-12 col-md-6 col-xl-3 d-grid align-content-start store-layout__social-column">
<div class="d-flex align-items-center gap-3 store-layout__social-list">
@for (social of socialMedia; track social.code) { @for (social of socialMedia; track social.code) {
<app-social-media <app-social-media
class="text-white" class="text-white"

View File

@@ -2,6 +2,10 @@
background-color: var(--tenant-footer-bg); background-color: var(--tenant-footer-bg);
} }
.store-layout__mobile-divider {
display: none;
}
.store-layout__brand-slot { .store-layout__brand-slot {
width: min(100%, 12rem); width: min(100%, 12rem);
min-width: 10rem; min-width: 10rem;
@@ -92,11 +96,98 @@
} }
@media (max-width: 767.98px) { @media (max-width: 767.98px) {
.store-layout__footer-content {
padding: 1.45rem 2.5rem 1.8rem !important;
}
.store-layout__footer-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0;
margin: 0;
}
.store-layout__footer-grid > * {
margin-top: 0;
padding-right: 0;
padding-left: 0;
}
.store-layout__links-column {
order: 1;
width: auto;
padding-bottom: 1.65rem;
font-size: 0.72rem;
line-height: 1.25;
&:nth-of-type(even) {
padding-left: 0.65rem;
}
> div {
gap: 0.5rem !important;
}
}
.store-layout__mobile-divider {
display: block;
order: 2;
grid-column: 1 / -1;
width: 100%;
border-top: 1px solid rgba(255, 255, 255, 0.3);
}
.store-layout__brand-column,
.store-layout__social-column {
order: 3;
width: auto;
padding-top: 2rem;
}
.store-layout__brand-slot { .store-layout__brand-slot {
width: min(100%, 7.75rem);
min-width: 0; min-width: 0;
} }
.store-layout__brand-logo {
max-height: 2.5rem;
}
.store-layout__brand-logo-shell {
width: min(100%, 7.75rem);
height: 2.5rem;
}
.store-layout__brand-text { .store-layout__brand-text {
font-size: 1.45rem; font-size: 1.45rem;
} }
.store-layout__social-column {
align-items: center;
}
.store-layout__social-list {
justify-content: flex-end;
gap: 0 !important;
}
.store-layout__contact {
order: 4;
grid-column: 1 / -1;
width: auto;
gap: 1.25rem !important;
padding-top: 0.9rem;
text-align: center;
}
.store-layout__contact-details {
justify-items: center;
gap: 0.4rem !important;
font-size: 0.7rem;
}
.store-layout__copyright {
font-size: 0.6rem;
line-height: 1.3;
}
} }

View File

@@ -25,5 +25,6 @@ export class StoreFooterComponent {
@Input({ required: true }) footerSections: StoreFooterSection[] = []; @Input({ required: true }) footerSections: StoreFooterSection[] = [];
@Input({ required: true }) socialMedia: SocialMedia[] = []; @Input({ required: true }) socialMedia: SocialMedia[] = [];
@Input() logoUrl: string | null = null; @Input() logoUrl: string | null = null;
@Input() storeName: string | null = null;
readonly logoutClick = output<void>(); readonly logoutClick = output<void>();
} }

View File

@@ -1,81 +1,83 @@
<header class="store-layout__header"> <header class="store-layout__header">
<div class="store-layout__header-main"> <div class="store-layout__header-main">
<div <div class="container-xl px-3 px-md-4 py-3 py-lg-4">
class="container-xl px-3 px-md-4 py-3 py-lg-4 d-flex flex-row flex-wrap align-items-center justify-content-between gap-3" <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 <a
routerLink="/" routerLink="/"
class="store-layout__brand-slot d-inline-flex align-items-center gap-3 flex-shrink-0 text-decoration-none" class="store-layout__brand-slot d-inline-flex align-items-center gap-3 text-decoration-none"
data-testid="store-header-brand-slot" data-testid="store-header-brand-slot"
aria-label="Logo del comercio" aria-label="Logo del comercio"
> >
@if (logoUrl()) { @if (logoUrl()) {
<img class="store-layout__brand-logo" [src]="logoUrl()" alt="Logo del comercio" /> <img class="store-layout__brand-logo" [src]="logoUrl()" alt="Logo del comercio" />
} @else { } @else {
<div class="store-layout__brand-logo-shell" aria-hidden="true"> <div class="store-layout__brand-logo-shell" aria-hidden="true">
<span class="store-layout__brand-logo-skeleton"></span> <span class="store-layout__brand-logo-skeleton"></span>
</div> </div>
} }
</a> </a>
<div
class="d-flex flex-row align-items-center gap-3 ms-auto flex-nowrap flex-grow-1 justify-content-between justify-content-md-end"
>
<div class="store-layout__search-wrapper">
<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>
@if (ticketsMenu(); as menu) { <div class="col-12 px-0 d-md-none order-3">
<app-button <hr class="store-layout__mobile-separator">
variant="primary" </div>
hostClass="flex-shrink-0"
data-testid="store-header-tickets" <div class="col-12 col-md order-4 order-md-2">
(click)="ticketsClick.emit()" <div class="store-layout__search-wrapper ms-md-auto">
> <div class="input-group store-layout__search" role="search">
{{ menu.label }} <label class="visually-hidden" for="store-search-input">Buscar productos</label>
</app-button> <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>
}
<div
class="d-flex align-items-center justify-content-end gap-1"
aria-label="Acciones de usuario"
>
<app-cart-icon <app-cart-icon
[quantity]="cartQuantity()" [quantity]="cartQuantity()"
ariaLabel="Carrito de compras" ariaLabel="Carrito de compras"
title="Carrito" title="Carrito"
(click)="cartClick.emit()" (click)="cartClick.emit()"
/> />
<div class="store-layout__user-menu">
<div class="store-layout__user-menu d-none d-md-inline-flex position-relative">
<app-icon-button <app-icon-button
variant="user" variant="user"
ariaLabel="Mi cuenta" ariaLabel="Mi cuenta"
@@ -94,12 +96,46 @@
/> />
} }
</div> </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>
<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> </div>
</div> </div>
<div class="store-layout__categories"> <div class="store-layout__categories d-none d-md-block">
<div class="container-xl px-3 px-md-4"> <div class="container-xl px-3 px-md-4">
<div class="store-layout__category-menu"> <div class="store-layout__category-menu">
<button <button

View File

@@ -10,7 +10,10 @@
.store-layout__brand-slot { .store-layout__brand-slot {
min-width: 150px; min-width: 150px;
max-width:40px; }
.store-layout__brand-column {
max-width: 12rem;
} }
.store-layout__brand-logo-shell { .store-layout__brand-logo-shell {
@@ -42,10 +45,25 @@
} }
.store-layout__search-wrapper { .store-layout__search-wrapper {
width: min(100%, 260px); width: 100%;
min-width: 0; 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 { .store-layout__search {
width: 100%; width: 100%;
} }
@@ -137,5 +155,56 @@
@media (max-width: 767.98px) { @media (max-width: 767.98px) {
.store-layout__brand-slot { .store-layout__brand-slot {
min-width: 0; min-width: 0;
max-width: 150px;
}
.store-layout__mobile-separator {
border-color: #DDDDDD;
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: 0 0 auto;
width: auto;
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

@@ -1,5 +1,5 @@
import { Component, ElementRef, HostListener, inject, input, output, signal } from '@angular/core'; import { Component, ElementRef, HostListener, inject, input, output, signal } from '@angular/core';
import { RouterLink } from '@angular/router'; import { Router, RouterLink } from '@angular/router';
import { AuthUser } from '../../../services/auth/auth.interfaces'; import { AuthUser } from '../../../services/auth/auth.interfaces';
import { CartIconComponent } from '../../../../shared/components/cart-icon/cart-icon.component'; import { CartIconComponent } from '../../../../shared/components/cart-icon/cart-icon.component';
import { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.component'; import { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.component';
@@ -26,6 +26,7 @@ import { CategoryDropdownComponent } from './category-dropdown/category-dropdown
}) })
export class StoreHeaderComponent { export class StoreHeaderComponent {
private readonly elementRef = inject(ElementRef<HTMLElement>); private readonly elementRef = inject(ElementRef<HTMLElement>);
private readonly router = inject(Router);
readonly logoUrl = input<string | null>(null); readonly logoUrl = input<string | null>(null);
readonly cartQuantity = input<number>(0); readonly cartQuantity = input<number>(0);
@@ -43,13 +44,14 @@ export class StoreHeaderComponent {
protected readonly isUserDropdownOpen = signal(false); protected readonly isUserDropdownOpen = signal(false);
protected readonly isCategoryDropdownOpen = signal(false); protected readonly isCategoryDropdownOpen = signal(false);
protected readonly isMobileMenuOpen = signal(false);
protected readonly minSearchLength = 3; protected readonly minSearchLength = 3;
protected readonly showSearchError = signal(false); protected readonly showSearchError = signal(false);
protected readonly searchControl = new FormControl('', { nonNullable: true }); protected readonly searchControl = new FormControl('', { nonNullable: true });
@HostListener('document:click', ['$event']) @HostListener('document:click', ['$event'])
protected onDocumentClick(event: MouseEvent): void { protected onDocumentClick(event: MouseEvent): void {
if (!this.isUserDropdownOpen() && !this.isCategoryDropdownOpen()) { if (!this.isUserDropdownOpen() && !this.isCategoryDropdownOpen() && !this.isMobileMenuOpen()) {
return; return;
} }
@@ -57,6 +59,7 @@ export class StoreHeaderComponent {
if (target instanceof Node && !this.elementRef.nativeElement.contains(target)) { if (target instanceof Node && !this.elementRef.nativeElement.contains(target)) {
this.isUserDropdownOpen.set(false); this.isUserDropdownOpen.set(false);
this.isCategoryDropdownOpen.set(false); this.isCategoryDropdownOpen.set(false);
this.isMobileMenuOpen.set(false);
} }
} }
@@ -64,6 +67,7 @@ export class StoreHeaderComponent {
protected onEscapeKey(): void { protected onEscapeKey(): void {
this.isUserDropdownOpen.set(false); this.isUserDropdownOpen.set(false);
this.isCategoryDropdownOpen.set(false); this.isCategoryDropdownOpen.set(false);
this.isMobileMenuOpen.set(false);
} }
protected toggleUserDropdown(): void { protected toggleUserDropdown(): void {
@@ -109,6 +113,25 @@ export class StoreHeaderComponent {
protected onCategorySelect(category: Category): void { protected onCategorySelect(category: Category): void {
this.isCategoryDropdownOpen.set(false); this.isCategoryDropdownOpen.set(false);
this.isMobileMenuOpen.set(false);
this.categorySelect.emit(category); 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

@@ -61,6 +61,7 @@
[footerSections]="footerSections()" [footerSections]="footerSections()"
[socialMedia]="tenant()?.social_media ?? []" [socialMedia]="tenant()?.social_media ?? []"
[logoUrl]="tenant()?.footer_logo ?? null" [logoUrl]="tenant()?.footer_logo ?? null"
[storeName]="tenant()?.nombre ?? null"
(logoutClick)="onLogoutClick()" (logoutClick)="onLogoutClick()"
/> />
</div> </div>

View File

@@ -21,9 +21,10 @@
.store-layout__cart-dropdown { .store-layout__cart-dropdown {
position: absolute; position: absolute;
top: 76px; top: 100px;
right: calc((100% - 1320px) / 2 + 1.5rem); right: calc((100% - 1320px) / 2 + 1.5rem);
width: 400px;
max-height: calc(100vh - 120px); max-height: calc(100vh - 120px);
z-index: 1050; z-index: 1050;
background-color: #ffffff; background-color: #ffffff;

View File

@@ -1,10 +1,11 @@
import { DOCUMENT } from '@angular/common'; import { DOCUMENT } from '@angular/common';
import { HttpClient, HttpErrorResponse, HttpResponse } from '@angular/common/http'; import { HttpErrorResponse, HttpResponse } from '@angular/common/http';
import { isPlatformBrowser, isPlatformServer } from '@angular/common'; import { isPlatformBrowser, isPlatformServer } from '@angular/common';
import { computed, inject, Injectable, PLATFORM_ID, signal, TransferState, makeStateKey } from '@angular/core'; import { computed, inject, Injectable, PLATFORM_ID, signal, TransferState, makeStateKey } from '@angular/core';
import { firstValueFrom, map, Observable, of, tap } from 'rxjs'; import { firstValueFrom, map, Observable, of, tap } from 'rxjs';
import { environment } from '../../../../environments/environment'; import { environment } from '../../../../environments/environment';
import { BaseApiService } from '../base-api.service';
import { import {
AuthUser, AuthUser,
LoginPayload, LoginPayload,
@@ -26,8 +27,7 @@ const AUTH_USER_SSR_STATE_KEY = makeStateKey<AuthUser>('shopit.auth.user');
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
}) })
export class AuthService { export class AuthService extends BaseApiService {
private readonly http = inject(HttpClient);
private readonly document = inject(DOCUMENT); private readonly document = inject(DOCUMENT);
private readonly platformId = inject(PLATFORM_ID); private readonly platformId = inject(PLATFORM_ID);
private readonly cookieService = inject(CookieService); private readonly cookieService = inject(CookieService);
@@ -51,7 +51,9 @@ export class AuthService {
.post<LoginResponse>( .post<LoginResponse>(
`${environment.url}login`, `${environment.url}login`,
{ ...payload, tenant_codigo: tenant.codigo }, { ...payload, tenant_codigo: tenant.codigo },
{ withCredentials: true } {
withCredentials: true
}
) )
.pipe( .pipe(
tap((response) => this.applyAuthenticatedState(response.token, response.user)), tap((response) => this.applyAuthenticatedState(response.token, response.user)),
@@ -62,13 +64,18 @@ export class AuthService {
register(payload: RegisterPayload): Observable<RegisterResponse> { register(payload: RegisterPayload): Observable<RegisterResponse> {
const tenantCode = this.tenantService.getTenant()?.codigo; const tenantCode = this.tenantService.getTenant()?.codigo;
return this.http.post<RegisterResponse>(`${environment.url}register`, { return this.http.post<RegisterResponse>(
...payload, `${environment.url}register`,
...(tenantCode ? { tenant_codigo: tenantCode } : {}) {
}); ...payload,
...(tenantCode ? { tenant_codigo: tenantCode } : {})
},
);
} }
requestPasswordReset(email: string): Observable<HttpResponse<ResetPasswordAttemptResponse>> { requestPasswordReset(
email: string
): Observable<HttpResponse<ResetPasswordAttemptResponse>> {
const tenantCode = this.tenantService.getTenant()?.codigo; const tenantCode = this.tenantService.getTenant()?.codigo;
return this.http.post<ResetPasswordAttemptResponse>( return this.http.post<ResetPasswordAttemptResponse>(
@@ -77,7 +84,9 @@ export class AuthService {
email, email,
...(tenantCode ? { tenant_codigo: tenantCode } : {}) ...(tenantCode ? { tenant_codigo: tenantCode } : {})
}, },
{ observe: 'response' } {
observe: 'response'
}
); );
} }
@@ -88,7 +97,9 @@ export class AuthService {
return this.http.post<ValidateResetPasswordAttemptResponse>( return this.http.post<ValidateResetPasswordAttemptResponse>(
`${environment.url}password/reset-attempts/validate`, `${environment.url}password/reset-attempts/validate`,
{ email, codigo }, { email, codigo },
{ observe: 'response' } {
observe: 'response'
}
); );
} }
@@ -122,7 +133,9 @@ export class AuthService {
.post<LoginResponse>( .post<LoginResponse>(
`${environment.url}auth/google/exchange`, `${environment.url}auth/google/exchange`,
{ oauth_code: oauthCode, tenant_codigo: tenant.codigo }, { oauth_code: oauthCode, tenant_codigo: tenant.codigo },
{ withCredentials: true } {
withCredentials: true
}
) )
.pipe( .pipe(
tap((response) => this.applyAuthenticatedState(response.token, response.user)), tap((response) => this.applyAuthenticatedState(response.token, response.user)),
@@ -131,7 +144,9 @@ export class AuthService {
} }
updateProfile(payload: UpdateProfilePayload): Observable<AuthUser> { updateProfile(payload: UpdateProfilePayload): Observable<AuthUser> {
return this.http.put<AuthUser>(`${environment.url}me`, payload).pipe(tap((user) => this.userState.set(user))); return this.http
.put<AuthUser>(`${environment.url}me`, payload)
.pipe(tap((user) => this.userState.set(user)));
} }
logout(): Observable<void> { logout(): Observable<void> {
@@ -140,7 +155,9 @@ export class AuthService {
return of(void 0); return of(void 0);
} }
return this.http.post<void>(`${environment.url}logout`, {}).pipe(tap(() => this.clearSession())); return this.http
.post<void>(`${environment.url}logout`, {})
.pipe(tap(() => this.clearSession()));
} }
async bootstrap(): Promise<void> { async bootstrap(): Promise<void> {
@@ -179,7 +196,9 @@ export class AuthService {
} }
loadCurrentUser(): Observable<AuthUser> { loadCurrentUser(): Observable<AuthUser> {
return this.http.get<AuthUser>(`${environment.url}me`).pipe(tap((user) => this.userState.set(user))); return this.http
.get<AuthUser>(`${environment.url}me`)
.pipe(tap((user) => this.userState.set(user)));
} }
hydrateSession(): void { hydrateSession(): void {

View File

@@ -0,0 +1,42 @@
import '@angular/compiler';
import { provideHttpClient } from '@angular/common/http';
import { TestBed } from '@angular/core/testing';
import { afterEach, describe, expect, it } from 'vitest';
import { BaseApiService } from './base-api.service';
import { LOADING_MODE } from './global-loading/loading-mode';
class TestApiService extends BaseApiService {
contextFor() {
return this.loadingContext();
}
}
describe('BaseApiService', () => {
afterEach(() => {
TestBed.resetTestingModule();
});
it('uses global loading by default', () => {
TestBed.configureTestingModule({ providers: [provideHttpClient()] });
const service = TestBed.runInInjectionContext(() => new TestApiService());
expect(service.contextFor().get(LOADING_MODE)).toBe('global');
});
it('creates an isolated custom-loading scope', () => {
TestBed.configureTestingModule({ providers: [provideHttpClient()] });
const service = TestBed.runInInjectionContext(() => new TestApiService());
expect(service.withCustomLoading().contextFor().get(LOADING_MODE)).toBe('custom');
expect(service.contextFor().get(LOADING_MODE)).toBe('global');
});
it('creates an isolated scope without a loading indicator', () => {
TestBed.configureTestingModule({ providers: [provideHttpClient()] });
const service = TestBed.runInInjectionContext(() => new TestApiService());
expect(service.withoutLoading().contextFor().get(LOADING_MODE)).toBe('none');
expect(service.contextFor().get(LOADING_MODE)).toBe('global');
});
});

View File

@@ -0,0 +1,79 @@
import { HttpClient, HttpContext } from '@angular/common/http';
import { inject } from '@angular/core';
import {
LOADING_MODE,
LoadingMode
} from './global-loading/loading-mode';
const REQUEST_LOADING_MODE = Symbol('request-loading-mode');
class LoadingHttpClient {
readonly get: HttpClient['get'];
readonly post: HttpClient['post'];
readonly put: HttpClient['put'];
readonly patch: HttpClient['patch'];
readonly delete: HttpClient['delete'];
constructor(
http: HttpClient,
private readonly buildContext: (context?: HttpContext) => HttpContext
) {
this.get = ((url: string, options?: object) =>
http.get(url, this.withLoadingContext(options))) as HttpClient['get'];
this.post = ((url: string, body: unknown, options?: object) =>
http.post(url, body, this.withLoadingContext(options))) as HttpClient['post'];
this.put = ((url: string, body: unknown, options?: object) =>
http.put(url, body, this.withLoadingContext(options))) as HttpClient['put'];
this.patch = ((url: string, body: unknown, options?: object) =>
http.patch(url, body, this.withLoadingContext(options))) as HttpClient['patch'];
this.delete = ((url: string, options?: object) =>
http.delete(url, this.withLoadingContext(options))) as HttpClient['delete'];
}
private withLoadingContext(options?: object): object {
const requestOptions = options as { context?: HttpContext } | undefined;
return {
...requestOptions,
context: this.buildContext(requestOptions?.context)
};
}
}
export abstract class BaseApiService {
private readonly rawHttp = inject(HttpClient);
private readonly [REQUEST_LOADING_MODE]: LoadingMode = 'global';
protected get http(): LoadingHttpClient {
return new LoadingHttpClient(
this.rawHttp,
(context) => this.loadingContext(context)
);
}
withLoading(loadingMode: LoadingMode): this {
const scopedService = Object.create(this) as this;
Object.defineProperty(scopedService, REQUEST_LOADING_MODE, {
configurable: false,
enumerable: false,
value: loadingMode,
writable: false
});
return scopedService;
}
withCustomLoading(): this {
return this.withLoading('custom');
}
withoutLoading(): this {
return this.withLoading('none');
}
protected loadingContext(context: HttpContext = new HttpContext()): HttpContext {
return context.set(LOADING_MODE, this[REQUEST_LOADING_MODE]);
}
}

View File

@@ -6,6 +6,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { CartService } from './cart.service'; import { CartService } from './cart.service';
import { TenantService } from '../tenant.service'; import { TenantService } from '../tenant.service';
import { Cart } from './cart.interface'; import { Cart } from './cart.interface';
import { LOADING_MODE } from '../global-loading/loading-mode';
describe('CartService', () => { describe('CartService', () => {
let service: CartService; let service: CartService;
@@ -75,6 +76,14 @@ describe('CartService', () => {
req.flush({ data: mockCart }); req.flush({ data: mockCart });
}); });
it('propagates a custom loading mode to the request context', () => {
service.withCustomLoading().loadCart().subscribe();
const req = httpMock.expectOne('http://api.test/tenants/acme/cart');
expect(req.request.context.get(LOADING_MODE)).toBe('custom');
req.flush({ data: mockCart });
});
it('should clear cart state locally', () => { it('should clear cart state locally', () => {
service.clearCart(); service.clearCart();

View File

@@ -1,16 +1,15 @@
import { inject, Injectable, signal } from '@angular/core'; import { inject, Injectable, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { catchError, map, Observable, tap } from 'rxjs'; import { catchError, map, Observable, tap } from 'rxjs';
import { ApiResponse } from '../api-response.interface'; import { ApiResponse } from '../api-response.interface';
import { BaseApiService } from '../base-api.service';
import { TenantService } from '../tenant.service'; import { TenantService } from '../tenant.service';
import { Cart } from './cart.interface'; import { Cart } from './cart.interface';
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
}) })
export class CartService { export class CartService extends BaseApiService {
private readonly http = inject(HttpClient);
private readonly tenantService = inject(TenantService); private readonly tenantService = inject(TenantService);
private readonly cartState = signal<Cart | null>(null); private readonly cartState = signal<Cart | null>(null);
@@ -53,7 +52,13 @@ export class CartService {
return this.http return this.http
.post< .post<
ApiResponse<Cart> ApiResponse<Cart>
>(`${this.tenantApiUrl}/cart/items`, { catalog_item_id: catalogItemId, variant_id: variantId, cantidad }, { withCredentials: true }) >(
`${this.tenantApiUrl}/cart/items`,
{ catalog_item_id: catalogItemId, variant_id: variantId, cantidad },
{
withCredentials: true,
},
)
.pipe( .pipe(
tap((response) => { tap((response) => {
this.cartState.set(response.data); this.cartState.set(response.data);
@@ -66,12 +71,21 @@ export class CartService {
); );
} }
updateItemQuantity(cartItemId: number, cantidad: number): Observable<ApiResponse<Cart>> { updateItemQuantity(
cartItemId: number,
cantidad: number,
): Observable<ApiResponse<Cart>> {
this.isUpdatingState.set(true); this.isUpdatingState.set(true);
return this.http return this.http
.patch< .patch<
ApiResponse<Cart> ApiResponse<Cart>
>(`${this.tenantApiUrl}/cart/items/${cartItemId}`, { cantidad }, { withCredentials: true }) >(
`${this.tenantApiUrl}/cart/items/${cartItemId}`,
{ cantidad },
{
withCredentials: true,
},
)
.pipe( .pipe(
tap((response) => { tap((response) => {
this.cartState.set(response.data); this.cartState.set(response.data);
@@ -89,7 +103,9 @@ export class CartService {
return this.http return this.http
.delete< .delete<
ApiResponse<Cart> ApiResponse<Cart>
>(`${this.tenantApiUrl}/cart/items/${cartItemId}`, { withCredentials: true }) >(`${this.tenantApiUrl}/cart/items/${cartItemId}`, {
withCredentials: true,
})
.pipe( .pipe(
tap((response) => { tap((response) => {
this.cartState.set(response.data); this.cartState.set(response.data);

View File

@@ -1,10 +1,11 @@
import { HttpParams } from '@angular/common/http';
import { inject, Injectable } from '@angular/core'; import { inject, Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { map, Observable } from 'rxjs'; import { map, Observable } from 'rxjs';
import { ApiPaginationQueryParams } from '../api-pagination-query-params.interface'; import { ApiPaginationQueryParams } from '../api-pagination-query-params.interface';
import { ApiPaginatedResponse } from '../api-paginated-response.interface'; import { ApiPaginatedResponse } from '../api-paginated-response.interface';
import { ApiResponse } from '../api-response.interface'; import { ApiResponse } from '../api-response.interface';
import { BaseApiService } from '../base-api.service';
import { TenantService } from '../tenant.service'; import { TenantService } from '../tenant.service';
import { import {
CatalogFeaturedGroup, CatalogFeaturedGroup,
@@ -24,8 +25,7 @@ export interface CatalogSearchQueryParams extends ApiPaginationQueryParams {
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
}) })
export class CatalogService { export class CatalogService extends BaseApiService {
private readonly http = inject(HttpClient);
private readonly tenantService = inject(TenantService); private readonly tenantService = inject(TenantService);
private get tenantApiUrl(): string { private get tenantApiUrl(): string {
@@ -47,7 +47,9 @@ export class CatalogService {
): Observable<ApiPaginatedResponse<CatalogFeaturedItem[]>> { ): Observable<ApiPaginatedResponse<CatalogFeaturedItem[]>> {
return this.http.get<ApiPaginatedResponse<CatalogFeaturedItem[]>>( return this.http.get<ApiPaginatedResponse<CatalogFeaturedItem[]>>(
`${this.tenantApiUrl}/catalog-items`, `${this.tenantApiUrl}/catalog-items`,
{ params: this.buildHttpParams(params) }, {
params: this.buildHttpParams(params),
},
); );
} }
@@ -66,18 +68,25 @@ export class CatalogService {
): Observable<CatalogFeaturedItems> { ): Observable<CatalogFeaturedItems> {
return this.http.get<CatalogFeaturedItems>( return this.http.get<CatalogFeaturedItems>(
`${this.tenantApiUrl}/catalog/featured-groups/${featuredGroupId}/items`, `${this.tenantApiUrl}/catalog/featured-groups/${featuredGroupId}/items`,
{ params: this.buildHttpParams(params) }, {
params: this.buildHttpParams(params),
},
); );
} }
getCatalogItem(id: number, variantId?: number): Observable<CatalogItemDetail> { getCatalogItem(
id: number,
variantId?: number,
): Observable<CatalogItemDetail> {
let params = new HttpParams(); let params = new HttpParams();
if (variantId) { if (variantId) {
params = params.set('variant_id', variantId); params = params.set('variant_id', variantId);
} }
return this.http return this.http
.get<ApiResponse<CatalogItemDetail>>(`${this.tenantApiUrl}/catalog-items/${id}`, { params }) .get<ApiResponse<CatalogItemDetail>>(`${this.tenantApiUrl}/catalog-items/${id}`, {
params,
})
.pipe(map((response) => response.data)); .pipe(map((response) => response.data));
} }

View File

@@ -1,8 +1,8 @@
import { inject, Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
import { BaseApiService } from './base-api.service';
export interface UpdatePurchaseCustomerPayload { export interface UpdatePurchaseCustomerPayload {
dni: string; dni: string;
@@ -77,9 +77,7 @@ export interface PurchaseDetailResponse extends PurchaseStatusResponse {
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
}) })
export class CheckoutService { export class CheckoutService extends BaseApiService {
private readonly http = inject(HttpClient);
async startCheckout( async startCheckout(
tenantCode: string, tenantCode: string,
payload: StartCheckoutPayload, payload: StartCheckoutPayload,
@@ -182,7 +180,10 @@ export class CheckoutService {
return purchase; return purchase;
} }
async completePurchase(tenantCode: string, purchaseId: number): Promise<PurchaseStatusResponse> { async completePurchase(
tenantCode: string,
purchaseId: number,
): Promise<PurchaseStatusResponse> {
const response = await firstValueFrom( const response = await firstValueFrom(
this.http.post<{ data?: PurchaseStatusResponse } | PurchaseStatusResponse>( this.http.post<{ data?: PurchaseStatusResponse } | PurchaseStatusResponse>(
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/complete`, `${environment.url}tenants/${tenantCode}/compras/${purchaseId}/complete`,
@@ -220,7 +221,10 @@ export class CheckoutService {
return { status: purchase.status ?? null }; return { status: purchase.status ?? null };
} }
async cancelPurchase(tenantCode: string, purchaseId: number): Promise<PurchaseStatusResponse> { async cancelPurchase(
tenantCode: string,
purchaseId: number,
): Promise<PurchaseStatusResponse> {
const response = await firstValueFrom( const response = await firstValueFrom(
this.http.post<{ data?: PurchaseStatusResponse } | PurchaseStatusResponse>( this.http.post<{ data?: PurchaseStatusResponse } | PurchaseStatusResponse>(
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/cancel`, `${environment.url}tenants/${tenantCode}/compras/${purchaseId}/cancel`,
@@ -244,7 +248,9 @@ export class CheckoutService {
if (status) { if (status) {
url += `?status=${status}`; url += `?status=${status}`;
} }
const response = await firstValueFrom(this.http.get<{ data: PurchaseSummaryResponse[] }>(url)); const response = await firstValueFrom(
this.http.get<{ data: PurchaseSummaryResponse[] }>(url),
);
if (!response) { if (!response) {
throw new Error('Error al obtener las compras.'); throw new Error('Error al obtener las compras.');
} }

View File

@@ -0,0 +1,80 @@
import '@angular/compiler';
import { HttpClient, HttpContext, provideHttpClient, withInterceptors } from '@angular/common/http';
import {
HttpTestingController,
provideHttpClientTesting
} from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { globalLoadingInterceptor } from './global-loading.interceptor';
import { GlobalLoadingService } from './global-loading.service';
import { LOADING_MODE, LoadingMode } from './loading-mode';
describe('globalLoadingInterceptor', () => {
let client: HttpClient;
let httpTesting: HttpTestingController;
let loadingService: GlobalLoadingService;
beforeEach(() => {
vi.useFakeTimers();
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptors([globalLoadingInterceptor])),
provideHttpClientTesting()
]
});
client = TestBed.inject(HttpClient);
httpTesting = TestBed.inject(HttpTestingController);
loadingService = TestBed.inject(GlobalLoadingService);
});
afterEach(() => {
httpTesting.verify();
TestBed.resetTestingModule();
vi.useRealTimers();
});
it('tracks concurrent requests until the last one completes', () => {
client.get('/api/first').subscribe();
client.get('/api/second').subscribe();
vi.advanceTimersByTime(150);
expect(loadingService.isLoading()).toBe(true);
httpTesting.expectOne('/api/first').flush({});
expect(loadingService.isLoading()).toBe(true);
httpTesting.expectOne('/api/second').flush({});
expect(loadingService.isLoading()).toBe(false);
});
it('stops loading when a request fails', () => {
client.get('/api/failing').subscribe({ error: () => undefined });
vi.advanceTimersByTime(150);
expect(loadingService.isLoading()).toBe(true);
httpTesting.expectOne('/api/failing').flush(
{ message: 'Error' },
{ status: 500, statusText: 'Server Error' }
);
expect(loadingService.isLoading()).toBe(false);
});
it.each<LoadingMode>(['custom', 'none'])(
'does not show the global loader for %s requests',
(loadingMode) => {
const context = new HttpContext().set(LOADING_MODE, loadingMode);
client.get(`/api/${loadingMode}`, { context }).subscribe();
vi.advanceTimersByTime(150);
expect(loadingService.isLoading()).toBe(false);
httpTesting.expectOne(`/api/${loadingMode}`).flush({});
}
);
});

View File

@@ -0,0 +1,20 @@
import { HttpEvent, HttpHandlerFn, HttpInterceptorFn, HttpRequest } from '@angular/common/http';
import { inject } from '@angular/core';
import { Observable, finalize } from 'rxjs';
import { GlobalLoadingService } from './global-loading.service';
import { LOADING_MODE } from './loading-mode';
export const globalLoadingInterceptor: HttpInterceptorFn = (
request: HttpRequest<unknown>,
next: HttpHandlerFn
): Observable<HttpEvent<unknown>> => {
if (request.context.get(LOADING_MODE) !== 'global') {
return next(request);
}
const loadingService = inject(GlobalLoadingService);
loadingService.start();
return next(request).pipe(finalize(() => loadingService.stop()));
};

View File

@@ -0,0 +1,65 @@
import '@angular/compiler';
import { TestBed } from '@angular/core/testing';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { GlobalLoadingService } from './global-loading.service';
describe('GlobalLoadingService', () => {
let service: GlobalLoadingService;
beforeEach(() => {
vi.useFakeTimers();
TestBed.configureTestingModule({});
service = TestBed.inject(GlobalLoadingService);
});
afterEach(() => {
TestBed.resetTestingModule();
vi.useRealTimers();
});
it('delays the loading state to avoid flashes on fast operations', () => {
service.start();
expect(service.isLoading()).toBe(false);
service.stop();
vi.advanceTimersByTime(150);
expect(service.isLoading()).toBe(false);
});
it('shows the loading state for an operation that exceeds the delay', () => {
service.start();
vi.advanceTimersByTime(150);
expect(service.isLoading()).toBe(true);
service.stop();
expect(service.isLoading()).toBe(false);
});
it('stays visible until all concurrent operations finish', () => {
service.start();
service.start();
vi.advanceTimersByTime(150);
service.stop();
expect(service.isLoading()).toBe(true);
service.stop();
expect(service.isLoading()).toBe(false);
});
it('ignores extra stop calls without corrupting the counter', () => {
service.stop();
service.start();
vi.advanceTimersByTime(150);
expect(service.isLoading()).toBe(true);
service.stop();
expect(service.isLoading()).toBe(false);
});
});

View File

@@ -0,0 +1,58 @@
import { isPlatformBrowser } from '@angular/common';
import { Injectable, PLATFORM_ID, inject, signal } from '@angular/core';
const SHOW_DELAY_MS = 150;
@Injectable({ providedIn: 'root' })
export class GlobalLoadingService {
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
private readonly loadingState = signal(false);
private pendingOperations = 0;
private showTimer: ReturnType<typeof setTimeout> | undefined;
readonly isLoading = this.loadingState.asReadonly();
start(): void {
if (!this.isBrowser) {
return;
}
this.pendingOperations += 1;
if (this.pendingOperations > 1 || this.loadingState()) {
return;
}
this.showTimer = setTimeout(() => {
this.showTimer = undefined;
if (this.pendingOperations > 0) {
this.loadingState.set(true);
}
}, SHOW_DELAY_MS);
}
stop(): void {
if (!this.isBrowser || this.pendingOperations === 0) {
return;
}
this.pendingOperations -= 1;
if (this.pendingOperations > 0) {
return;
}
this.cancelShowTimer();
this.loadingState.set(false);
}
private cancelShowTimer(): void {
if (this.showTimer === undefined) {
return;
}
clearTimeout(this.showTimer);
this.showTimer = undefined;
}
}

View File

@@ -0,0 +1,5 @@
import { HttpContextToken } from '@angular/common/http';
export type LoadingMode = 'global' | 'custom' | 'none';
export const LOADING_MODE = new HttpContextToken<LoadingMode>(() => 'global');

View File

@@ -1,5 +1,5 @@
import { isPlatformBrowser, isPlatformServer } from '@angular/common'; import { isPlatformBrowser, isPlatformServer } from '@angular/common';
import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { HttpErrorResponse } from '@angular/common/http';
import { import {
inject, inject,
Injectable, Injectable,
@@ -13,6 +13,7 @@ import { IS_DISCOVERING_ROUTES } from '@angular/ssr';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
import { BaseApiService } from './base-api.service';
import { Tenant, TenantBootstrapResponse } from './tenant.interface'; import { Tenant, TenantBootstrapResponse } from './tenant.interface';
import { import {
TENANT_SSR_STATE_KEY, TENANT_SSR_STATE_KEY,
@@ -23,8 +24,7 @@ import {
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
}) })
export class TenantService { export class TenantService extends BaseApiService {
private readonly http = inject(HttpClient);
private readonly platformId = inject(PLATFORM_ID); private readonly platformId = inject(PLATFORM_ID);
private readonly request = inject(REQUEST, { optional: true }); private readonly request = inject(REQUEST, { optional: true });
private readonly responseInit = inject(RESPONSE_INIT, { optional: true }); private readonly responseInit = inject(RESPONSE_INIT, { optional: true });
@@ -73,7 +73,9 @@ export class TenantService {
try { try {
const response = await firstValueFrom( const response = await firstValueFrom(
this.http.get<TenantBootstrapResponse>(`${environment.url}tenants/bootstrap/${domain}`) this.http.get<TenantBootstrapResponse>(
`${environment.url}tenants/bootstrap/${domain}`
)
); );
if (!response?.data) { if (!response?.data) {

View File

@@ -0,0 +1,15 @@
<section class="menu-content-section" [attr.aria-labelledby]="titleId()">
<header class="menu-content-section__header">
<div class="menu-content-section__prefix">
<ng-content select="[menuContentPrefix]" />
</div>
<h2 class="menu-content-section__title" [id]="titleId()">
{{ title() | uppercase }}
</h2>
</header>
<div class="menu-content-section__body">
<ng-content />
</div>
</section>

View File

@@ -0,0 +1,53 @@
:host {
display: block;
width: 100%;
min-width: 0;
}
.menu-content-section {
width: 100%;
}
.menu-content-section__header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.25rem;
}
.menu-content-section__prefix:empty {
display: none;
}
.menu-content-section__title {
margin: 0;
color: #888888;
font-size: 1rem;
font-weight: 600;
line-height: 1.3;
}
.menu-content-section__body {
min-width: 0;
}
@media (max-width: 767.98px) {
.menu-content-section {
margin-inline: auto;
}
.menu-content-section__header {
position: relative;
justify-content: center;
}
.menu-content-section__prefix:not(:empty) {
position: absolute;
left: 0;
}
.menu-content-section__title {
width: 100%;
text-align: center;
}
}

View File

@@ -0,0 +1,58 @@
import { Component, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { describe, expect, it } from 'vitest';
import { Tenant } from '../../../../core/services/tenant.interface';
import { TenantService } from '../../../../core/services/tenant.service';
import { MenuContentSectionComponent } from './menu-content-section.component';
@Component({
imports: [MenuContentSectionComponent],
template: `
<app-menu-content-section menuCode="help.contact" titleId="contact-title">
<a menuContentPrefix href="/ayuda">Volver</a>
<p>Contenido proyectado</p>
</app-menu-content-section>
`,
})
class TestHostComponent {}
describe('MenuContentSectionComponent', () => {
it('renders an accessible title and projects its content', async () => {
const tenant = signal({
menues: [
{
id: 1,
code: 'help.contact',
label: 'Contacto del comercio',
parent_menu_code: 'help',
content_type: 'static',
route: '/ayuda/contacto',
submenues: [],
},
],
} as unknown as Tenant);
await TestBed.configureTestingModule({
imports: [TestHostComponent],
providers: [{ provide: TenantService, useValue: { tenant: tenant.asReadonly() } }],
}).compileComponents();
const fixture = TestBed.createComponent(TestHostComponent);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const section = element.querySelector('section');
expect(section?.getAttribute('aria-labelledby')).toBe('contact-title');
expect(element.querySelector('#contact-title')?.textContent?.trim()).toBe(
'CONTACTO DEL COMERCIO',
);
expect(element.querySelector('.menu-content-section__prefix a')?.textContent).toContain(
'Volver',
);
expect(element.querySelector('.menu-content-section__body')?.textContent).toContain(
'Contenido proyectado',
);
});
});

View File

@@ -0,0 +1,22 @@
import { UpperCasePipe } from '@angular/common';
import { Component, computed, inject, input } from '@angular/core';
import { findMenu } from '../../../../core/services/menu.utils';
import { TenantService } from '../../../../core/services/tenant.service';
@Component({
selector: 'app-menu-content-section',
imports: [UpperCasePipe],
templateUrl: './menu-content-section.component.html',
styleUrl: './menu-content-section.component.scss',
})
export class MenuContentSectionComponent {
private readonly tenantService = inject(TenantService);
readonly menuCode = input.required<string>();
readonly titleId = input.required<string>();
protected readonly title = computed(
() => findMenu(this.tenantService.tenant()?.menues ?? [], this.menuCode())?.label ?? '',
);
}

View File

@@ -0,0 +1,22 @@
<div class="menu-page-layout">
<aside class="menu-page-layout__sidebar">
<h1 class="menu-page-layout__title">{{ menu()?.label }}</h1>
<div class="menu-page-layout__divider"></div>
<nav class="menu-page-layout__nav" [attr.aria-label]="navigationLabel">
@for (submenu of submenues(); track submenu.code) {
<a
class="menu-page-layout__link"
[routerLink]="submenu.route"
routerLinkActive="menu-page-layout__link--active"
>
{{ submenu.label }}
</a>
}
</nav>
</aside>
<main class="menu-page-layout__content">
<router-outlet />
</main>
</div>

View File

@@ -0,0 +1,88 @@
:host {
display: block;
width: 100%;
}
.menu-page-layout {
display: grid;
grid-template-columns: minmax(190px, 250px) minmax(0, 1fr);
gap: clamp(3rem, 8vw, 6.25rem);
width: 100%;
&__sidebar,
&__content {
min-width: 0;
}
&__sidebar {
display: flex;
flex-direction: column;
}
&__title {
margin: 0 0 0.75rem;
color: #1a1a1a;
font-size: 1rem;
font-weight: 700;
line-height: 1.25;
text-transform: uppercase;
}
&__divider {
height: 1px;
margin-bottom: 1.25rem;
background: #dedede;
}
&__nav {
display: flex;
flex-direction: column;
gap: 1rem;
}
&__link {
color: #888888;
font-size: 0.875rem;
font-weight: 500;
line-height: 1.35;
text-decoration: none;
transition: color 150ms ease;
&:hover,
&:focus-visible {
color: #5a5a5a;
}
&:focus-visible {
border-radius: 2px;
outline: 2px solid var(--color-primary);
outline-offset: 3px;
}
&--active {
color: var(--color-primary);
font-weight: 600;
}
}
}
@media (max-width: 767.98px) {
.menu-page-layout {
grid-template-columns: 1fr;
gap: 2rem;
&__title {
text-align: center;
}
&__nav {
flex-flow: row wrap;
justify-content: center;
gap: 0.75rem 1.25rem;
}
&__content {
width: 100%;
}
}
}

View File

@@ -0,0 +1,136 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, provideRouter } from '@angular/router';
import { describe, expect, it } from 'vitest';
import { Tenant } from '../../../../core/services/tenant.interface';
import { TenantService } from '../../../../core/services/tenant.service';
import { MenuPageLayoutComponent } from './menu-page-layout.component';
function createTenant(): Tenant {
return {
id: 1,
codigo: 'test',
nombre: 'Test',
dominio: 'localhost',
primary_color: '#000000',
secondary_color: '#000000',
danger_color: '#000000',
success_color: '#000000',
header_bg_color: '#ffffff',
footer_bg_color: '#ffffff',
header_logo: '',
footer_logo: '',
categories: [],
menues: [
{
id: 1,
code: 'account',
label: 'Mi cuenta',
parent_menu_code: null,
content_type: 'dynamic',
route: '/mi-cuenta',
submenues: [
{
id: 2,
code: 'account.profile',
label: 'Datos personales',
parent_menu_code: 'account',
content_type: 'dynamic',
route: '/mi-cuenta/datos-personales',
submenues: [],
},
{
id: 3,
code: 'account.purchases',
label: 'Mis compras',
parent_menu_code: 'account',
content_type: 'dynamic',
route: '/mi-cuenta/compras',
submenues: [],
},
],
},
{
id: 4,
code: 'help',
label: 'Centro de ayuda',
parent_menu_code: null,
content_type: 'dynamic',
route: '/ayuda',
submenues: [
{
id: 5,
code: 'help.faq',
label: 'Consultas habituales',
parent_menu_code: 'help',
content_type: 'dynamic',
route: '/ayuda/preguntas-frecuentes',
submenues: [],
},
],
},
],
};
}
async function renderLayout(menuCode: string, navigationLabel: string): Promise<HTMLElement> {
const tenant = signal(createTenant());
await TestBed.configureTestingModule({
imports: [MenuPageLayoutComponent],
providers: [
provideRouter([]),
{
provide: ActivatedRoute,
useValue: {
snapshot: {
data: { menuCode, navigationLabel },
},
},
},
{
provide: TenantService,
useValue: { tenant: tenant.asReadonly() },
},
],
}).compileComponents();
const fixture = TestBed.createComponent(MenuPageLayoutComponent);
fixture.detectChanges();
return fixture.nativeElement as HTMLElement;
}
describe('MenuPageLayoutComponent', () => {
it('renders the configured account menu and its navigation label', async () => {
const element = await renderLayout('account', 'Secciones de mi cuenta');
const links = Array.from(
element.querySelectorAll<HTMLAnchorElement>('.menu-page-layout__link'),
);
expect(element.querySelector('.menu-page-layout__title')?.textContent?.trim()).toBe(
'Mi cuenta',
);
expect(element.querySelector('nav')?.getAttribute('aria-label')).toBe('Secciones de mi cuenta');
expect(links.map((link) => link.textContent?.trim())).toEqual([
'Datos personales',
'Mis compras',
]);
expect(links.map((link) => link.getAttribute('href'))).toEqual([
'/mi-cuenta/datos-personales',
'/mi-cuenta/compras',
]);
});
it('uses the same component for the help menu', async () => {
const element = await renderLayout('help', 'Secciones de ayuda');
expect(element.querySelector('.menu-page-layout__title')?.textContent?.trim()).toBe(
'Centro de ayuda',
);
expect(element.querySelector('.menu-page-layout__link')?.textContent?.trim()).toBe(
'Consultas habituales',
);
});
});

View File

@@ -0,0 +1,26 @@
import { Component, computed, inject } from '@angular/core';
import { ActivatedRoute, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { findMenu } from '../../../../core/services/menu.utils';
import { TenantService } from '../../../../core/services/tenant.service';
@Component({
selector: 'app-menu-page-layout',
imports: [RouterLink, RouterLinkActive, RouterOutlet],
templateUrl: './menu-page-layout.component.html',
styleUrl: './menu-page-layout.component.scss',
})
export class MenuPageLayoutComponent {
private readonly route = inject(ActivatedRoute);
private readonly tenantService = inject(TenantService);
protected readonly navigationLabel =
(this.route.snapshot.data['navigationLabel'] as string | undefined) ?? 'Secciones';
private readonly menuCode = this.route.snapshot.data['menuCode'] as string | undefined;
protected readonly menu = computed(() =>
findMenu(this.tenantService.tenant()?.menues ?? [], this.menuCode ?? ''),
);
protected readonly submenues = computed(() => this.menu()?.submenues ?? []);
}

View File

@@ -1,11 +0,0 @@
<div class="sidebar-container">
<h2 class="sidebar-title">{{ menu()?.label }}</h2>
<div class="divider"></div>
<nav class="sidebar-nav" aria-label="Secciones de mi cuenta">
@for (menu of submenues(); track menu.code) {
<a [routerLink]="menu.route" routerLinkActive="active" class="nav-link">
{{ menu.label }}
</a>
}
</nav>
</div>

View File

@@ -1,35 +0,0 @@
.sidebar-container {
display: flex;
flex-direction: column;
}
.sidebar-title {
font-size: 16px;
font-weight: bold;
color: #1a1a1a;
margin-bottom: 12px;
text-transform: uppercase;
}
.divider {
height: 1px;
background-color: #e0e0e0;
margin-bottom: 20px;
}
.sidebar-nav {
display: flex;
flex-direction: column;
gap: 16px;
}
.nav-link {
text-decoration: none;
color: #888;
font-size: 14px;
font-weight: 500;
transition: color 0.2s;
}
.nav-link:hover {
color: #5a5a5a;
}
.nav-link.active {
color: var(--color-primary); /* Blue color matching the image */
font-weight: 600;
}

View File

@@ -1,85 +0,0 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { describe, expect, it } from 'vitest';
import { Tenant } from '../../../../../../core/services/tenant.interface';
import { TenantService } from '../../../../../../core/services/tenant.service';
import { AccountSidebar } from './account-sidebar';
describe('AccountSidebar', () => {
it('renders only the account submenus configured for the tenant', async () => {
const tenant = signal<Tenant>({
id: 1,
codigo: 'test',
nombre: 'Test',
dominio: 'localhost',
primary_color: '#000000',
secondary_color: '#000000',
danger_color: '#000000',
success_color: '#000000',
header_bg_color: '#ffffff',
footer_bg_color: '#ffffff',
header_logo: '',
footer_logo: '',
categories: [],
menues: [
{
id: 1,
code: 'account',
label: 'Mi cuenta',
parent_menu_code: null,
content_type: 'dynamic',
route: '/mi-cuenta',
submenues: [
{
id: 2,
code: 'account.profile',
label: 'Datos personales',
parent_menu_code: 'account',
content_type: 'dynamic',
route: '/mi-cuenta/datos-personales',
submenues: [],
},
{
id: 3,
code: 'account.purchases',
label: 'Mis compras',
parent_menu_code: 'account',
content_type: 'dynamic',
route: '/mi-cuenta/compras',
submenues: [],
},
],
},
],
});
await TestBed.configureTestingModule({
imports: [AccountSidebar],
providers: [
provideRouter([]),
{
provide: TenantService,
useValue: { tenant: tenant.asReadonly() },
},
],
}).compileComponents();
const fixture = TestBed.createComponent(AccountSidebar);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const links = Array.from(element.querySelectorAll<HTMLAnchorElement>('.nav-link'));
expect(element.querySelector('.sidebar-title')?.textContent?.trim()).toBe('Mi cuenta');
expect(links.map((link) => link.textContent?.trim())).toEqual([
'Datos personales',
'Mis compras',
]);
expect(links.map((link) => link.getAttribute('href'))).toEqual([
'/mi-cuenta/datos-personales',
'/mi-cuenta/compras',
]);
});
});

View File

@@ -1,21 +0,0 @@
import { Component, computed, inject } from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router';
import { findMenu } from '../../../../../../core/services/menu.utils';
import { TenantService } from '../../../../../../core/services/tenant.service';
@Component({
selector: 'app-account-sidebar',
standalone: true,
imports: [RouterLink, RouterLinkActive],
templateUrl: './account-sidebar.html',
styleUrl: './account-sidebar.scss',
})
export class AccountSidebar {
private readonly tenantService = inject(TenantService);
protected readonly menu = computed(() =>
findMenu(this.tenantService.tenant()?.menues ?? [], 'account'),
);
protected readonly submenues = computed(() => this.menu()?.submenues ?? []);
}

View File

@@ -13,3 +13,13 @@
flex: 1; flex: 1;
display: block; display: block;
} }
@media (max-width: 767.98px) {
.form-actions {
flex-direction: column;
}
.action-btn {
width: 100%;
}
}

View File

@@ -29,7 +29,9 @@ export class PurchaseList implements OnInit {
try { try {
const tenantCode = this.tenantService.tenant()?.codigo || ''; const tenantCode = this.tenantService.tenant()?.codigo || '';
const response = await this.checkoutService.getPurchases(tenantCode, 'paid'); const response = await this.checkoutService
.withCustomLoading()
.getPurchases(tenantCode, 'paid');
const mappedPurchases = response.data.map((purchase: PurchaseSummaryResponse) => ({ const mappedPurchases = response.data.map((purchase: PurchaseSummaryResponse) => ({
id: purchase.id, id: purchase.id,
date: this.formatDate(purchase.created_at), date: this.formatDate(purchase.created_at),

View File

@@ -1,4 +1,7 @@
<div class="page-container"> <app-menu-content-section
<h2 class="page-title">DATOS PERSONALES</h2> class="account-page"
<app-profile-form></app-profile-form> menuCode="account.profile"
</div> titleId="profile-page-title"
>
<app-profile-form />
</app-menu-content-section>

View File

@@ -1,13 +1,10 @@
.page-container { :host {
display: flex; display: block;
flex-direction: column; width: 100%;
}
.account-page {
display: block;
width: 100%; width: 100%;
max-width: 600px; max-width: 600px;
} }
.page-title {
font-size: 15px;
font-weight: bold;
color: #A0A0A0;
margin-bottom: 24px;
}

View File

@@ -1,10 +1,11 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { MenuContentSectionComponent } from '../../../../components/menu-content-section/menu-content-section.component';
import { ProfileForm } from '../../components/profile-form/profile-form'; import { ProfileForm } from '../../components/profile-form/profile-form';
@Component({ @Component({
selector: 'app-profile-page', selector: 'app-profile-page',
standalone: true, standalone: true,
imports: [ProfileForm], imports: [ProfileForm, MenuContentSectionComponent],
templateUrl: './profile-page.html', templateUrl: './profile-page.html',
styleUrl: './profile-page.scss', styleUrl: './profile-page.scss',
}) })

View File

@@ -1,26 +1,35 @@
<div class="page-container"> <app-menu-content-section
<div class="d-flex align-items-center mb-4"> class="account-page"
<a routerLink="../" class="text-decoration-none " > menuCode="account.purchases"
<i class="bi bi-arrow-left fs-5"></i> titleId="purchase-detail-page-title"
</a> >
<h2 class="page-title m-0">MIS COMPRAS</h2> <a
</div> menuContentPrefix
routerLink="../"
class="purchase-back-link text-decoration-none"
aria-label="Volver a mis compras"
>
<i class="bi bi-arrow-left fs-5" aria-hidden="true"></i>
</a>
@if (isLoading()) { @if (isLoading()) {
<p class="purchase-loading">Cargando detalle de compra...</p> <p class="purchase-loading">Cargando detalle de compra...</p>
} @else if (purchase(); as purchase) { } @else if (purchase(); as purchase) {
<div class="d-flex justify-content-between align-items-center mb-4 pb-3" style="border-bottom: 1px solid #dddddd;"> <div
class="d-flex justify-content-between align-items-center mb-4 pb-3"
style="border-bottom: 1px solid #dddddd"
>
<div class="purchase-info"> <div class="purchase-info">
<span class="purchase-id">Compra {{ purchase.id }}.</span> <span class="purchase-id">Compra {{ purchase.id }}.</span>
<span class="purchase-date">Fecha de compra: {{ purchase.date }}</span> <span class="purchase-date">Fecha de compra: {{ purchase.date }}</span>
</div> </div>
<div class="purchase-total "> <div class="purchase-total">
<span class="purchase-total-label">Total:</span> <span class="purchase-total-label">Total:</span>
<span class="purchase-total-value">${{ purchase.total }}</span> <span class="purchase-total-value">${{ purchase.total }}</span>
</div> </div>
</div> </div>
<p style="font-size: 12px; color: #666666; margin:0;">Productos:</p> <p style="font-size: 12px; color: #666666; margin: 0">Productos:</p>
<div class="d-flex flex-column"> <div class="d-flex flex-column">
@for (item of purchase.items; track item.id) { @for (item of purchase.items; track item.id) {
@@ -30,4 +39,4 @@
} }
</div> </div>
} }
</div> </app-menu-content-section>

View File

@@ -1,14 +1,21 @@
.page-container { :host {
display: flex; display: block;
flex-direction: column; width: 100%;
}
.account-page {
display: block;
width: 100%; width: 100%;
max-width: 600px; max-width: 600px;
} }
.page-title { .purchase-back-link {
font-size: 15px; color: #888888;
font-weight: bold;
color: #A0A0A0; &:hover,
&:focus-visible {
color: var(--color-primary);
}
} }
.purchase-info { .purchase-info {

View File

@@ -8,6 +8,7 @@ import {
} from '../../../../../../core/services/checkout.service'; } from '../../../../../../core/services/checkout.service';
import { TenantService } from '../../../../../../core/services/tenant.service'; import { TenantService } from '../../../../../../core/services/tenant.service';
import { ToastService } from '../../../../../../core/services/toast.service'; import { ToastService } from '../../../../../../core/services/toast.service';
import { MenuContentSectionComponent } from '../../../../components/menu-content-section/menu-content-section.component';
import { PurchaseItem, PurchaseItemViewModel } from '../../components/purchase-item/purchase-item'; import { PurchaseItem, PurchaseItemViewModel } from '../../components/purchase-item/purchase-item';
type PurchaseDetailViewModel = { type PurchaseDetailViewModel = {
@@ -20,7 +21,7 @@ type PurchaseDetailViewModel = {
@Component({ @Component({
selector: 'app-purchase-detail-page', selector: 'app-purchase-detail-page',
standalone: true, standalone: true,
imports: [CommonModule, RouterLink, PurchaseItem], imports: [CommonModule, RouterLink, PurchaseItem, MenuContentSectionComponent],
templateUrl: './purchase-detail-page.html', templateUrl: './purchase-detail-page.html',
styleUrl: './purchase-detail-page.scss', styleUrl: './purchase-detail-page.scss',
}) })
@@ -44,7 +45,9 @@ export class PurchaseDetailPage implements OnInit {
} }
try { try {
const response = await this.checkoutService.getPurchase(tenant.codigo, purchaseId); const response = await this.checkoutService
.withCustomLoading()
.getPurchase(tenant.codigo, purchaseId);
this.purchase.set({ this.purchase.set({
id: response.id, id: response.id,

View File

@@ -1,4 +1,7 @@
<div class="page-container"> <app-menu-content-section
<h2 class="page-title">MIS COMPRAS</h2> class="account-page"
<app-purchase-list></app-purchase-list> menuCode="account.purchases"
</div> titleId="purchases-page-title"
>
<app-purchase-list />
</app-menu-content-section>

View File

@@ -1,12 +1,10 @@
.page-container { :host {
display: flex; display: block;
flex-direction: column; width: 100%;
}
.account-page {
display: block;
width: 100%; width: 100%;
max-width: 600px; max-width: 600px;
} }
.page-title {
font-size: 15px;
font-weight: bold;
color: #A0A0A0;
}

View File

@@ -1,10 +1,11 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { MenuContentSectionComponent } from '../../../../components/menu-content-section/menu-content-section.component';
import { PurchaseList } from '../../components/purchase-list/purchase-list'; import { PurchaseList } from '../../components/purchase-list/purchase-list';
@Component({ @Component({
selector: 'app-purchases-page', selector: 'app-purchases-page',
standalone: true, standalone: true,
imports: [PurchaseList], imports: [PurchaseList, MenuContentSectionComponent],
templateUrl: './purchases-page.html', templateUrl: './purchases-page.html',
styleUrl: './purchases-page.scss', styleUrl: './purchases-page.scss',
}) })

View File

@@ -1,7 +1,7 @@
import { HttpClient } from '@angular/common/http';
import { inject, Injectable } from '@angular/core'; import { inject, Injectable } from '@angular/core';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import { BaseApiService } from '../../../../../../core/services/base-api.service';
import { TenantService } from '../../../../../../core/services/tenant.service'; import { TenantService } from '../../../../../../core/services/tenant.service';
export interface TicketResponse { export interface TicketResponse {
@@ -21,13 +21,14 @@ export interface TicketResponse {
} }
@Injectable() @Injectable()
export class TicketService { export class TicketService extends BaseApiService {
private readonly http = inject(HttpClient);
private readonly tenantService = inject(TenantService); private readonly tenantService = inject(TenantService);
getTickets(): Promise<TicketResponse[]> { getTickets(): Promise<TicketResponse[]> {
return firstValueFrom( return firstValueFrom(
this.http.get<{ data: TicketResponse[] }>(`${this.tenantService.getTenantApiUrl()}/tickets`), this.http.get<{ data: TicketResponse[] }>(
`${this.tenantService.getTenantApiUrl()}/tickets`,
),
).then((response) => response.data ?? []); ).then((response) => response.data ?? []);
} }
@@ -36,7 +37,9 @@ export class TicketService {
this.http.post( this.http.post(
`${this.tenantService.getTenantApiUrl()}/tickets/pdf`, `${this.tenantService.getTenantApiUrl()}/tickets/pdf`,
{ ticket_ids: ticketIds }, { ticket_ids: ticketIds },
{ responseType: 'blob' }, {
responseType: 'blob',
},
), ),
); );
} }

View File

@@ -1,6 +1,8 @@
<section class="tickets-page"> <app-menu-content-section
<h1 class="tickets-page__title">MIS TICKETS</h1> class="tickets-page"
menuCode="account.tickets"
titleId="tickets-page-title"
>
<div class="tickets-list" [attr.aria-busy]="isLoading() || isGeneratingPdf()"> <div class="tickets-list" [attr.aria-busy]="isLoading() || isGeneratingPdf()">
@if (isGeneratingPdf()) { @if (isGeneratingPdf()) {
<div class="tickets-list__generating" role="status" aria-live="polite"> <div class="tickets-list__generating" role="status" aria-live="polite">
@@ -106,4 +108,4 @@
<p class="tickets-list__empty">Aún no tenés tickets disponibles.</p> <p class="tickets-list__empty">Aún no tenés tickets disponibles.</p>
} }
</div> </div>
</section> </app-menu-content-section>

View File

@@ -1,15 +1,9 @@
.tickets-page { .tickets-page {
display: block;
width: 100%; width: 100%;
max-width: 720px; max-width: 720px;
} }
.tickets-page__title {
margin: 0 0 20px;
color: #a0a0a0;
font-size: 15px;
font-weight: 700;
}
.tickets-list { .tickets-list {
position: relative; position: relative;
} }
@@ -206,10 +200,6 @@
} }
@media (max-width: 767.98px) { @media (max-width: 767.98px) {
.tickets-page__title {
margin-top: 24px;
}
.tickets-list__skeleton { .tickets-list__skeleton {
grid-template-columns: auto minmax(0, 1fr) auto; grid-template-columns: auto minmax(0, 1fr) auto;
gap: 10px; gap: 10px;

View File

@@ -1,13 +1,21 @@
import { signal } from '@angular/core';
import { By } from '@angular/platform-browser'; import { By } from '@angular/platform-browser';
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { TenantService } from '../../../../../../core/services/tenant.service';
import { ToastService } from '../../../../../../core/services/toast.service'; import { ToastService } from '../../../../../../core/services/toast.service';
import { ModalService } from '../../../../../../core/services/modal.service'; import { ModalService } from '../../../../../../core/services/modal.service';
import { TicketComponent } from './components/ticket/ticket.component'; import { TicketComponent } from './components/ticket/ticket.component';
import { TicketResponse, TicketService } from './ticket.service'; import { TicketResponse, TicketService } from './ticket.service';
import { TicketsPage } from './tickets-page'; import { TicketsPage } from './tickets-page';
function withCustomLoading<T extends object>(service: T): T & { withCustomLoading: () => T } {
return Object.assign(service, {
withCustomLoading: () => service,
});
}
const ticket = (overrides: Partial<TicketResponse>): TicketResponse => ({ const ticket = (overrides: Partial<TicketResponse>): TicketResponse => ({
id: 1, id: 1,
tenant_code: 'tenant', tenant_code: 'tenant',
@@ -26,6 +34,31 @@ const ticket = (overrides: Partial<TicketResponse>): TicketResponse => ({
}); });
describe('TicketsPage', () => { describe('TicketsPage', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
{
provide: TenantService,
useValue: {
tenant: signal({
menues: [
{
id: 4,
code: 'account.tickets',
label: 'Entradas disponibles',
parent_menu_code: 'account',
content_type: 'dynamic',
route: '/mi-cuenta/tickets',
submenues: [],
},
],
}).asReadonly(),
},
},
],
});
});
it('shares the generated PDF as a file without text that Windows could prioritize', async () => { it('shares the generated PDF as a file without text that Windows could prioritize', async () => {
const share = vi.fn().mockResolvedValue(undefined); const share = vi.fn().mockResolvedValue(undefined);
const canShare = vi.fn().mockReturnValue(true); const canShare = vi.fn().mockReturnValue(true);
@@ -40,10 +73,10 @@ describe('TicketsPage', () => {
providers: [ providers: [
{ {
provide: TicketService, provide: TicketService,
useValue: { useValue: withCustomLoading({
getTickets: () => Promise.resolve([ticket({})]), getTickets: () => Promise.resolve([ticket({})]),
downloadPdf: () => Promise.resolve(new Blob(['pdf-content'])), downloadPdf: () => Promise.resolve(new Blob(['pdf-content'])),
}, }),
}, },
], ],
}, },
@@ -84,11 +117,11 @@ describe('TicketsPage', () => {
const createObjectUrl = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:ticket'); const createObjectUrl = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:ticket');
const revokeObjectUrl = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined); const revokeObjectUrl = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined);
let downloadedFilename = ''; let downloadedFilename = '';
const linkClick = vi const linkClick = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (
.spyOn(HTMLAnchorElement.prototype, 'click') this: HTMLAnchorElement,
.mockImplementation(function (this: HTMLAnchorElement) { ) {
downloadedFilename = this.download; downloadedFilename = this.download;
}); });
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [TicketsPage], imports: [TicketsPage],
@@ -99,10 +132,10 @@ describe('TicketsPage', () => {
providers: [ providers: [
{ {
provide: TicketService, provide: TicketService,
useValue: { useValue: withCustomLoading({
getTickets: () => Promise.resolve([ticket({})]), getTickets: () => Promise.resolve([ticket({})]),
downloadPdf, downloadPdf,
}, }),
}, },
], ],
}, },
@@ -150,7 +183,10 @@ describe('TicketsPage', () => {
.overrideComponent(TicketsPage, { .overrideComponent(TicketsPage, {
set: { set: {
providers: [ providers: [
{ provide: TicketService, useValue: { getTickets: () => Promise.resolve([]) } }, {
provide: TicketService,
useValue: withCustomLoading({ getTickets: () => Promise.resolve([]) }),
},
], ],
}, },
}) })
@@ -183,7 +219,10 @@ describe('TicketsPage', () => {
.overrideComponent(TicketsPage, { .overrideComponent(TicketsPage, {
set: { set: {
providers: [ providers: [
{ provide: TicketService, useValue: { getTickets: () => Promise.resolve(tickets) } }, {
provide: TicketService,
useValue: withCustomLoading({ getTickets: () => Promise.resolve(tickets) }),
},
], ],
}, },
}) })
@@ -236,7 +275,9 @@ describe('TicketsPage', () => {
providers: [ providers: [
{ {
provide: TicketService, provide: TicketService,
useValue: { getTickets: () => Promise.resolve([ticket({})]) }, useValue: withCustomLoading({
getTickets: () => Promise.resolve([ticket({})]),
}),
}, },
], ],
}, },

View File

@@ -3,12 +3,13 @@ import { Component, computed, inject, OnInit, signal } from '@angular/core';
import { ModalService } from '../../../../../../core/services/modal.service'; import { ModalService } from '../../../../../../core/services/modal.service';
import { ToastService } from '../../../../../../core/services/toast.service'; import { ToastService } from '../../../../../../core/services/toast.service';
import { IconButtonComponent } from '../../../../../../shared/components/icon-button/icon-button.component'; import { IconButtonComponent } from '../../../../../../shared/components/icon-button/icon-button.component';
import { MenuContentSectionComponent } from '../../../../components/menu-content-section/menu-content-section.component';
import { TicketComponent } from './components/ticket/ticket.component'; import { TicketComponent } from './components/ticket/ticket.component';
import { TicketResponse, TicketService } from './ticket.service'; import { TicketResponse, TicketService } from './ticket.service';
@Component({ @Component({
selector: 'app-tickets-page', selector: 'app-tickets-page',
imports: [IconButtonComponent, TicketComponent], imports: [IconButtonComponent, TicketComponent, MenuContentSectionComponent],
providers: [TicketService], providers: [TicketService],
templateUrl: './tickets-page.html', templateUrl: './tickets-page.html',
styleUrl: './tickets-page.scss', styleUrl: './tickets-page.scss',
@@ -35,7 +36,7 @@ export class TicketsPage implements OnInit {
async ngOnInit(): Promise<void> { async ngOnInit(): Promise<void> {
try { try {
this.tickets.set(await this.ticketService.getTickets()); this.tickets.set(await this.ticketService.withCustomLoading().getTickets());
} catch { } catch {
this.toastService.danger('Hubo un error al cargar los tickets'); this.toastService.danger('Hubo un error al cargar los tickets');
} finally { } finally {
@@ -151,7 +152,9 @@ export class TicketsPage implements OnInit {
this.isGeneratingPdf.set(true); this.isGeneratingPdf.set(true);
try { try {
return await this.ticketService.downloadPdf(tickets.map((ticket) => ticket.id)); return await this.ticketService
.withCustomLoading()
.downloadPdf(tickets.map((ticket) => ticket.id));
} catch { } catch {
this.toastService.danger('No se pudo generar el PDF de los tickets.'); this.toastService.danger('No se pudo generar el PDF de los tickets.');
return null; return null;

View File

@@ -1,6 +0,0 @@
<div class="sidebar-layout">
<app-account-sidebar class="account-sidebar"></app-account-sidebar>
<div class="account-content">
<router-outlet></router-outlet>
</div>
</div>

View File

@@ -1,17 +0,0 @@
.sidebar-layout {
display: flex;
margin: 0 auto;
gap: 100px;
min-height: calc(100vh - 100px);
}
.account-sidebar {
width: 250px;
flex-shrink: 0;
}
.account-content {
flex: 1;
}
label {
font-size: 13px !important;
}

View File

@@ -1,12 +0,0 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { AccountSidebar } from '../components/account-sidebar/account-sidebar';
@Component({
selector: 'app-sidebar-layout',
standalone: true,
imports: [RouterOutlet, AccountSidebar],
templateUrl: './sidebar-layout.html',
styleUrl: './sidebar-layout.scss',
})
export class SidebarLayout {}

View File

@@ -1,8 +1,6 @@
<section class="category-items" aria-labelledby="category-items-title"> <section class="category-items" aria-labelledby="category-items-title">
@if (error()) { @if (error()) {
<p class="alert text-center mb-0 category-items__alert-error">{{ error() }}</p> <p class="alert text-center mb-0 category-items__alert-error">{{ error() }}</p>
} @else if (loading()) {
<p class="alert alert-light border text-center mb-0">Cargando productos...</p>
} @else if (results(); as categoryResults) { } @else if (results(); as categoryResults) {
<header class="category-items__header mb-5"> <header class="category-items__header mb-5">
<h1 id="category-items-title" class="category-items__title mb-0"> <h1 id="category-items-title" class="category-items__title mb-0">
@@ -19,7 +17,6 @@
[layout]="categoryResults.layout" [layout]="categoryResults.layout"
[groupLayout]="paginatedLayout" [groupLayout]="paginatedLayout"
[items]="categoryResults" [items]="categoryResults"
[loading]="loading()"
(buy)="onBuyProduct($event)" (buy)="onBuyProduct($event)"
(addToCart)="onAddToCart($event)" (addToCart)="onAddToCart($event)"
(pageChange)="onPageChange($event)" (pageChange)="onPageChange($event)"

View File

@@ -69,7 +69,9 @@ describe('CategoryItemsPageComponent', () => {
}, },
{ {
provide: CatalogService, provide: CatalogService,
useValue: { getCategoryItems }, useValue: {
getCategoryItems,
},
}, },
{ {
provide: TenantService, provide: TenantService,

View File

@@ -50,7 +50,6 @@ export class CategoryItemsPageComponent {
private readonly toastService = inject(ToastService); private readonly toastService = inject(ToastService);
protected readonly results = signal<CategoryItemsResponse | null>(null); protected readonly results = signal<CategoryItemsResponse | null>(null);
protected readonly loading = signal(true);
protected readonly error = signal<string | null>(null); protected readonly error = signal<string | null>(null);
protected readonly creatingDirectPurchase = signal(false); protected readonly creatingDirectPurchase = signal(false);
protected readonly paginatedLayout: CatalogGroupLayout = 'paginated'; protected readonly paginatedLayout: CatalogGroupLayout = 'paginated';
@@ -71,7 +70,6 @@ export class CategoryItemsPageComponent {
tap(() => { tap(() => {
this.results.set(null); this.results.set(null);
this.error.set(null); this.error.set(null);
this.loading.set(true);
}), }),
switchMap(({ categoryId, page }) => { switchMap(({ categoryId, page }) => {
if (categoryId === 0) { if (categoryId === 0) {
@@ -92,7 +90,6 @@ export class CategoryItemsPageComponent {
) )
.subscribe((results) => { .subscribe((results) => {
this.results.set(results); this.results.set(results);
this.loading.set(false);
}); });
} }

View File

@@ -20,6 +20,6 @@
display: block; display: block;
width: 100%; width: 100%;
flex: 1 1 290px; flex: 1 1 290px;
max-width: 290px;
} }
} }

View File

@@ -6,6 +6,15 @@
@media (max-width: 900px) { @media (max-width: 900px) {
grid-template-columns: 1fr; grid-template-columns: 1fr;
.checkout-page__cart-col {
order: 1;
}
.checkout-page__stepper-col,
.checkout-page__editing-notice {
order: 2;
}
} }
&__stepper-col { &__stepper-col {

View File

@@ -22,6 +22,7 @@ describe('CheckoutPageComponent payment validation', () => {
generatePaymentIntent: ReturnType<typeof vi.fn>; generatePaymentIntent: ReturnType<typeof vi.fn>;
getPurchase: ReturnType<typeof vi.fn>; getPurchase: ReturnType<typeof vi.fn>;
submitPurchaseForReview: ReturnType<typeof vi.fn>; submitPurchaseForReview: ReturnType<typeof vi.fn>;
withCustomLoading: ReturnType<typeof vi.fn>;
}; };
let cartServiceStub: { let cartServiceStub: {
cart: ReturnType<typeof signal>; cart: ReturnType<typeof signal>;
@@ -60,7 +61,9 @@ describe('CheckoutPageComponent payment validation', () => {
}), }),
getPurchase: vi.fn().mockResolvedValue({ status: 'pending_payment' }), getPurchase: vi.fn().mockResolvedValue({ status: 'pending_payment' }),
submitPurchaseForReview: vi.fn().mockResolvedValue({ status: 'in_review' }), submitPurchaseForReview: vi.fn().mockResolvedValue({ status: 'in_review' }),
withCustomLoading: vi.fn(),
}; };
checkoutServiceStub.withCustomLoading.mockReturnValue(checkoutServiceStub);
cartServiceStub = { cartServiceStub = {
cart: signal({ cart: signal({
id: 10, id: 10,
@@ -191,24 +194,25 @@ describe('CheckoutPageComponent payment validation', () => {
expect(checkoutServiceStub.getPurchase).not.toHaveBeenCalled(); expect(checkoutServiceStub.getPurchase).not.toHaveBeenCalled();
}); });
it.each(['created', 'pending_payment', 'in_review'])( it('checks a transfer once and redirects to purchase status while pending', async () => {
'shows the payment error when a transfer remains %s', const { component } = createComponent();
async (status) => { component.selectedPaymentMethod.set('transfer');
checkoutServiceStub.submitPurchaseForReview.mockResolvedValue({ status });
const { component } = createComponent();
component.selectedPaymentMethod.set('transfer');
await component.onComplete(); await component.onComplete();
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(1);
expect(component.transferValidationStatus()).toBe('pending');
expect(cartServiceStub.clearCart).not.toHaveBeenCalled();
expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25]);
expect(checkoutServiceStub.submitPurchaseForReview).toHaveBeenCalledWith('tenant-test', 25); await vi.advanceTimersByTimeAsync(30_000);
expect(component.transferValidationStatus()).toBe('error'); expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(1);
expect(cartServiceStub.clearCart).not.toHaveBeenCalled();
expect(routerStub.navigate).not.toHaveBeenCalled();
},
);
it('submits a paid transfer and navigates to its confirmation page', async () => { await component.onComplete();
checkoutServiceStub.submitPurchaseForReview.mockResolvedValue({ status: 'paid' }); expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(1);
});
it('navigates after a transfer is confirmed as paid', async () => {
checkoutServiceStub.getPurchase.mockResolvedValue({ status: 'paid' });
const { component } = createComponent(); const { component } = createComponent();
component.selectedPaymentMethod.set('transfer'); component.selectedPaymentMethod.set('transfer');
@@ -362,7 +366,11 @@ describe('CheckoutPageComponent payment validation', () => {
expect(component.checkoutStepIndex()).toBe(1); expect(component.checkoutStepIndex()).toBe(1);
expect(component.selectedPaymentMethod()).toBe('qr'); expect(component.selectedPaymentMethod()).toBe('qr');
expect(checkoutServiceStub.generatePaymentIntent).toHaveBeenCalledWith('tenant-test', 25, 'qr'); expect(checkoutServiceStub.generatePaymentIntent).toHaveBeenCalledWith(
'tenant-test',
25,
'qr',
);
expect(component.qrData()).toBe('qr-value'); expect(component.qrData()).toBe('qr-value');
expect(component.qrPaymentStatus()).toBe('waiting'); expect(component.qrPaymentStatus()).toBe('waiting');
}); });

View File

@@ -343,11 +343,9 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
this.isGeneratingIntent.set(true); this.isGeneratingIntent.set(true);
try { try {
const response = await this.checkoutService.generatePaymentIntent( const response = await this.checkoutService
tenant.codigo, .withCustomLoading()
purchaseId, .generatePaymentIntent(tenant.codigo, purchaseId, method);
method,
);
if (method === 'qr' && response.qr_data?.qr_code) { if (method === 'qr' && response.qr_data?.qr_code) {
if (requestId !== this.paymentMethodRequestId || this.selectedPaymentMethod() !== 'qr') { if (requestId !== this.paymentMethodRequestId || this.selectedPaymentMethod() !== 'qr') {
@@ -381,12 +379,9 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
this.transferValidationStatus.set('idle'); this.transferValidationStatus.set('idle');
this.isGeneratingIntent.set(true); this.isGeneratingIntent.set(true);
try { try {
const response = await this.checkoutService.generatePaymentIntent( const response = await this.checkoutService
tenant.codigo, .withCustomLoading()
purchaseId, .generatePaymentIntent(tenant.codigo, purchaseId, 'transfer', dni);
'transfer',
dni,
);
if (response.transfer_data) { if (response.transfer_data) {
this.markPurchasePendingPayment(); this.markPurchasePendingPayment();
@@ -438,10 +433,9 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
this.transferValidationStatus.set('checking'); this.transferValidationStatus.set('checking');
try { try {
const purchase = await this.checkoutService.submitPurchaseForReview( const purchase = await this.checkoutService
tenant.codigo, .withCustomLoading()
purchaseId, .submitPurchaseForReview(tenant.codigo, purchaseId);
);
if (purchase.status === 'paid') { if (purchase.status === 'paid') {
this.navigateToPurchaseStatus(purchaseId); this.navigateToPurchaseStatus(purchaseId);
@@ -494,7 +488,9 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
this.isCheckingQrPayment.set(true); this.isCheckingQrPayment.set(true);
try { try {
const purchase = await this.checkoutService.getPurchase(tenant.codigo, purchaseId); const purchase = await this.checkoutService
.withCustomLoading()
.getPurchase(tenant.codigo, purchaseId);
if (runId !== this.qrPollingRunId) { if (runId !== this.qrPollingRunId) {
return; return;
@@ -575,7 +571,9 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
} }
try { try {
const purchase = await this.checkoutService.getPurchase(tenant.codigo, purchaseId); const purchase = await this.checkoutService
.withCustomLoading()
.getPurchase(tenant.codigo, purchaseId);
if ( if (
purchase.status === 'in_review' || purchase.status === 'in_review' ||

View File

@@ -34,7 +34,7 @@
</div> </div>
</section> </section>
<section class="payment-panel d-flex justify-content-end" aria-live="polite"> <section class="payment-panel" aria-live="polite">
@if (isGeneratingIntent()) { @if (isGeneratingIntent()) {
<div class="payment-panel__card" style="text-align: center; padding: 2rem"> <div class="payment-panel__card" style="text-align: center; padding: 2rem">
<h3 class="payment-panel__title">Cargando información de pago...</h3> <h3 class="payment-panel__title">Cargando información de pago...</h3>

View File

@@ -32,6 +32,11 @@
.payment-methods { .payment-methods {
min-width: 0; min-width: 0;
@media (max-width: 760px) {
width: min(100%, 300px);
justify-self: center;
}
&__title { &__title {
margin: 0 0 1.75rem; margin: 0 0 1.75rem;
color: #8a8a8a; color: #8a8a8a;
@@ -109,7 +114,22 @@
} }
.payment-panel { .payment-panel {
display: flex;
justify-content: flex-end;
min-width: 0; min-width: 0;
> app-checkout-payment-qr,
> app-checkout-payment-transfer,
> app-checkout-payment-telepagos {
display: block;
width: 100%;
max-width: 320px;
}
@media (max-width: 760px) {
justify-content: center;
}
&__card { &__card {
width: 100%; width: 100%;
max-width: 320px; max-width: 320px;

View File

@@ -1,16 +0,0 @@
<aside class="help-sidebar">
<h1 class="help-sidebar__title">{{ menu()?.label }}</h1>
<div class="help-sidebar__divider"></div>
<nav class="help-sidebar__nav" aria-label="Secciones de ayuda">
@for (menu of submenues(); track menu.code) {
<a
class="help-sidebar__link"
[routerLink]="menu.route"
routerLinkActive="help-sidebar__link--active"
>
{{ menu.label }}
</a>
}
</nav>
</aside>

View File

@@ -1,57 +0,0 @@
.help-sidebar {
display: flex;
flex-direction: column;
}
.help-sidebar__title {
margin: 0 0 0.75rem;
color: #1a1a1a;
font-size: 1rem;
font-weight: 700;
line-height: 1.25;
text-transform: uppercase;
}
.help-sidebar__divider {
height: 1px;
margin-bottom: 1.25rem;
background: #dedede;
}
.help-sidebar__nav {
display: flex;
flex-direction: column;
gap: 1rem;
}
.help-sidebar__link {
color: #888888;
font-size: 0.875rem;
font-weight: 500;
line-height: 1.35;
text-decoration: none;
transition: color 150ms ease;
&:hover,
&:focus-visible {
color: #5a5a5a;
}
&:focus-visible {
border-radius: 2px;
outline: 2px solid var(--color-primary);
outline-offset: 3px;
}
}
.help-sidebar__link--active {
color: var(--color-primary);
font-weight: 600;
}
@media (max-width: 767.98px) {
.help-sidebar__nav {
flex-flow: row wrap;
gap: 0.75rem 1.25rem;
}
}

View File

@@ -1,72 +0,0 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { describe, expect, it } from 'vitest';
import { Tenant } from '../../../../../../core/services/tenant.interface';
import { TenantService } from '../../../../../../core/services/tenant.service';
import { HelpSidebar } from './help-sidebar';
describe('HelpSidebar', () => {
it('renders the parent and submenu labels supplied by the tenant', async () => {
const tenant = signal<Tenant>({
id: 1,
codigo: 'test',
nombre: 'Test',
dominio: 'localhost',
primary_color: '#000000',
secondary_color: '#000000',
danger_color: '#000000',
success_color: '#000000',
header_bg_color: '#ffffff',
footer_bg_color: '#ffffff',
header_logo: '',
footer_logo: '',
categories: [],
menues: [
{
id: 1,
code: 'help',
label: 'Centro de ayuda',
parent_menu_code: null,
content_type: 'dynamic',
route: '/ayuda',
submenues: [
{
id: 2,
code: 'help.faq',
label: 'Consultas habituales',
parent_menu_code: 'help',
content_type: 'dynamic',
route: '/ayuda/preguntas-frecuentes',
submenues: [],
},
],
},
],
});
await TestBed.configureTestingModule({
imports: [HelpSidebar],
providers: [
provideRouter([]),
{
provide: TenantService,
useValue: { tenant: tenant.asReadonly() },
},
],
}).compileComponents();
const fixture = TestBed.createComponent(HelpSidebar);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('.help-sidebar__title')?.textContent?.trim()).toBe(
'Centro de ayuda',
);
expect(element.querySelector('.help-sidebar__link')?.textContent?.trim()).toBe(
'Consultas habituales',
);
});
});

View File

@@ -1,20 +0,0 @@
import { Component, computed, inject } from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router';
import { findMenu } from '../../../../../../core/services/menu.utils';
import { TenantService } from '../../../../../../core/services/tenant.service';
@Component({
selector: 'app-help-sidebar',
imports: [RouterLink, RouterLinkActive],
templateUrl: './help-sidebar.html',
styleUrl: './help-sidebar.scss',
})
export class HelpSidebar {
private readonly tenantService = inject(TenantService);
protected readonly menu = computed(() =>
findMenu(this.tenantService.tenant()?.menues ?? [], 'help'),
);
protected readonly submenues = computed(() => this.menu()?.submenues ?? []);
}

View File

@@ -1,53 +1,53 @@
<section class="contact-page" aria-labelledby="contact-page-title"> <app-menu-content-section menuCode="help.contact" titleId="contact-page-title">
<div class="contact-page__details"> <div class="contact-page">
<h2 id="contact-page-title" class="contact-page__title">CONTACTO</h2> <div class="contact-page__details">
@if (contact().whatsappUrl && contact().whatsappLabel) {
<a
class="contact-page__contact-link"
[href]="contact().whatsappUrl"
target="_blank"
rel="noopener noreferrer"
>
<i class="fa-brands fa-whatsapp" aria-hidden="true"></i>
<span>{{ contact().whatsappLabel }}</span>
</a>
}
@if (contact().whatsappUrl && contact().whatsappLabel) { @if (contact().phone; as phone) {
<a <a class="contact-page__contact-link" [href]="phoneUrl(phone)">
class="contact-page__contact-link" <i class="fa-solid fa-phone" aria-hidden="true"></i>
[href]="contact().whatsappUrl" <span>{{ phone }}</span>
target="_blank" </a>
rel="noopener noreferrer" }
>
<i class="fa-brands fa-whatsapp" aria-hidden="true"></i>
<span>{{ contact().whatsappLabel }}</span>
</a>
}
@if (contact().phone; as phone) { @for (location of contact().locations; track location.id) {
<a class="contact-page__contact-link" [href]="phoneUrl(phone)"> <article class="contact-page__location">
<i class="fa-solid fa-phone" aria-hidden="true"></i> <h3 class="contact-page__location-title">
<span>{{ phone }}</span> <i class="fa-solid fa-location-dot" aria-hidden="true"></i>
</a> <span>{{ location.label }}</span>
} </h3>
@for (location of contact().locations; track location.id) { <ul class="contact-page__addresses">
<article class="contact-page__location"> @for (address of location.addresses; track address.id) {
<h3 class="contact-page__location-title"> <li>{{ address.address }}</li>
<i class="fa-solid fa-location-dot" aria-hidden="true"></i> }
<span>{{ location.label }}</span> </ul>
</h3> </article>
}
<ul class="contact-page__addresses"> @if (!contact().whatsappUrl && !contact().phone && contact().locations.length === 0) {
@for (address of location.addresses; track address.id) { <p class="contact-page__empty">No hay datos de contacto disponibles.</p>
<li>{{ address.address }}</li> }
} </div>
</ul>
</article>
}
@if (!contact().whatsappUrl && !contact().phone && contact().locations.length === 0) { @if (contact().mapLocations.length > 0) {
<p class="contact-page__empty">No hay datos de contacto disponibles.</p> <app-location-map
class="contact-page__map"
[locations]="contact().mapLocations"
[markerColor]="markerColor()"
mapStyle="minimal-light"
ariaLabel="Mapa de sucursales y puntos de atención"
/>
} }
</div> </div>
</app-menu-content-section>
@if (contact().mapLocations.length > 0) {
<app-location-map
class="contact-page__map"
[locations]="contact().mapLocations"
[markerColor]="markerColor()"
mapStyle="minimal-light"
ariaLabel="Mapa de sucursales y puntos de atención"
/>
}
</section>

View File

@@ -20,14 +20,6 @@
align-items: flex-start; align-items: flex-start;
} }
.contact-page__title {
margin: 0 0 1.25rem;
color: inherit;
font-size: 1rem;
font-weight: 400;
line-height: 1.3;
}
.contact-page__contact-link { .contact-page__contact-link {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -122,6 +114,26 @@
} }
} }
@media (max-width: 767.98px) {
.contact-page__details {
align-items: center;
text-align: center;
}
.contact-page__location {
width: 100%;
}
.contact-page__location-title {
justify-content: center;
}
.contact-page__addresses {
padding-left: 0;
list-style-position: inside;
}
}
@media (max-width: 575.98px) { @media (max-width: 575.98px) {
.contact-page__map::ng-deep .location-map { .contact-page__map::ng-deep .location-map {
min-height: 16rem; min-height: 16rem;

View File

@@ -2,6 +2,7 @@ import { Component, computed, inject } from '@angular/core';
import { findMenu } from '../../../../../../core/services/menu.utils'; import { findMenu } from '../../../../../../core/services/menu.utils';
import { TenantService } from '../../../../../../core/services/tenant.service'; import { TenantService } from '../../../../../../core/services/tenant.service';
import { MenuContentSectionComponent } from '../../../../components/menu-content-section/menu-content-section.component';
import { import {
LocationMapComponent, LocationMapComponent,
MapLocation, MapLocation,
@@ -30,7 +31,7 @@ type ContactContent = {
@Component({ @Component({
selector: 'app-contact-page', selector: 'app-contact-page',
imports: [LocationMapComponent], imports: [LocationMapComponent, MenuContentSectionComponent],
templateUrl: './contact-page.html', templateUrl: './contact-page.html',
styleUrl: './contact-page.scss', styleUrl: './contact-page.scss',
}) })

View File

@@ -1,14 +1,8 @@
<section class="faq-page" aria-labelledby="faq-page-title"> <app-menu-content-section class="faq-page" menuCode="help.faq" titleId="faq-page-title">
<h2 id="faq-page-title" class="faq-page__title">PREGUNTAS FRECUENTES</h2>
@if (faqs().length > 0) { @if (faqs().length > 0) {
<app-accordion [flush]="true"> <app-accordion [flush]="true">
@for (faq of faqs(); track $index; let last = $last) { @for (faq of faqs(); track $index; let last = $last) {
<app-accordion-item <app-accordion-item [title]="faq.question" [headingLevel]="3" [open]="last">
[title]="faq.question"
[headingLevel]="3"
[open]="last"
>
<p>{{ faq.answer }}</p> <p>{{ faq.answer }}</p>
</app-accordion-item> </app-accordion-item>
} }
@@ -16,4 +10,4 @@
} @else { } @else {
<p class="faq-page__empty">No hay preguntas frecuentes disponibles.</p> <p class="faq-page__empty">No hay preguntas frecuentes disponibles.</p>
} }
</section> </app-menu-content-section>

View File

@@ -4,18 +4,11 @@
} }
.faq-page { .faq-page {
display: block;
width: 100%; width: 100%;
max-width: 760px; max-width: 760px;
} }
.faq-page__title {
margin: 0 0 0.5rem;
color: #a0a0a0;
font-size: 0.9375rem;
font-weight: 700;
line-height: 1.3;
}
.faq-page p { .faq-page p {
margin: 0; margin: 0;
} }

View File

@@ -4,6 +4,7 @@ import { findMenu } from '../../../../../../core/services/menu.utils';
import { TenantService } from '../../../../../../core/services/tenant.service'; import { TenantService } from '../../../../../../core/services/tenant.service';
import { AccordionItemComponent } from '../../../../../../shared/components/accordion/accordion-item.component'; import { AccordionItemComponent } from '../../../../../../shared/components/accordion/accordion-item.component';
import { AccordionComponent } from '../../../../../../shared/components/accordion/accordion.component'; import { AccordionComponent } from '../../../../../../shared/components/accordion/accordion.component';
import { MenuContentSectionComponent } from '../../../../components/menu-content-section/menu-content-section.component';
type Faq = { type Faq = {
question: string; question: string;
@@ -12,7 +13,7 @@ type Faq = {
@Component({ @Component({
selector: 'app-faq-page', selector: 'app-faq-page',
imports: [AccordionComponent, AccordionItemComponent], imports: [AccordionComponent, AccordionItemComponent, MenuContentSectionComponent],
templateUrl: './faq-page.html', templateUrl: './faq-page.html',
styleUrl: './faq-page.scss', styleUrl: './faq-page.scss',
}) })

View File

@@ -1,7 +0,0 @@
<div class="help-layout">
<app-help-sidebar class="help-layout__sidebar" />
<div class="help-layout__content">
<router-outlet />
</div>
</div>

View File

@@ -1,25 +0,0 @@
:host {
display: block;
}
.help-layout {
display: grid;
grid-template-columns: minmax(190px, 250px) minmax(0, 1fr);
gap: clamp(3rem, 8vw, 6.25rem);
width: 100%;
}
.help-layout__sidebar {
min-width: 0;
}
.help-layout__content {
min-width: 0;
}
@media (max-width: 767.98px) {
.help-layout {
grid-template-columns: 1fr;
gap: 2rem;
}
}

View File

@@ -1,12 +0,0 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { HelpSidebar } from '../components/help-sidebar/help-sidebar';
@Component({
selector: 'app-help-sidebar-layout',
imports: [RouterOutlet, HelpSidebar],
templateUrl: './help-sidebar-layout.html',
styleUrl: './help-sidebar-layout.scss',
})
export class HelpSidebarLayout {}

View File

@@ -30,7 +30,7 @@
<label class="visually-hidden" for="login-password">Contraseña</label> <label class="visually-hidden" for="login-password">Contraseña</label>
<app-input <app-input
id="login-password" id="login-password"
type="password" type="password-toggle"
placeholder="Contraseña" placeholder="Contraseña"
[value]="form.controls.password.value" [value]="form.controls.password.value"
[invalid]="showControlError('password')" [invalid]="showControlError('password')"

View File

@@ -65,13 +65,17 @@ describe('ProductDetailPageComponent', () => {
}); });
catalogServiceStub = { catalogServiceStub = {
getCatalogItem: vi.fn(), getCatalogItem: vi.fn(),
withCustomLoading: vi.fn(),
}; };
catalogServiceStub.withCustomLoading.mockReturnValue(catalogServiceStub);
routerStub = { routerStub = {
navigate: vi.fn(), navigate: vi.fn(),
}; };
cartServiceStub = { cartServiceStub = {
addItem: vi.fn().mockReturnValue(of({ message: 'Producto agregado al carrito' })), addItem: vi.fn().mockReturnValue(of({ message: 'Producto agregado al carrito' })),
withCustomLoading: vi.fn(),
}; };
cartServiceStub.withCustomLoading.mockReturnValue(cartServiceStub);
toastServiceStub = { toastServiceStub = {
success: vi.fn(), success: vi.fn(),
danger: vi.fn(), danger: vi.fn(),

View File

@@ -155,7 +155,10 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
this.variantLoading.set(true); this.variantLoading.set(true);
this.productSub?.unsubscribe(); this.productSub?.unsubscribe();
this.productSub = this.catalogService.getCatalogItem(productId, variantId).subscribe({ this.productSub = this.catalogService
.withCustomLoading()
.getCatalogItem(productId, variantId)
.subscribe({
next: (prod) => { next: (prod) => {
this.applyProduct(prod, false); this.applyProduct(prod, false);
this.variantLoading.set(false); this.variantLoading.set(false);
@@ -246,7 +249,10 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
} }
this.addingToCart.set(true); this.addingToCart.set(true);
this.cartService.addItem(currentProduct.id, variant?.id ?? null, this.quantity()).subscribe({ this.cartService
.withCustomLoading()
.addItem(currentProduct.id, variant?.id ?? null, this.quantity())
.subscribe({
next: (res) => { next: (res) => {
const msg = res.message || 'Producto agregado al carrito'; const msg = res.message || 'Producto agregado al carrito';
this.toastService.success(msg); this.toastService.success(msg);

View File

@@ -66,6 +66,9 @@ describe('PurchaseStatusPageComponent', () => {
async function render(hasGeneratedTickets: boolean) { async function render(hasGeneratedTickets: boolean) {
const checkoutService = { const checkoutService = {
getPurchase: vi.fn().mockResolvedValue(purchase(hasGeneratedTickets)), getPurchase: vi.fn().mockResolvedValue(purchase(hasGeneratedTickets)),
withCustomLoading() {
return this;
},
}; };
const router = { const router = {
navigate: vi.fn().mockResolvedValue(true), navigate: vi.fn().mockResolvedValue(true),

View File

@@ -63,7 +63,9 @@ export class PurchaseStatusPageComponent implements OnInit {
} }
try { try {
const purchase = await this.checkoutService.getPurchase(this.tenantCode, this.purchaseId); const purchase = await this.checkoutService
.withCustomLoading()
.getPurchase(this.tenantCode, this.purchaseId);
this.status.set(this.resolveStatus(purchase)); this.status.set(this.resolveStatus(purchase));
this.hasGeneratedTickets.set(purchase.has_generated_tickets === true); this.hasGeneratedTickets.set(purchase.has_generated_tickets === true);
} catch (error) { } catch (error) {

View File

@@ -18,9 +18,10 @@
:host ::ng-deep .recover-password-code-page__inputs .form-control { :host ::ng-deep .recover-password-code-page__inputs .form-control {
min-height: 52px; min-height: 52px;
padding: 0.5rem; padding: 0.5rem !important;
text-align: center; text-align: center;
font-size: 20px; font-size: 20px;
background-image: none !important;
} }
:host ::ng-deep .recover-password-code-page__resend-action { :host ::ng-deep .recover-password-code-page__resend-action {

View File

@@ -1,8 +1,8 @@
<section class="search-results" aria-labelledby="search-results-title"> <section class="search-results" aria-labelledby="search-results-title">
<header class="search-results__header mb-4"> <header class="search-results__header mb-5">
<h1 id="search-results-title" class="h3 mb-2">Resultados de búsqueda</h1> <h1 id="search-results-title" class="search-results__title mb-0">Resultados de búsqueda</h1>
@if (query()) { @if (query()) {
<p class="text-body-secondary mb-0"> <p class="search-results__subtitle text-body-secondary mb-0">
Resultados para <strong>“{{ query() }}”</strong> Resultados para <strong>“{{ query() }}”</strong>
</p> </p>
} }
@@ -10,8 +10,6 @@
@if (error()) { @if (error()) {
<p class="alert text-center mb-0 search-results__alert-error">{{ error() }}</p> <p class="alert text-center mb-0 search-results__alert-error">{{ error() }}</p>
} @else if (loading()) {
<p class="alert alert-light border text-center mb-0">Buscando productos...</p>
} @else if (results(); as searchResults) { } @else if (results(); as searchResults) {
@if (!searchResults.data.length) { @if (!searchResults.data.length) {
<p class="alert alert-light border text-center mb-0"> <p class="alert alert-light border text-center mb-0">

View File

@@ -1,3 +1,24 @@
.search-results__header {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 16px 28px;
text-align: center;
}
.search-results__title {
color: var(--bs-primary);
font-size: 24px;
font-weight: bold;
line-height: 1.2;
}
.search-results__subtitle {
line-height: 1.2;
}
.search-results__alert-error { .search-results__alert-error {
color: var(--tenant-danger-color, var(--bs-danger)); color: var(--tenant-danger-color, var(--bs-danger));
} }

View File

@@ -65,7 +65,9 @@ describe('SearchPageComponent', () => {
}, },
{ {
provide: CatalogService, provide: CatalogService,
useValue: { searchCatalog }, useValue: {
searchCatalog,
},
}, },
{ {
provide: TenantService, provide: TenantService,
@@ -106,6 +108,22 @@ describe('SearchPageComponent', () => {
); );
}); });
it('renders the search title and query subtitle with the category header layout', () => {
const fixture = TestBed.createComponent(SearchPageComponent);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const header = element.querySelector('.search-results__header');
const title = element.querySelector('#search-results-title');
const subtitle = element.querySelector('.search-results__subtitle');
expect(header?.classList).toContain('mb-5');
expect(title?.classList).toContain('search-results__title');
expect(title?.classList).toContain('mb-0');
expect(subtitle?.textContent).toContain('Resultados para');
expect(subtitle?.textContent).toContain('running');
});
it('does not search when the query has fewer than three characters', () => { it('does not search when the query has fewer than three characters', () => {
TestBed.overrideProvider(ActivatedRoute, { TestBed.overrideProvider(ActivatedRoute, {
useValue: { useValue: {

View File

@@ -56,7 +56,6 @@ export class SearchPageComponent {
protected readonly query = signal(''); protected readonly query = signal('');
protected readonly results = signal<ApiPaginatedResponse<CatalogFeaturedItem[]> | null>(null); protected readonly results = signal<ApiPaginatedResponse<CatalogFeaturedItem[]> | null>(null);
protected readonly loading = signal(false);
protected readonly error = signal<string | null>(null); protected readonly error = signal<string | null>(null);
protected readonly creatingDirectPurchase = signal(false); protected readonly creatingDirectPurchase = signal(false);
@@ -105,7 +104,6 @@ export class SearchPageComponent {
? null ? null
: `Ingresá al menos ${this.minSearchLength} caracteres para buscar.`, : `Ingresá al menos ${this.minSearchLength} caracteres para buscar.`,
); );
this.loading.set(query.length >= this.minSearchLength);
}), }),
switchMap(({ query, page }) => { switchMap(({ query, page }) => {
if (query.length < this.minSearchLength) { if (query.length < this.minSearchLength) {
@@ -124,7 +122,6 @@ export class SearchPageComponent {
) )
.subscribe((results) => { .subscribe((results) => {
this.results.set(results); this.results.set(results);
this.loading.set(false);
}); });
} }

View File

@@ -216,7 +216,9 @@ describe('StoreHomePageComponent', () => {
const catalogServiceStub = { const catalogServiceStub = {
getCatalog: vi.fn(), getCatalog: vi.fn(),
getFeaturedGroupItems: vi.fn().mockReturnValue(of(createItemsPage(pageTwoItems, 2, 2))), getFeaturedGroupItems: vi.fn().mockReturnValue(of(createItemsPage(pageTwoItems, 2, 2))),
withCustomLoading: vi.fn(),
}; };
catalogServiceStub.withCustomLoading.mockReturnValue(catalogServiceStub);
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [StoreHomePageComponent], imports: [StoreHomePageComponent],
@@ -232,6 +234,7 @@ describe('StoreHomePageComponent', () => {
(element.querySelector('[data-testid="paginator-next"]') as HTMLButtonElement).click(); (element.querySelector('[data-testid="paginator-next"]') as HTMLButtonElement).click();
fixture.detectChanges(); fixture.detectChanges();
expect(catalogServiceStub.withCustomLoading).toHaveBeenCalledOnce();
expect(catalogServiceStub.getFeaturedGroupItems).toHaveBeenCalledWith(7, { page: 2 }); expect(catalogServiceStub.getFeaturedGroupItems).toHaveBeenCalledWith(7, { page: 2 });
expect(element.textContent).toContain('Mouse Gamer'); expect(element.textContent).toContain('Mouse Gamer');
expect(element.querySelector('[data-testid="paginator-status"]')?.textContent?.trim()).toBe( expect(element.querySelector('[data-testid="paginator-status"]')?.textContent?.trim()).toBe(
@@ -244,7 +247,9 @@ describe('StoreHomePageComponent', () => {
const catalogServiceStub = { const catalogServiceStub = {
getCatalog: vi.fn(), getCatalog: vi.fn(),
getFeaturedGroupItems: vi.fn().mockReturnValue(nextPage.asObservable()), getFeaturedGroupItems: vi.fn().mockReturnValue(nextPage.asObservable()),
withCustomLoading: vi.fn(),
}; };
catalogServiceStub.withCustomLoading.mockReturnValue(catalogServiceStub);
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [StoreHomePageComponent], imports: [StoreHomePageComponent],

View File

@@ -105,7 +105,10 @@ export class StoreHomePageComponent implements OnInit, OnDestroy {
this.setGroupLoading(groupId, true); this.setGroupLoading(groupId, true);
this.groupRequestSubscriptions.get(groupId)?.unsubscribe(); this.groupRequestSubscriptions.get(groupId)?.unsubscribe();
const subscription = this.catalogService.getFeaturedGroupItems(groupId, { page }).subscribe({ const subscription = this.catalogService
.withCustomLoading()
.getFeaturedGroupItems(groupId, { page })
.subscribe({
next: (items) => { next: (items) => {
this.catalog.update((groups) => this.catalog.update((groups) =>
groups.map((candidate) => groups.map((candidate) =>
@@ -182,7 +185,10 @@ export class StoreHomePageComponent implements OnInit, OnDestroy {
this.error.set(null); this.error.set(null);
this.catalogRequestSubscription?.unsubscribe(); this.catalogRequestSubscription?.unsubscribe();
this.catalogRequestSubscription = this.catalogService.getCatalog().subscribe({ this.catalogRequestSubscription = this.catalogService
.withCustomLoading()
.getCatalog()
.subscribe({
next: (catalog) => this.catalog.set(catalog), next: (catalog) => this.catalog.set(catalog),
error: () => { error: () => {
this.catalog.set([]); this.catalog.set([]);

View File

@@ -116,9 +116,13 @@ export const routes: Routes = [
{ {
path: 'ayuda', path: 'ayuda',
canActivate: [hasMenuGuard('help')], canActivate: [hasMenuGuard('help')],
data: {
menuCode: 'help',
navigationLabel: 'Secciones de ayuda',
},
loadComponent: () => loadComponent: () =>
import('./pages/help-page/sidebar-layout/help-sidebar-layout').then( import('./components/menu-page-layout/menu-page-layout.component').then(
(m) => m.HelpSidebarLayout, (m) => m.MenuPageLayoutComponent,
), ),
children: [ children: [
{ {
@@ -152,8 +156,14 @@ export const routes: Routes = [
{ {
path: 'mi-cuenta', path: 'mi-cuenta',
canActivate: [authGuard, hasMenuGuard('account')], canActivate: [authGuard, hasMenuGuard('account')],
data: {
menuCode: 'account',
navigationLabel: 'Secciones de mi cuenta',
},
loadComponent: () => loadComponent: () =>
import('./pages/account-page/sidebar-layout/sidebar-layout').then((m) => m.SidebarLayout), import('./components/menu-page-layout/menu-page-layout.component').then(
(m) => m.MenuPageLayoutComponent,
),
children: [ children: [
{ {
path: 'datos-personales', path: 'datos-personales',

View File

@@ -1,8 +1,4 @@
:host { :host {
display: inline-block;
}
:host(.product-detail__cta) {
display: block; display: block;
width: 100%; width: 100%;
} }
@@ -11,10 +7,6 @@
min-height: 40px; min-height: 40px;
font-weight: 700; font-weight: 700;
font-size: 15px; font-size: 15px;
min-width: 216px;
}
:host(.product-detail__cta) .btn {
width: 100%; width: 100%;
} }

View File

@@ -87,3 +87,13 @@
display: flex; display: flex;
gap: 12px; gap: 12px;
} }
@media (max-width: 767.98px) {
.cart-actions {
flex-direction: column;
::ng-deep app-button {
width: 100%;
}
}
}

View File

@@ -0,0 +1,11 @@
@if (isLoading()) {
<div
class="global-loading"
role="status"
aria-live="polite"
aria-label="Cargando"
>
<span class="global-loading__spinner" aria-hidden="true"></span>
<span class="visually-hidden">Cargando...</span>
</div>
}

View File

@@ -0,0 +1,31 @@
.global-loading {
position: fixed;
inset: 0;
z-index: 3000;
display: grid;
place-items: center;
background: rgba(255, 255, 255, 0.64);
backdrop-filter: blur(2px);
cursor: wait;
}
.global-loading__spinner {
width: 3rem;
height: 3rem;
border: 0.25rem solid rgba(32, 32, 32, 0.12);
border-top-color: var(--tenant-primary, #6376f3);
border-radius: 50%;
animation: global-loading-spin 0.75s linear infinite;
}
@keyframes global-loading-spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.global-loading__spinner {
animation-duration: 1.5s;
}
}

View File

@@ -0,0 +1,54 @@
import '@angular/compiler';
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { afterEach, describe, expect, it } from 'vitest';
import { GlobalLoadingService } from '../../../core/services/global-loading/global-loading.service';
import { GlobalLoadingComponent } from './global-loading.component';
describe('GlobalLoadingComponent', () => {
afterEach(() => {
TestBed.resetTestingModule();
});
it('renders an accessible overlay while the app is loading', async () => {
const loadingState = signal(true);
await TestBed.configureTestingModule({
imports: [GlobalLoadingComponent],
providers: [
{
provide: GlobalLoadingService,
useValue: { isLoading: loadingState.asReadonly() }
}
]
}).compileComponents();
const fixture = TestBed.createComponent(GlobalLoadingComponent);
fixture.detectChanges();
const overlay = fixture.nativeElement.querySelector('.global-loading');
expect(overlay).not.toBeNull();
expect(overlay.getAttribute('role')).toBe('status');
expect(overlay.getAttribute('aria-label')).toBe('Cargando');
});
it('does not render the overlay while the app is idle', async () => {
const loadingState = signal(false);
await TestBed.configureTestingModule({
imports: [GlobalLoadingComponent],
providers: [
{
provide: GlobalLoadingService,
useValue: { isLoading: loadingState.asReadonly() }
}
]
}).compileComponents();
const fixture = TestBed.createComponent(GlobalLoadingComponent);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.global-loading')).toBeNull();
});
});

View File

@@ -0,0 +1,15 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { GlobalLoadingService } from '../../../core/services/global-loading/global-loading.service';
@Component({
selector: 'app-global-loading',
templateUrl: './global-loading.component.html',
styleUrl: './global-loading.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class GlobalLoadingComponent {
private readonly loadingService = inject(GlobalLoadingService);
protected readonly isLoading = this.loadingService.isLoading;
}

View File

@@ -1,8 +1,12 @@
<div class="hero-banner-container"> <div class="hero-banner-container">
<div <div
class="hero-banner position-relative rounded" class="hero-banner position-relative rounded"
[ngStyle]="{'background-image': heroConfig?.background_image ? 'url(' + heroConfig.background_image + ')' : 'none'}"
> >
<div
class="hero-media"
aria-hidden="true"
[ngStyle]="{'background-image': heroConfig?.background_image ? 'url(' + heroConfig.background_image + ')' : 'none'}"
></div>
@if (heroConfig) { @if (heroConfig) {
<div class="hero-content d-flex justify-content-end p-4 p-md-5 w-100 h-100 align-items-center"> <div class="hero-content d-flex justify-content-end p-4 p-md-5 w-100 h-100 align-items-center">
@@ -14,7 +18,7 @@
<div class="hero-description" [innerHTML]="heroConfig.description_html"></div> <div class="hero-description" [innerHTML]="heroConfig.description_html"></div>
} }
@if (heroConfig.button_text) { @if (heroConfig.button_text) {
<a [href]="heroConfig.button_href || '#'" class="text-decoration-none mt-3 d-inline-block"> <a [href]="heroConfig.button_href || '#'" class="hero-action text-decoration-none mt-3 d-inline-block">
<app-button variant="primary"> <app-button variant="primary">
{{ heroConfig.button_text }} {{ heroConfig.button_text }}
</app-button> </app-button>
@@ -26,7 +30,7 @@
@if (eventConfig) { @if (eventConfig) {
<div class="event-card-container position-absolute w-100 d-flex justify-content-center"> <div class="event-card-container position-absolute w-100 d-flex justify-content-center">
<div class="event-card bg-white shadow rounded p-3 p-md-4 text-center"> <div id="event-details" class="event-card bg-white shadow rounded p-3 p-md-4 text-center">
@if (eventConfig.title) { @if (eventConfig.title) {
<h3 class="event-title text-primary fw-bold mb-3">{{ eventConfig.title }}</h3> <h3 class="event-title text-primary fw-bold mb-3">{{ eventConfig.title }}</h3>
} }
@@ -45,6 +49,11 @@
</div> </div>
} }
</div> </div>
<div class="event-schedule-label" aria-hidden="true">
<i class="fa-solid fa-chevron-down"></i>
<span>Ver horarios</span>
</div>
</div> </div>
</div> </div>
} }

View File

@@ -5,22 +5,25 @@
.hero-banner { .hero-banner {
background-color: #e9ecef; background-color: #e9ecef;
background-size: cover;
background-position: center;
min-height: 400px; min-height: 400px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
}
&::before { .hero-media {
position: absolute;
inset: 0;
background-position: center;
background-repeat: no-repeat;
background-size: cover;
border-radius: inherit;
&::after {
content: ""; content: "";
position: absolute; position: absolute;
top: 0; inset: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(to right, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.7) 50%, rgba(255, 255, 255, 0.95) 100%); background: linear-gradient(to right, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.7) 50%, rgba(255, 255, 255, 0.95) 100%);
border-radius: inherit; border-radius: inherit;
z-index: 1;
} }
} }
@@ -85,27 +88,147 @@
font-weight: 500; font-weight: 500;
} }
@media (max-width: 768px) { .event-schedule-label {
.hero-banner::before { display: none;
background: none; }
@media (max-width: 767.98px) {
.hero-banner-container {
padding-bottom: 0;
margin-bottom: 0;
}
.hero-banner {
min-height: 0;
overflow: hidden;
background-color: #f5f5f5;
}
.hero-media {
position: relative;
inset: auto;
flex: 0 0 auto;
width: 100%;
height: clamp(8.75rem, 44vw, 10.5rem);
background-position: center center;
background-size: 140% auto;
border-radius: 0;
}
.hero-media::after {
top: auto;
height: 48%;
background: linear-gradient(
to bottom,
rgba(245, 245, 245, 0) 0%,
rgba(245, 245, 245, 0.78) 55%,
#f5f5f5 100%
);
border-radius: 0;
} }
.hero-content { .hero-content {
justify-content: center !important; justify-content: center !important;
background: none; height: auto !important;
min-height: 8.25rem;
margin-top: -1.7rem;
padding: 0 1.25rem 1.75rem !important;
align-items: flex-start !important;
} }
.hero-text-box { .hero-text-box {
width: 100%;
max-width: 19rem;
padding: 0;
text-align: center; text-align: center;
background: rgba(255, 255, 255, 0.8); background: transparent;
border-radius: 8px; border-radius: 0;
} }
::ng-deep .hero-title,
.hero-title {
margin-bottom: 0.4rem;
font-size: 0.875rem;
line-height: 1.2;
}
::ng-deep .hero-title p,
::ng-deep .hero-description p {
margin-bottom: 0;
}
::ng-deep .hero-description,
.hero-description {
font-size: 0.75rem;
line-height: 1.25;
}
.hero-action {
margin-top: 1rem !important;
}
.hero-action ::ng-deep .btn {
min-height: 1.875rem;
padding: 0.4rem 1.35rem;
border-radius: 0.25rem;
font-size: 0.625rem;
}
.event-card-container { .event-card-container {
bottom: -80px; position: relative !important;
bottom: auto;
left: auto;
padding: 0 1.25rem 1.2rem;
} }
.hero-banner-container { .event-card {
padding-bottom: 6rem; width: 100%;
max-width: 22rem;
min-height: 11.4rem;
padding: 1.75rem 1rem 0.8rem !important;
border-radius: 0 !important;
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.04) !important;
}
.event-title {
max-width: 16rem;
margin: 0 auto 0.85rem !important;
font-size: 1rem;
line-height: 1.1;
letter-spacing: 0;
}
.event-details {
gap: 0.45rem !important;
font-size: 0.7rem;
line-height: 1.25;
}
.event-details > div {
gap: 0.5rem !important;
}
.event-icon {
width: 0.9rem;
font-size: 0.85rem;
}
.event-info-text {
font-weight: 400;
}
.event-schedule-label {
display: flex;
justify-content: center;
align-items: center;
gap: 0.55rem;
margin-top: 1.9rem;
color: var(--tenant-primary, #009d4f);
font-size: 0.625rem;
font-weight: 700;
}
.event-schedule-label i {
font-size: 0.65rem;
} }
} }

View File

@@ -69,7 +69,8 @@
color: var(--tenant-danger); color: var(--tenant-danger);
} }
.icon-btn--user { .icon-btn--user,
.icon-btn--bars {
i { i {
width: 23px; width: 23px;
height: 23px; height: 23px;

View File

@@ -10,7 +10,8 @@ export type IconButtonVariant =
| 'angle-down' | 'angle-down'
| 'copy' | 'copy'
| 'download' | 'download'
| 'share'; | 'share'
| 'bars';
@Component({ @Component({
selector: 'app-icon-button', selector: 'app-icon-button',
@@ -38,6 +39,7 @@ export class IconButtonComponent {
copy: 'fa-solid fa-copy', copy: 'fa-solid fa-copy',
download: 'fa-solid fa-download', download: 'fa-solid fa-download',
share: 'fa-solid fa-share-nodes', share: 'fa-solid fa-share-nodes',
bars: 'fa-solid fa-bars',
}; };
return classes[this.variant()]; return classes[this.variant()];
}); });

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