15 Commits

Author SHA1 Message Date
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
64 changed files with 1529 additions and 262 deletions

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
<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="row g-4">
<div class="col-12">
<div class="container-xl px-3 px-md-4 py-4 py-md-5 store-layout__footer-content">
<div class="row g-4 store-layout__footer-grid">
<div class="col-12 store-layout__brand-column">
<div
class="store-layout__brand-slot d-inline-flex align-items-center gap-3"
data-testid="store-footer-brand-slot"
@@ -17,30 +17,29 @@
</div>
</div>
<div class="col-12 col-md-6 col-xl-5 d-grid gap-3 align-content-start">
<div class="d-grid gap-2">
<div class="d-flex align-items-center gap-2 ">
<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 store-layout__contact-details">
<div class="d-flex align-items-center gap-2">
<i class="fa-solid fa-location-dot" aria-hidden="true"></i>
<span>Av. San Lorenzo 1542, Rosario</span>
</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>
<span>54 9 (0341) 6658247</span>
</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>
@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">
@for (link of section.links; track link.label) {
@if (link.routerLink) {
<a
class="store-layout__footer-link"
[routerLink]="link.routerLink"
>
<a class="store-layout__footer-link" [routerLink]="link.routerLink">
{{ link.label }}
</a>
} @else if (link.action === 'logout') {
@@ -59,8 +58,10 @@
</div>
}
<div class="col-12 col-md-6 col-xl-3 d-grid align-content-start">
<div class="d-flex align-items-center gap-3">
<div class="store-layout__mobile-divider" aria-hidden="true"></div>
<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) {
<app-social-media
class="text-white"

View File

@@ -2,6 +2,10 @@
background-color: var(--tenant-footer-bg);
}
.store-layout__mobile-divider {
display: none;
}
.store-layout__brand-slot {
width: min(100%, 12rem);
min-width: 10rem;
@@ -92,11 +96,98 @@
}
@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: 100vw;
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 {
width: min(100%, 7.75rem);
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 {
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 }) socialMedia: SocialMedia[] = [];
@Input() logoUrl: string | null = null;
@Input() storeName: string | null = null;
readonly logoutClick = output<void>();
}

View File

@@ -1,81 +1,83 @@
<header class="store-layout__header">
<div class="store-layout__header-main">
<div
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"
>
<a
routerLink="/"
class="store-layout__brand-slot d-inline-flex align-items-center gap-3 flex-shrink-0 text-decoration-none"
data-testid="store-header-brand-slot"
aria-label="Logo del comercio"
>
@if (logoUrl()) {
<img class="store-layout__brand-logo" [src]="logoUrl()" alt="Logo del comercio" />
} @else {
<div class="store-layout__brand-logo-shell" aria-hidden="true">
<span class="store-layout__brand-logo-skeleton"></span>
</div>
}
</a>
<div
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 class="container-xl px-3 px-md-4 py-3 py-lg-4">
<div class="row align-items-center gy-3 gy-md-0">
<div class="col-auto col-md-auto order-1 store-layout__brand-column">
<a
routerLink="/"
class="store-layout__brand-slot d-inline-flex align-items-center gap-3 text-decoration-none"
data-testid="store-header-brand-slot"
aria-label="Logo del comercio"
>
@if (logoUrl()) {
<img class="store-layout__brand-logo" [src]="logoUrl()" alt="Logo del comercio" />
} @else {
<div class="store-layout__brand-logo-shell" aria-hidden="true">
<span class="store-layout__brand-logo-skeleton"></span>
</div>
}
</a>
</div>
@if (ticketsMenu(); as menu) {
<app-button
variant="primary"
hostClass="flex-shrink-0"
data-testid="store-header-tickets"
(click)="ticketsClick.emit()"
>
{{ menu.label }}
</app-button>
}
<div class="col-12 px-0 d-md-none order-3">
<hr class="store-layout__mobile-separator">
</div>
<div class="col-12 col-md order-4 order-md-2">
<div class="store-layout__search-wrapper ms-md-auto">
<div class="input-group store-layout__search" role="search">
<label class="visually-hidden" for="store-search-input">Buscar productos</label>
<input
id="store-search-input"
type="search"
class="form-control store-layout__search-input border-end-0 rounded-start"
placeholder="Buscar productos"
autocomplete="off"
[attr.aria-describedby]="showSearchError() ? 'store-search-error' : null"
[attr.aria-invalid]="showSearchError()"
[minlength]="minSearchLength"
[formControl]="searchControl"
(input)="clearSearchError()"
(keydown.enter)="submitSearch($event)"
/>
<button
type="button"
class="btn store-layout__search-button border-start-0 rounded-end px-3"
aria-label="Buscar"
(click)="submitSearch()"
>
<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
</button>
</div>
@if (showSearchError()) {
<p id="store-search-error" class="store-layout__search-error mb-0" role="alert">
Ingresá al menos {{ minSearchLength }} caracteres para buscar.
</p>
}
</div>
</div>
<div class="store-layout__actions-column col-auto col-md-auto order-2 order-md-3 ms-auto d-flex flex-row align-items-center justify-content-end gap-2 gap-md-3">
@if (ticketsMenu(); as menu) {
<app-button
variant="primary"
hostClass="store-layout__tickets-btn-host"
data-testid="store-header-tickets"
(click)="ticketsClick.emit()"
>
{{ menu.label }}
</app-button>
}
<div
class="d-flex align-items-center justify-content-end gap-1"
aria-label="Acciones de usuario"
>
<app-cart-icon
[quantity]="cartQuantity()"
ariaLabel="Carrito de compras"
title="Carrito"
(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
variant="user"
ariaLabel="Mi cuenta"
@@ -94,12 +96,46 @@
/>
}
</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 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="store-layout__category-menu">
<button

View File

@@ -10,7 +10,10 @@
.store-layout__brand-slot {
min-width: 150px;
max-width:40px;
}
.store-layout__brand-column {
max-width: 12rem;
}
.store-layout__brand-logo-shell {
@@ -42,10 +45,25 @@
}
.store-layout__search-wrapper {
width: min(100%, 260px);
width: 100%;
min-width: 0;
}
@media (min-width: 768px) {
.store-layout__search-wrapper {
width: min(100%, 260px);
}
.store-layout__actions-column {
padding-left: 0;
}
::ng-deep .store-layout__tickets-btn-host {
flex: 0 0 clamp(8rem, 15vw, 13.5rem);
width: clamp(8rem, 15vw, 13.5rem);
}
}
.store-layout__search {
width: 100%;
}
@@ -137,5 +155,56 @@
@media (max-width: 767.98px) {
.store-layout__brand-slot {
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 { RouterLink } from '@angular/router';
import { Router, RouterLink } from '@angular/router';
import { AuthUser } from '../../../services/auth/auth.interfaces';
import { CartIconComponent } from '../../../../shared/components/cart-icon/cart-icon.component';
import { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.component';
@@ -26,6 +26,7 @@ import { CategoryDropdownComponent } from './category-dropdown/category-dropdown
})
export class StoreHeaderComponent {
private readonly elementRef = inject(ElementRef<HTMLElement>);
private readonly router = inject(Router);
readonly logoUrl = input<string | null>(null);
readonly cartQuantity = input<number>(0);
@@ -43,13 +44,14 @@ export class StoreHeaderComponent {
protected readonly isUserDropdownOpen = signal(false);
protected readonly isCategoryDropdownOpen = signal(false);
protected readonly isMobileMenuOpen = signal(false);
protected readonly minSearchLength = 3;
protected readonly showSearchError = signal(false);
protected readonly searchControl = new FormControl('', { nonNullable: true });
@HostListener('document:click', ['$event'])
protected onDocumentClick(event: MouseEvent): void {
if (!this.isUserDropdownOpen() && !this.isCategoryDropdownOpen()) {
if (!this.isUserDropdownOpen() && !this.isCategoryDropdownOpen() && !this.isMobileMenuOpen()) {
return;
}
@@ -57,6 +59,7 @@ export class StoreHeaderComponent {
if (target instanceof Node && !this.elementRef.nativeElement.contains(target)) {
this.isUserDropdownOpen.set(false);
this.isCategoryDropdownOpen.set(false);
this.isMobileMenuOpen.set(false);
}
}
@@ -64,6 +67,7 @@ export class StoreHeaderComponent {
protected onEscapeKey(): void {
this.isUserDropdownOpen.set(false);
this.isCategoryDropdownOpen.set(false);
this.isMobileMenuOpen.set(false);
}
protected toggleUserDropdown(): void {
@@ -109,6 +113,25 @@ export class StoreHeaderComponent {
protected onCategorySelect(category: Category): void {
this.isCategoryDropdownOpen.set(false);
this.isMobileMenuOpen.set(false);
this.categorySelect.emit(category);
}
protected toggleMobileMenu(): void {
this.isMobileMenuOpen.update((isOpen) => !isOpen);
}
protected onMobileAccountClick(): void {
this.isMobileMenuOpen.set(false);
if (!this.isAuthenticated()) {
this.loginClick.emit();
} else {
const menus = this.accountNavigationMenus();
if (menus.length > 0) {
this.router.navigate([menus[0].route]);
} else {
this.router.navigate(['/cuenta']);
}
}
}
}

View File

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

View File

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

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 { TenantService } from '../tenant.service';
import { Cart } from './cart.interface';
import { LOADING_MODE } from '../global-loading/loading-mode';
describe('CartService', () => {
let service: CartService;
@@ -75,6 +76,14 @@ describe('CartService', () => {
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', () => {
service.clearCart();

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { environment } from '../../../environments/environment';
import { BaseApiService } from './base-api.service';
export interface UpdatePurchaseCustomerPayload {
dni: string;
@@ -77,9 +77,7 @@ export interface PurchaseDetailResponse extends PurchaseStatusResponse {
@Injectable({
providedIn: 'root',
})
export class CheckoutService {
private readonly http = inject(HttpClient);
export class CheckoutService extends BaseApiService {
async startCheckout(
tenantCode: string,
payload: StartCheckoutPayload,
@@ -182,7 +180,10 @@ export class CheckoutService {
return purchase;
}
async completePurchase(tenantCode: string, purchaseId: number): Promise<PurchaseStatusResponse> {
async completePurchase(
tenantCode: string,
purchaseId: number,
): Promise<PurchaseStatusResponse> {
const response = await firstValueFrom(
this.http.post<{ data?: PurchaseStatusResponse } | PurchaseStatusResponse>(
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/complete`,
@@ -220,7 +221,10 @@ export class CheckoutService {
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(
this.http.post<{ data?: PurchaseStatusResponse } | PurchaseStatusResponse>(
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/cancel`,
@@ -244,7 +248,9 @@ export class CheckoutService {
if (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) {
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 { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { HttpErrorResponse } from '@angular/common/http';
import {
inject,
Injectable,
@@ -13,6 +13,7 @@ import { IS_DISCOVERING_ROUTES } from '@angular/ssr';
import { firstValueFrom } from 'rxjs';
import { environment } from '../../../environments/environment';
import { BaseApiService } from './base-api.service';
import { Tenant, TenantBootstrapResponse } from './tenant.interface';
import {
TENANT_SSR_STATE_KEY,
@@ -23,8 +24,7 @@ import {
@Injectable({
providedIn: 'root'
})
export class TenantService {
private readonly http = inject(HttpClient);
export class TenantService extends BaseApiService {
private readonly platformId = inject(PLATFORM_ID);
private readonly request = inject(REQUEST, { optional: true });
private readonly responseInit = inject(RESPONSE_INIT, { optional: true });
@@ -73,7 +73,9 @@ export class TenantService {
try {
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) {

View File

@@ -29,7 +29,9 @@ export class PurchaseList implements OnInit {
try {
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) => ({
id: purchase.id,
date: this.formatDate(purchase.created_at),

View File

@@ -44,7 +44,9 @@ export class PurchaseDetailPage implements OnInit {
}
try {
const response = await this.checkoutService.getPurchase(tenant.codigo, purchaseId);
const response = await this.checkoutService
.withCustomLoading()
.getPurchase(tenant.codigo, purchaseId);
this.purchase.set({
id: response.id,

View File

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

View File

@@ -8,6 +8,14 @@ import { TicketComponent } from './components/ticket/ticket.component';
import { TicketResponse, TicketService } from './ticket.service';
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 => ({
id: 1,
tenant_code: 'tenant',
@@ -40,10 +48,10 @@ describe('TicketsPage', () => {
providers: [
{
provide: TicketService,
useValue: {
useValue: withCustomLoading({
getTickets: () => Promise.resolve([ticket({})]),
downloadPdf: () => Promise.resolve(new Blob(['pdf-content'])),
},
}),
},
],
},
@@ -99,10 +107,10 @@ describe('TicketsPage', () => {
providers: [
{
provide: TicketService,
useValue: {
useValue: withCustomLoading({
getTickets: () => Promise.resolve([ticket({})]),
downloadPdf,
},
}),
},
],
},
@@ -150,7 +158,10 @@ describe('TicketsPage', () => {
.overrideComponent(TicketsPage, {
set: {
providers: [
{ provide: TicketService, useValue: { getTickets: () => Promise.resolve([]) } },
{
provide: TicketService,
useValue: withCustomLoading({ getTickets: () => Promise.resolve([]) }),
},
],
},
})
@@ -183,7 +194,10 @@ describe('TicketsPage', () => {
.overrideComponent(TicketsPage, {
set: {
providers: [
{ provide: TicketService, useValue: { getTickets: () => Promise.resolve(tickets) } },
{
provide: TicketService,
useValue: withCustomLoading({ getTickets: () => Promise.resolve(tickets) }),
},
],
},
})
@@ -236,7 +250,9 @@ describe('TicketsPage', () => {
providers: [
{
provide: TicketService,
useValue: { getTickets: () => Promise.resolve([ticket({})]) },
useValue: withCustomLoading({
getTickets: () => Promise.resolve([ticket({})]),
}),
},
],
},

View File

@@ -35,7 +35,7 @@ export class TicketsPage implements OnInit {
async ngOnInit(): Promise<void> {
try {
this.tickets.set(await this.ticketService.getTickets());
this.tickets.set(await this.ticketService.withCustomLoading().getTickets());
} catch {
this.toastService.danger('Hubo un error al cargar los tickets');
} finally {
@@ -151,7 +151,9 @@ export class TicketsPage implements OnInit {
this.isGeneratingPdf.set(true);
try {
return await this.ticketService.downloadPdf(tickets.map((ticket) => ticket.id));
return await this.ticketService
.withCustomLoading()
.downloadPdf(tickets.map((ticket) => ticket.id));
} catch {
this.toastService.danger('No se pudo generar el PDF de los tickets.');
return null;

View File

@@ -1,8 +1,6 @@
<section class="category-items" aria-labelledby="category-items-title">
@if (error()) {
<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) {
<header class="category-items__header mb-5">
<h1 id="category-items-title" class="category-items__title mb-0">
@@ -19,7 +17,6 @@
[layout]="categoryResults.layout"
[groupLayout]="paginatedLayout"
[items]="categoryResults"
[loading]="loading()"
(buy)="onBuyProduct($event)"
(addToCart)="onAddToCart($event)"
(pageChange)="onPageChange($event)"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -63,7 +63,9 @@ export class PurchaseStatusPageComponent implements OnInit {
}
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.hasGeneratedTickets.set(purchase.has_generated_tickets === true);
} catch (error) {

View File

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

View File

@@ -1,8 +1,8 @@
<section class="search-results" aria-labelledby="search-results-title">
<header class="search-results__header mb-4">
<h1 id="search-results-title" class="h3 mb-2">Resultados de búsqueda</h1>
<header class="search-results__header mb-5">
<h1 id="search-results-title" class="search-results__title mb-0">Resultados de búsqueda</h1>
@if (query()) {
<p class="text-body-secondary mb-0">
<p class="search-results__subtitle text-body-secondary mb-0">
Resultados para <strong>“{{ query() }}”</strong>
</p>
}
@@ -10,8 +10,6 @@
@if (error()) {
<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) {
@if (!searchResults.data.length) {
<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 {
color: var(--tenant-danger-color, var(--bs-danger));
}

View File

@@ -65,7 +65,9 @@ describe('SearchPageComponent', () => {
},
{
provide: CatalogService,
useValue: { searchCatalog },
useValue: {
searchCatalog,
},
},
{
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', () => {
TestBed.overrideProvider(ActivatedRoute, {
useValue: {

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,4 @@
:host {
display: inline-block;
}
:host(.product-detail__cta) {
display: block;
width: 100%;
}
@@ -11,10 +7,6 @@
min-height: 40px;
font-weight: 700;
font-size: 15px;
min-width: 216px;
}
:host(.product-detail__cta) .btn {
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 position-relative rounded"
[ngStyle]="{'background-image': heroConfig?.background_image ? 'url(' + heroConfig.background_image + ')' : 'none'}"
<div
class="hero-banner position-relative rounded"
>
<div
class="hero-media"
aria-hidden="true"
[ngStyle]="{'background-image': heroConfig?.background_image ? 'url(' + heroConfig.background_image + ')' : 'none'}"
></div>
@if (heroConfig) {
<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>
}
@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">
{{ heroConfig.button_text }}
</app-button>
@@ -26,7 +30,7 @@
@if (eventConfig) {
<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) {
<h3 class="event-title text-primary fw-bold mb-3">{{ eventConfig.title }}</h3>
}
@@ -45,6 +49,11 @@
</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>
}

View File

@@ -5,22 +5,25 @@
.hero-banner {
background-color: #e9ecef;
background-size: cover;
background-position: center;
min-height: 400px;
display: flex;
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: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
inset: 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%);
border-radius: inherit;
z-index: 1;
}
}
@@ -85,27 +88,147 @@
font-weight: 500;
}
@media (max-width: 768px) {
.hero-banner::before {
background: none;
.event-schedule-label {
display: 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 {
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 {
width: 100%;
max-width: 19rem;
padding: 0;
text-align: center;
background: rgba(255, 255, 255, 0.8);
border-radius: 8px;
background: transparent;
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 {
bottom: -80px;
position: relative !important;
bottom: auto;
left: auto;
padding: 0 1.25rem 1.2rem;
}
.hero-banner-container {
padding-bottom: 6rem;
.event-card {
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);
}
.icon-btn--user {
.icon-btn--user,
.icon-btn--bars {
i {
width: 23px;
height: 23px;

View File

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

View File

@@ -51,7 +51,7 @@
(click)="togglePasswordVisibility()"
>
<i
class="fa-solid"
class="fa-regular"
[ngClass]="isPasswordVisible() ? 'fa-eye' : 'fa-eye-slash'"
aria-hidden="true"
></i>

View File

@@ -22,6 +22,7 @@
[title]="item.nombre"
[description]="item.descripcion ?? ''"
[price]="price(item)"
[variants]="item.variants ?? []"
(buy)="emitColumnBuy(item, $event)"
(addToCart)="emitColumnCart(item, $event)"
/>

View File

@@ -160,6 +160,31 @@ describe('ProductListComponent', () => {
});
});
it('passes column variants through to the purchase event', async () => {
const itemWithVariants: ProductListItem = {
...items[0],
variants: [
{ id: 91, stock_tecnico: 3, values: { fecha: '10 de octubre' } },
{ id: 92, stock_tecnico: 4, values: { fecha: '11 de octubre' } },
],
};
const fixture = await render('column_with_cart', [itemWithVariants]);
const buySpy = vi.fn();
fixture.componentInstance.buy.subscribe(buySpy);
const card = fixture.nativeElement.querySelector(
'app-product-vertical-with-cart-card',
) as HTMLElement;
(card.querySelector('.btn-primary') as HTMLButtonElement).click();
expect(buySpy).toHaveBeenCalledWith({
product: itemWithVariants,
quantity: 1,
variant: 91,
directPurchase: true,
});
});
it('renders pagination and emits the requested page', async () => {
const fixture = await render('row');
const pageChangeSpy = vi.fn();

View File

@@ -123,8 +123,11 @@ export class ProductListComponent {
});
}
protected emitColumnCart(product: ProductListItem, event: { quantity: number }): void {
this.addToCart.emit({ product, quantity: event.quantity });
protected emitColumnCart(
product: ProductListItem,
event: { quantity: number; variant: number | null },
): void {
this.addToCart.emit({ product, quantity: event.quantity, variant: event.variant });
}
protected emitRowBuy(
@@ -139,8 +142,16 @@ export class ProductListComponent {
});
}
protected emitColumnBuy(product: ProductListItem, event: { quantity: number }): void {
this.buy.emit({ product, quantity: event.quantity, variant: null, directPurchase: true });
protected emitColumnBuy(
product: ProductListItem,
event: { quantity: number; variant: number | null },
): void {
this.buy.emit({
product,
quantity: event.quantity,
variant: event.variant,
directPurchase: true,
});
}
protected emitProductDetailBuy(product: ProductListItem): void {

View File

@@ -8,14 +8,71 @@
</div>
<div class="product-vertical-with-cart-card__purchase">
<div class="product-vertical-with-cart-card__summary">
<span class="product-vertical-with-cart-card__price">{{ formattedPrice() }}</span>
<app-quantity-selector [(quantity)]="quantity" />
</div>
@if (!hasVariants()) {
<div class="product-vertical-with-cart-card__summary">
<span class="product-vertical-with-cart-card__price">{{ formattedPrice() }}</span>
<app-quantity-selector [(quantity)]="quantity" />
</div>
} @else if (!hasMultipleVariantSelectors()) {
<div class="product-vertical-with-cart-card__single-variant">
<span class="product-vertical-with-cart-card__price">{{ formattedPrice() }}</span>
<div class="product-vertical-with-cart-card__single-variant-row">
@for (selector of variantSelectors(); track selector.key) {
<select
class="form-select product-vertical-with-cart-card__variant-select"
[attr.aria-label]="selector.label"
[ngModel]="selectedValues()[selector.key]"
(ngModelChange)="onVariantValueChange(selector.key, $event)"
>
@for (option of selector.options; track option) {
<option [ngValue]="option">{{ option }}</option>
}
</select>
}
<app-quantity-selector [(quantity)]="quantity" />
</div>
</div>
} @else {
<div class="product-vertical-with-cart-card__multiple-variants">
<div class="product-vertical-with-cart-card__summary-column">
<span class="product-vertical-with-cart-card__price">{{ formattedPrice() }}</span>
<app-quantity-selector [(quantity)]="quantity" />
</div>
<div class="product-vertical-with-cart-card__variant-selectors">
@for (selector of variantSelectors(); track selector.key) {
<select
class="form-select product-vertical-with-cart-card__variant-select"
[attr.aria-label]="selector.label"
[ngModel]="selectedValues()[selector.key]"
(ngModelChange)="onVariantValueChange(selector.key, $event)"
>
@for (option of selector.options; track option) {
<option [ngValue]="option">{{ option }}</option>
}
</select>
}
</div>
</div>
}
<div class="product-vertical-with-cart-card__actions">
<app-button variant="primary" (click)="onBuy()">Comprar</app-button>
<app-button variant="secondary" (click)="onAddToCart()"> Agregar al carrito </app-button>
<app-button
variant="primary"
[disabled]="hasVariants() && selectedVariant() === null"
(click)="onBuy()"
>
Comprar
</app-button>
<app-button
variant="secondary"
[disabled]="hasVariants() && selectedVariant() === null"
(click)="onAddToCart()"
>
Agregar al carrito
</app-button>
</div>
</div>
</article>

View File

@@ -40,7 +40,7 @@
}
&__description {
margin: 23px 0 ;
margin: 23px 0;
color: #6f6f6f;
font-size: 11px;
font-weight: 300;
@@ -55,11 +55,66 @@
&__summary {
display: flex;
align-items: center;
justify-content: space-between;
justify-content: center;
gap: 16px;
padding-inline: 12px;
}
&__single-variant {
display: grid;
justify-items: center;
gap: 16px;
}
&__single-variant-row {
display: flex;
align-items: center;
width: 100%;
gap: 8px;
}
&__multiple-variants {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
align-items: center;
gap: 16px;
}
&__summary-column,
&__variant-selectors {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
min-width: 0;
}
&__variant-selectors {
align-items: stretch;
}
&__variant-select {
min-width: 0;
height: 40px;
border-color: #d8d8d8;
color: #6f6f6f;
font-size: 12px;
&:focus {
border-color: var(--tenant-primary, #009933);
box-shadow: 0 0 0 0.2rem color-mix(in srgb, transparent 75%, var(--tenant-primary, #009933));
}
}
&__single-variant-row &__variant-select {
flex: 1 1 auto;
}
&__single-variant-row app-quantity-selector {
flex: 0 0 auto;
}
&__price {
color: var(--tenant-primary, #009933);
font-size: 22px;
@@ -87,6 +142,12 @@
}
}
@media (max-width: 767.98px) {
.product-vertical-with-cart-card {
border-color: var(--color-primary, var(--tenant-primary, #009933));
}
}
@media (max-width: 420px) {
.product-vertical-with-cart-card {
padding: 42px 20px 21px;
@@ -98,6 +159,5 @@
&__summary {
padding-inline: 12px;
}
}
}

View File

@@ -83,7 +83,7 @@ describe('ProductVerticalWithCartCardComponent', () => {
) as HTMLButtonElement;
button.click();
expect(buySpy).toHaveBeenCalledWith({ quantity: 1 });
expect(buySpy).toHaveBeenCalledWith({ quantity: 1, variant: null });
});
it('emits addToCart with the current quantity', async () => {
@@ -98,7 +98,7 @@ describe('ProductVerticalWithCartCardComponent', () => {
) as HTMLButtonElement;
button.click();
expect(addToCartSpy).toHaveBeenCalledWith({ quantity: 3 });
expect(addToCartSpy).toHaveBeenCalledWith({ quantity: 3, variant: null });
});
it('uses the shared primary and secondary buttons', async () => {
@@ -115,4 +115,66 @@ describe('ProductVerticalWithCartCardComponent', () => {
element.querySelector('.product-vertical-with-cart-card__actions .btn-outline-primary'),
).not.toBeNull();
});
it('places a single variant selector in the same row as the quantity', async () => {
const fixture = await createComponent();
fixture.componentRef.setInput('variants', [
{ id: 10, values: { fecha: '10 de octubre' } },
{ id: 11, values: { fecha: '11 de octubre' } },
]);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const row = element.querySelector('.product-vertical-with-cart-card__single-variant-row');
expect(row?.querySelectorAll('.product-vertical-with-cart-card__variant-select')).toHaveLength(
1,
);
expect(row?.querySelector('app-quantity-selector')).not.toBeNull();
expect(element.querySelector('.product-vertical-with-cart-card__multiple-variants')).toBeNull();
});
it('uses separate summary and selector columns for multiple variant attributes', async () => {
const fixture = await createComponent();
fixture.componentRef.setInput('variants', [
{ id: 20, values: { fecha: '10 de octubre', turno: 'Mañana' } },
{ id: 21, values: { fecha: '10 de octubre', turno: 'Tarde' } },
]);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const layout = element.querySelector('.product-vertical-with-cart-card__multiple-variants');
expect(
layout?.querySelector('.product-vertical-with-cart-card__summary-column'),
).not.toBeNull();
expect(
layout?.querySelectorAll('.product-vertical-with-cart-card__variant-select'),
).toHaveLength(2);
});
it('emits the selected variant with the purchase action', async () => {
const fixture = await createComponent();
fixture.componentRef.setInput('variants', [
{ id: 30, values: { fecha: '10 de octubre' } },
{ id: 31, values: { fecha: '11 de octubre' } },
]);
fixture.detectChanges();
const buySpy = vi.fn();
fixture.componentInstance.buy.subscribe(buySpy);
const select = fixture.nativeElement.querySelector(
'.product-vertical-with-cart-card__variant-select',
) as HTMLSelectElement;
select.value = select.options[1].value;
select.dispatchEvent(new Event('change'));
fixture.detectChanges();
const button = fixture.nativeElement.querySelector(
'.product-vertical-with-cart-card__actions .btn-primary',
) as HTMLButtonElement;
button.click();
expect(buySpy).toHaveBeenCalledWith({ quantity: 1, variant: 31 });
});
});

View File

@@ -1,11 +1,33 @@
import { ChangeDetectionStrategy, Component, computed, input, model, output } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
input,
model,
output,
signal,
untracked,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ButtonComponent } from '../button/button.component';
import { QuantitySelectorComponent } from '../quantity-selector/quantity-selector.component';
export interface VerticalCartVariant {
id: number;
values: Record<string, string>;
}
interface VariantSelector {
key: string;
label: string;
options: string[];
}
@Component({
selector: 'app-product-vertical-with-cart-card',
imports: [ButtonComponent, QuantitySelectorComponent],
imports: [ButtonComponent, FormsModule, QuantitySelectorComponent],
templateUrl: './product-vertical-with-cart-card.component.html',
styleUrl: './product-vertical-with-cart-card.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -14,20 +36,80 @@ export class ProductVerticalWithCartCardComponent {
readonly title = input<string>('');
readonly description = input<string>('');
readonly price = input<number>(0);
readonly variants = input<VerticalCartVariant[]>([]);
readonly quantity = model<number>(1);
readonly selectedVariant = model<number | null>(null);
readonly buy = output<{ quantity: number }>();
readonly addToCart = output<{ quantity: number }>();
readonly buy = output<{ quantity: number; variant: number | null }>();
readonly addToCart = output<{ quantity: number; variant: number | null }>();
protected readonly selectedValues = signal<Record<string, string>>({});
protected readonly formattedPrice = computed(() => this.formatCurrency(this.price()));
protected readonly variantSelectors = computed<VariantSelector[]>(() => {
const variants = this.variants();
const keys = Array.from(new Set(variants.flatMap((variant) => Object.keys(variant.values))));
return keys.map((key) => ({
key,
label: this.formatVariantLabel(key),
options: Array.from(
new Set(
variants
.map((variant) => variant.values[key])
.filter((value): value is string => Boolean(value)),
),
),
}));
});
protected readonly hasVariants = computed(() => this.variantSelectors().length > 0);
protected readonly hasMultipleVariantSelectors = computed(
() => this.variantSelectors().length > 1,
);
constructor() {
effect(() => {
const variants = this.variants();
untracked(() => {
if (variants.length === 0) {
this.selectedValues.set({});
this.selectedVariant.set(null);
return;
}
const selected =
variants.find((variant) => variant.id === this.selectedVariant()) ?? variants[0];
this.selectedValues.set({ ...selected.values });
this.selectedVariant.set(selected.id);
});
});
}
protected onVariantValueChange(key: string, value: string): void {
const values = { ...this.selectedValues(), [key]: value };
const selectors = this.variantSelectors();
const matchingVariant = this.variants().find((variant) =>
selectors.every((selector) => variant.values[selector.key] === values[selector.key]),
);
this.selectedValues.set(values);
this.selectedVariant.set(matchingVariant?.id ?? null);
}
protected onAddToCart(): void {
this.addToCart.emit({ quantity: this.quantity() });
this.addToCart.emit({ quantity: this.quantity(), variant: this.selectedVariant() });
}
protected onBuy(): void {
this.buy.emit({ quantity: this.quantity() });
this.buy.emit({ quantity: this.quantity(), variant: this.selectedVariant() });
}
private formatVariantLabel(key: string): string {
const label = key.replace(/[_-]+/g, ' ');
return label.charAt(0).toUpperCase() + label.slice(1);
}
private formatCurrency(value: number): string {

View File

@@ -8,5 +8,5 @@
font-size: 15px;
font-weight: 700;
letter-spacing: 0.08em;
color: #000000;
color: var(--color-primary);
}

View File

@@ -1,5 +1,5 @@
<!doctype html>
<html lang="en">
<html lang="es">
<head>
<meta charset="utf-8">
<title>ShopitFront</title>