49 Commits

Author SHA1 Message Date
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
109a1cab5a refactor: update payment validation logic to handle transfer statuses more effectively 2026-07-29 09:36:22 -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
9bb3374ba0 feat: add submitPurchaseForReview method and update checkout flow to handle purchase review status 2026-07-29 09:20:17 -03:00
76327d34ec refactor: update styles for category items page header and title 2026-07-29 08:33:54 -03:00
4cb81ed70d feat: implement payment verification error component and update checkout flow 2026-07-28 17:02:12 -03:00
c233845ce0 feat: add WhatsApp support and improve payment timeout handling in checkout process 2026-07-28 16:57:00 -03:00
15a74bb56e feat: enhance purchase status page with ticket handling and WhatsApp integration 2026-07-28 16:26:45 -03:00
58e9dc06b1 refactor: simplify purchase status page by removing polling logic and updating messages 2026-07-28 16:03:17 -03:00
b96d6f23b6 feat: implement category dropdown and category items page with routing and styling 2026-07-28 12:20:53 -03:00
0726b2219e feat: add loading overlay and disable actions during PDF generation for tickets 2026-07-28 09:53:54 -03:00
7845c8f20e feat: implement loading state for checkout process and enhance stepper component 2026-07-28 09:25:04 -03:00
c41c60f349 feat: add download and share functionality for selected tickets 2026-07-28 09:01:39 -03:00
163444c91f feat: enhance loading skeleton for tickets list with improved styling and structure 2026-07-27 16:29:22 -03:00
4f1405f6c1 feat: refactor services injection in search and store home components, add direct purchase event tests 2026-07-27 16:26:03 -03:00
f063bac527 feat: implement direct purchase functionality and enhance product buy events 2026-07-27 16:25:38 -03:00
902f65d8d0 feat: enhance checkout process with purchase editing and status handling
- Implemented purchase editing functionality in CheckoutPageComponent, allowing users to modify items in their purchase.
- Added a new guard (checkoutPendingPurchaseGuard) to prevent navigation away from the checkout page while a purchase is in progress.
- Updated the login page to handle return URLs after authentication.
- Enhanced product detail page to support direct purchases with a new buyNow method.
- Introduced new UI elements and logic to handle purchase status, including expired and rejected states in PurchaseStatusPageComponent.
- Improved cart component to allow editing of item quantities with a toggle button.
- Added quantity selector enhancements to disable controls when necessary.
- Updated tests to cover new functionalities and ensure proper behavior of components.
2026-07-27 12:49:00 -03:00
9dcedc9382 Forgot password flow 2026-07-27 09:42:22 -03:00
565bc769ed feat(environment): add homo environment configuration for homologation 2026-07-24 16:59:15 -03:00
4e06145f44 feat(search): enforce minimum character limit for search input and display error message 2026-07-24 14:17:41 -03:00
9bb4ff61f7 feat(search): update search functionality to handle button click and Enter key press 2026-07-24 14:10:43 -03:00
79a6edc3f3 feat(search): implement search functionality with results page and routing 2026-07-24 14:10:26 -03:00
5d5146d784 feat(tickets-page): conditionally render header and bulk actions based on available tickets 2026-07-24 10:42:17 -03:00
34d351afc5 feat(store-layout): add tickets menu button and navigation handling in store header 2026-07-24 10:42:09 -03:00
9845a82f4a feat(product-list): center justify content in adaptive layout for improved alignment 2026-07-24 10:31:27 -03:00
f4e854843e feat(product-list): improve adaptive layout by refining grid-template-columns for better responsiveness 2026-07-24 10:30:24 -03:00
752c5fceea feat(menu): refactor menu handling to use labels directly and remove unused functions 2026-07-24 10:12:11 -03:00
974f947fc6 feat(account-page): implement dynamic account navigation menus in sidebar and user dropdown 2026-07-24 09:58:18 -03:00
fa0e9de886 feat(contact-page): implement contact page with dynamic content and map integration 2026-07-24 09:41:09 -03:00
656e41d189 feat(contact-page): add route for contact page with menu guard 2026-07-24 09:13:12 -03:00
63ae02df22 feat(location-map): implement reusable location map component with dynamic markers and directions 2026-07-24 09:05:16 -03:00
182 changed files with 8503 additions and 1122 deletions

View File

@@ -30,16 +30,11 @@
"input": "public"
}
],
"styles": [
"src/styles.scss"
],
"styles": ["node_modules/leaflet/dist/leaflet.css", "src/styles.scss"],
"server": "src/main.server.ts",
"outputMode": "server",
"security": {
"allowedHosts": [
"localhost",
"127.0.0.1"
]
"allowedHosts": ["localhost", "127.0.0.1"]
},
"ssr": {
"entry": "src/server.ts"
@@ -70,6 +65,14 @@
"optimization": false,
"extractLicenses": false,
"sourceMap": true
},
"homo": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.homo.ts"
}
]
}
},
"defaultConfiguration": "production"
@@ -85,6 +88,9 @@
},
"local": {
"buildTarget": "shopit-front:build:local"
},
"homo": {
"buildTarget": "shopit-front:build:homo"
}
},
"defaultConfiguration": "development"
@@ -95,4 +101,4 @@
}
}
}
}
}

25
package-lock.json generated
View File

@@ -19,6 +19,7 @@
"@fortawesome/fontawesome-free": "^7.2.0",
"bootstrap": "^5.3.8",
"express": "^5.1.0",
"leaflet": "^1.9.4",
"qrcode": "1.5.4",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
@@ -28,6 +29,7 @@
"@angular/cli": "^22.0.4",
"@angular/compiler-cli": "^22.0.0",
"@types/express": "^5.0.1",
"@types/leaflet": "^1.9.21",
"@types/node": "^20.17.19",
"jsdom": "^28.0.0",
"prettier": "^3.8.1",
@@ -3816,6 +3818,13 @@
"@types/send": "*"
}
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/http-errors": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
@@ -3823,6 +3832,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/leaflet": {
"version": "1.9.21",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/node": {
"version": "20.19.43",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
@@ -5936,6 +5955,12 @@
],
"license": "MIT"
},
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
},
"node_modules/listr2": {
"version": "10.2.1",
"resolved": "https://registry.npmjs.org/listr2/-/listr2-10.2.1.tgz",

View File

@@ -23,6 +23,7 @@
"@fortawesome/fontawesome-free": "^7.2.0",
"bootstrap": "^5.3.8",
"express": "^5.1.0",
"leaflet": "^1.9.4",
"qrcode": "1.5.4",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
@@ -32,6 +33,7 @@
"@angular/cli": "^22.0.4",
"@angular/compiler-cli": "^22.0.0",
"@types/express": "^5.0.1",
"@types/leaflet": "^1.9.21",
"@types/node": "^20.17.19",
"jsdom": "^28.0.0",
"prettier": "^3.8.1",

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

@@ -23,7 +23,8 @@ const tenant: Tenant = {
header_bg_color: '#313131',
footer_bg_color: '#313131',
header_logo: 'https://example.com/header.png',
footer_logo: 'https://example.com/footer.png'
footer_logo: 'https://example.com/footer.png',
categories: []
};
function createTenantServiceStub(
@@ -165,6 +166,55 @@ describe('app routes', () => {
]);
});
it('loads the recover password page inside the simple layout', async () => {
const { fixture, router } = await renderAppAt('/recuperar-contrasena');
const compiled = fixture.nativeElement as HTMLElement;
const recoverPasswordPage = compiled.querySelector('app-recover-password-page');
expect(router.url).toBe('/recuperar-contrasena');
expect(compiled.querySelector('.simple-layout')).not.toBeNull();
expect(recoverPasswordPage).not.toBeNull();
expect(recoverPasswordPage?.textContent).toContain('Recuperar contraseña');
expect(recoverPasswordPage?.querySelector('input')?.getAttribute('placeholder')).toBe('Email');
expect(recoverPasswordPage?.textContent).toContain('Recuperar acceso');
expect(recoverPasswordPage?.textContent).toContain('Volver');
});
it('loads the recovery code page and shows the entered email', async () => {
const { fixture, router } = await renderAppAt(
'/recuperar-contrasena/codigo?email=ada%40example.com'
);
const compiled = fixture.nativeElement as HTMLElement;
const codePage = compiled.querySelector('app-recover-password-code-page');
expect(router.url).toBe('/recuperar-contrasena/codigo?email=ada%40example.com');
expect(compiled.querySelector('.simple-layout')).not.toBeNull();
expect(codePage).not.toBeNull();
expect(codePage?.textContent).toContain('ada@example.com');
expect(codePage?.querySelectorAll('app-input')).toHaveLength(4);
expect(codePage?.textContent).toContain('Validar');
});
it('loads the reset password page with the shared password inputs', async () => {
const { fixture, router } = await renderAppAt(
'/recuperar-contrasena/restablecer?email=ada%40example.com&code=1234'
);
const compiled = fixture.nativeElement as HTMLElement;
const resetPage = compiled.querySelector('app-reset-password-page');
const placeholders = Array.from(resetPage?.querySelectorAll('input') ?? []).map((input) =>
input.getAttribute('placeholder')
);
expect(router.url).toBe(
'/recuperar-contrasena/restablecer?email=ada%40example.com&code=1234'
);
expect(compiled.querySelector('.simple-layout')).not.toBeNull();
expect(resetPage).not.toBeNull();
expect(resetPage?.textContent).toContain('Restablecer contraseña');
expect(placeholders).toEqual(['Nueva Contraseña', 'Repetir Nueva Contraseña']);
expect(resetPage?.textContent).toContain('Guardar');
});
it('redirects authenticated users away from /login', async () => {
const { router } = await renderAppAt('/login', createTenantServiceStub(), createAuthServiceStub(true));
@@ -186,7 +236,7 @@ describe('app routes', () => {
it('redirects unauthenticated users from /checkout to /login', async () => {
const { router } = await renderAppAt('/checkout', createTenantServiceStub(), createAuthServiceStub(false));
expect(router.url).toBe('/login');
expect(router.url).toBe('/login?returnUrl=%2Fcheckout');
});
it('allows authenticated users to access /checkout', async () => {

View File

@@ -24,7 +24,8 @@ const tenant: Tenant = {
header_bg_color: '#313131',
footer_bg_color: '#313131',
header_logo: 'https://example.com/header.png',
footer_logo: 'https://example.com/footer.png'
footer_logo: 'https://example.com/footer.png',
categories: []
};
function createTenantServiceStub(status: 'ready' | 'not-found', currentTenant: Tenant | null) {

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

@@ -0,0 +1,39 @@
@if (open() && categories().length) {
<section class="dropdown-menu show p-2 category-dropdown" role="menu" aria-label="Categorías">
@for (category of categories(); track category.id) {
<div class="dropdown-submenu">
<button
type="button"
class="dropdown-item d-flex align-items-center justify-content-between gap-3"
[class.active]="activeCategory()?.id === category.id"
role="menuitem"
[attr.aria-expanded]="category.subcategories.length ? activeCategory()?.id === category.id : null"
(click)="selectCategory(category)"
>
<span>{{ category.nombre }}</span>
@if (category.subcategories.length) {
<i class="fa-solid fa-chevron-right" aria-hidden="true"></i>
}
</button>
@if (activeCategory()?.id === category.id && subcategories().length) {
<div class="dropdown-menu show p-2 category-dropdown__submenu">
@for (subcategory of subcategories(); track subcategory.id) {
<button
type="button"
class="dropdown-item d-flex align-items-center justify-content-between gap-3"
role="menuitem"
(click)="categorySelect.emit(subcategory)"
>
<span>{{ subcategory.nombre }}</span>
@if (subcategory.subcategories.length) {
<i class="fa-solid fa-chevron-right" aria-hidden="true"></i>
}
</button>
}
</div>
}
</div>
}
</section>
}

View File

@@ -0,0 +1,47 @@
.category-dropdown {
min-width: 240px;
background-color: #ffffff;
border: 0;
box-shadow: 0 0 25px rgba(0, 0, 0, 0.05);
}
.category-dropdown .dropdown-item,
.category-dropdown .dropdown-item:active {
display: flex;
align-items: center;
min-height: 2rem;
font-size: 14px;
font-weight: 700;
line-height: 1.25;
color: #8a8a8a;
background-color: #ffffff;
}
.category-dropdown .dropdown-item:hover,
.category-dropdown .dropdown-item:focus,
.category-dropdown .dropdown-item.active {
color: var(--tenant-primary, var(--bs-primary));
background-color: #ffffff;
}
.dropdown-submenu {
position: relative;
}
.category-dropdown__submenu {
top: -0.5rem;
left: calc(100% - 0.1rem);
min-width: 240px;
border: 0;
box-shadow: 0 0 25px rgba(0, 0, 0, 0.05);
}
@media (max-width: 575.98px) {
.category-dropdown__submenu {
position: static;
margin: 0;
border: 0;
border-radius: 0;
box-shadow: none;
}
}

View File

@@ -0,0 +1,46 @@
import { ChangeDetectionStrategy, Component, computed, effect, input, output, signal } from '@angular/core';
import { Category } from '../../../../services/tenant.interface';
@Component({
selector: 'app-category-dropdown',
standalone: true,
templateUrl: './category-dropdown.component.html',
styleUrl: './category-dropdown.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CategoryDropdownComponent {
readonly categories = input<readonly Category[]>([]);
readonly open = input(false);
readonly categorySelect = output<Category>();
protected readonly activeCategory = signal<Category | null>(null);
protected readonly subcategories = computed(() => this.activeCategory()?.subcategories ?? []);
constructor() {
effect(() => {
const categories = this.categories();
const activeCategory = this.activeCategory();
if (!this.open()) {
this.activeCategory.set(null);
return;
}
if (activeCategory && !categories.some((category) => category.id === activeCategory.id)) {
this.activeCategory.set(null);
}
});
}
protected selectCategory(category: Category): void {
if (category.subcategories.length) {
this.activeCategory.update((activeCategory) =>
activeCategory?.id === category.id ? null : category,
);
return;
}
this.categorySelect.emit(category);
}
}

View File

@@ -1,46 +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="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"
/>
<button
type="button"
class="btn store-layout__search-button border-start-0 rounded-end px-3"
aria-label="Buscar"
<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"
>
<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
</button>
@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>
<div class="d-flex align-items-center justify-content-end gap-1" aria-label="Acciones de usuario">
<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>
}
<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"
@@ -54,25 +91,72 @@
<app-user-dropdown
id="store-user-dropdown"
[user]="user()"
[accountNavigationMenus]="accountNavigationMenus()"
(logoutClick)="onLogoutClick()"
/>
}
</div>
<div class="store-layout__mobile-menu d-inline-flex d-md-none position-relative">
<app-icon-button
variant="bars"
ariaLabel="Menu"
ariaControls="store-mobile-menu"
[ariaExpanded]="isMobileMenuOpen()"
title="Menu"
(clicked)="toggleMobileMenu()"
/>
@if (isMobileMenuOpen()) {
<div id="store-mobile-menu" class="position-absolute end-0 bg-white rounded shadow mt-2 py-2 store-layout__mobile-dropdown-menu">
<a
class="dropdown-item d-flex align-items-center gap-2 px-3 py-2 text-decoration-none store-layout__mobile-dropdown-item"
(click)="onMobileAccountClick()"
>
<i class="fa-regular fa-circle-user fa-lg"></i>
<span class="fw-medium">Mi cuenta</span>
</a>
<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 class="store-layout__categories d-none d-md-block">
<div class="container-xl px-3 px-md-4">
<button
type="button"
class="btn btn-link d-inline-flex align-items-center gap-2 px-0 py-2 text-uppercase fw-bold text-decoration-none store-layout__category-trigger"
aria-label="Categorias"
>
<span>Categorias</span>
<i class="fa-solid fa-chevron-down" aria-hidden="true"></i>
</button>
<div class="store-layout__category-menu">
<button
type="button"
class="btn btn-link d-inline-flex align-items-center gap-2 px-0 py-2 text-uppercase fw-bold text-decoration-none store-layout__category-trigger"
aria-label="Categorias"
aria-controls="store-category-dropdown"
[attr.aria-expanded]="isCategoryDropdownOpen()"
(click)="toggleCategoryDropdown()"
>
<span>Categorias</span>
<i class="fa-solid fa-chevron-down" aria-hidden="true"></i>
</button>
<app-category-dropdown
id="store-category-dropdown"
[categories]="categories()"
[open]="isCategoryDropdownOpen()"
(categorySelect)="onCategorySelect($event)"
/>
</div>
</div>
</div>
</header>

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 {
@@ -41,11 +44,37 @@
animation: store-header-logo-skeleton 1.2s ease-in-out infinite;
}
.store-layout__search {
width: min(100%, 260px);
.store-layout__search-wrapper {
width: 100%;
min-width: 0;
}
@media (min-width: 768px) {
.store-layout__search-wrapper {
width: min(100%, 260px);
}
.store-layout__actions-column {
padding-left: 0;
}
::ng-deep .store-layout__tickets-btn-host {
flex: 0 0 clamp(8rem, 15vw, 13.5rem);
width: clamp(8rem, 15vw, 13.5rem);
}
}
.store-layout__search {
width: 100%;
}
.store-layout__search-error {
margin-top: 0.25rem;
font-size: 0.75rem;
line-height: 1.2;
color: var(--tenant-danger, #dc3545);
}
.store-layout__search-input {
color: #666666;
border-color: #cccccc;
@@ -100,6 +129,19 @@
color: #8a8a8a;
}
.store-layout__category-menu {
position: relative;
display: inline-flex;
flex-direction: column;
}
.store-layout__category-menu app-category-dropdown {
position: absolute;
z-index: 1050;
top: 100%;
left: 0;
}
@keyframes store-header-logo-skeleton {
from {
background-position: 200% 0;
@@ -113,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,44 +1,73 @@
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';
import { UserDropdownComponent } from './user-dropdown/user-dropdown.component';
import { Menu } from '../../../services/tenant.interface';
import { ButtonComponent } from '../../../../shared/components/button/button.component';
import { ReactiveFormsModule, FormControl } from '@angular/forms';
import { Category } from '../../../services/tenant.interface';
import { CategoryDropdownComponent } from './category-dropdown/category-dropdown.component';
@Component({
selector: 'app-store-header',
imports: [CartIconComponent, IconButtonComponent, RouterLink, UserDropdownComponent],
imports: [
ButtonComponent,
CartIconComponent,
IconButtonComponent,
RouterLink,
ReactiveFormsModule,
UserDropdownComponent,
CategoryDropdownComponent,
],
templateUrl: './store-header.component.html',
styleUrl: './store-header.component.scss'
styleUrl: './store-header.component.scss',
})
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);
readonly user = input<AuthUser | null>(null);
readonly isAuthenticated = input<boolean>(false);
readonly accountNavigationMenus = input<readonly Menu[]>([]);
readonly ticketsMenu = input<Menu | null>(null);
readonly categories = input<readonly Category[]>([]);
readonly cartClick = output<void>();
readonly ticketsClick = output<void>();
readonly loginClick = output<void>();
readonly logoutClick = output<void>();
readonly searchSubmit = output<string>();
readonly categorySelect = output<Category>();
protected readonly isUserDropdownOpen = signal(false);
protected readonly isCategoryDropdownOpen = signal(false);
protected readonly isMobileMenuOpen = signal(false);
protected readonly minSearchLength = 3;
protected readonly showSearchError = signal(false);
protected readonly searchControl = new FormControl('', { nonNullable: true });
@HostListener('document:click', ['$event'])
protected onDocumentClick(event: MouseEvent): void {
if (!this.isUserDropdownOpen()) {
if (!this.isUserDropdownOpen() && !this.isCategoryDropdownOpen() && !this.isMobileMenuOpen()) {
return;
}
const target = event.target;
if (target instanceof Node && !this.elementRef.nativeElement.contains(target)) {
this.isUserDropdownOpen.set(false);
this.isCategoryDropdownOpen.set(false);
this.isMobileMenuOpen.set(false);
}
}
@HostListener('document:keydown.escape')
protected onEscapeKey(): void {
this.isUserDropdownOpen.set(false);
this.isCategoryDropdownOpen.set(false);
this.isMobileMenuOpen.set(false);
}
protected toggleUserDropdown(): void {
@@ -59,4 +88,50 @@ export class StoreHeaderComponent {
this.isUserDropdownOpen.set(false);
this.logoutClick.emit();
}
protected submitSearch(event?: Event): void {
event?.preventDefault();
const term = this.searchControl.value.trim();
if (term.length < this.minSearchLength) {
this.showSearchError.set(true);
return;
}
this.showSearchError.set(false);
this.searchSubmit.emit(term);
}
protected clearSearchError(): void {
this.showSearchError.set(false);
}
protected toggleCategoryDropdown(): void {
this.isCategoryDropdownOpen.update((isOpen) => !isOpen);
}
protected onCategorySelect(category: Category): void {
this.isCategoryDropdownOpen.set(false);
this.isMobileMenuOpen.set(false);
this.categorySelect.emit(category);
}
protected toggleMobileMenu(): void {
this.isMobileMenuOpen.update((isOpen) => !isOpen);
}
protected onMobileAccountClick(): void {
this.isMobileMenuOpen.set(false);
if (!this.isAuthenticated()) {
this.loginClick.emit();
} else {
const menus = this.accountNavigationMenus();
if (menus.length > 0) {
this.router.navigate([menus[0].route]);
} else {
this.router.navigate(['/cuenta']);
}
}
}
}

View File

@@ -5,12 +5,16 @@
<div class="user-dropdown__divider"></div>
<a routerLink="/mi-cuenta/datos-personales" class="user-dropdown__item" role="menuitem" data-testid="user-dropdown-account">
Mi cuenta
</a>
<a routerLink="/mi-cuenta/compras" class="user-dropdown__item" role="menuitem" data-testid="user-dropdown-purchases">
Mis compras
</a>
@for (menu of accountNavigationMenus(); track menu.code) {
<a
[routerLink]="menu.route"
class="user-dropdown__item"
role="menuitem"
[attr.data-testid]="'user-dropdown-' + menu.code"
>
{{ menu.label }}
</a>
}
<button
type="button"
class="user-dropdown__item"

View File

@@ -1,16 +1,18 @@
import { Component, computed, input, output } from '@angular/core';
import { RouterLink } from '@angular/router';
import { AuthUser } from '../../../../services/auth/auth.interfaces';
import { Menu } from '../../../../services/tenant.interface';
@Component({
selector: 'app-user-dropdown',
standalone: true,
imports: [RouterLink],
templateUrl: './user-dropdown.component.html',
styleUrl: './user-dropdown.component.scss'
styleUrl: './user-dropdown.component.scss',
})
export class UserDropdownComponent {
readonly user = input<AuthUser | null>(null);
readonly accountNavigationMenus = input<readonly Menu[]>([]);
readonly logoutClick = output<void>();
protected readonly displayName = computed(() => this.user()?.nombre_apellido || 'Mi cuenta');

View File

@@ -4,13 +4,22 @@
[logoUrl]="tenant()?.header_logo ?? null"
[user]="user()"
[isAuthenticated]="isAuthenticated()"
[accountNavigationMenus]="accountNavigationMenus()"
[ticketsMenu]="ticketsMenu() ?? null"
[categories]="tenant()?.categories ?? []"
(cartClick)="isCartOpen.set(!isCartOpen())"
(ticketsClick)="onTicketsClick()"
(loginClick)="onLoginClick()"
(logoutClick)="onLogoutClick()"
(searchSubmit)="onSearch($event)"
(categorySelect)="onCategorySelect($event)"
/>
@if (isCartOpen()) {
<div class="store-layout__cart-overlay" (click)="isCartOpen.set(false)"></div>
<div
class="store-layout__cart-overlay"
(click)="isCartOpen.set(false)"
></div>
<div class="store-layout__cart-dropdown card border-0 shadow-lg">
<app-cart
[showClose]="true"
@@ -21,8 +30,20 @@
[backgroundColor]="'#ffffff'"
(closed)="isCartOpen.set(false)"
>
<app-button variant="secondary" class="flex-grow-1" (click)="isCartOpen.set(false)">Seguir comprando</app-button>
<app-button variant="primary" class="flex-grow-1" (click)="onCheckoutClick()">Comprar</app-button>
<app-button
variant="secondary"
class="flex-grow-1"
(click)="isCartOpen.set(false)"
>Seguir comprando</app-button
>
<app-button
variant="primary"
class="flex-grow-1"
[disabled]="isCreatingPurchase()"
(click)="onCheckoutClick()"
>
Comprar
</app-button>
</app-cart>
</div>
}
@@ -40,6 +61,7 @@
[footerSections]="footerSections()"
[socialMedia]="tenant()?.social_media ?? []"
[logoUrl]="tenant()?.footer_logo ?? null"
[storeName]="tenant()?.nombre ?? null"
(logoutClick)="onLogoutClick()"
/>
</div>

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { provideRouter, Router } from '@angular/router';
import { of } from 'rxjs';
@@ -8,10 +9,13 @@ import { of } from 'rxjs';
import { Tenant } from '../../services/tenant.interface';
import { TenantService } from '../../services/tenant.service';
import { CartService } from '../../services/cart/cart.service';
import { Cart } from '../../services/cart/cart.interface';
import { ToastService } from '../../services/toast.service';
import { AuthService } from '../../services/auth/auth.service';
import { AuthUser } from '../../services/auth/auth.interfaces';
import { StoreLayoutComponent } from './store-layout.component';
import { StoreHeaderComponent } from './store-header/store-header.component';
import { CheckoutService } from '../../services/checkout.service';
const tenant: Tenant = {
id: 1,
@@ -26,10 +30,49 @@ const tenant: Tenant = {
footer_bg_color: '#313131',
header_logo: 'https://example.com/header.png',
footer_logo: 'https://example.com/footer.png',
categories: [],
menues: [
{
id: 4,
code: 'account',
label: 'Mi cuenta',
parent_menu_code: null,
content_type: 'dynamic',
route: '/mi-cuenta',
submenues: [
{
id: 5,
code: 'account.profile',
label: 'Datos personales',
parent_menu_code: 'account',
content_type: 'dynamic',
route: '/mi-cuenta/datos-personales',
submenues: [],
},
{
id: 6,
code: 'account.purchases',
label: 'Mis compras',
parent_menu_code: 'account',
content_type: 'dynamic',
route: '/mi-cuenta/compras',
submenues: [],
},
{
id: 7,
code: 'account.tickets',
label: 'Mis tickets',
parent_menu_code: 'account',
content_type: 'dynamic',
route: '/mi-cuenta/tickets',
submenues: [],
},
],
},
{
id: 1,
code: 'help',
label: 'Ayuda',
parent_menu_code: null,
content_type: 'dynamic',
route: '/ayuda',
@@ -37,6 +80,7 @@ const tenant: Tenant = {
{
id: 2,
code: 'help.contact',
label: 'Contacto',
parent_menu_code: 'help',
content_type: 'static',
route: '/ayuda/contacto',
@@ -45,6 +89,7 @@ const tenant: Tenant = {
{
id: 3,
code: 'help.faq',
label: 'Preguntas frecuentes',
parent_menu_code: 'help',
content_type: 'static',
route: '/ayuda/preguntas-frecuentes',
@@ -82,10 +127,19 @@ const tenant: Tenant = {
};
describe('StoreLayoutComponent', () => {
let tenantState = signal<Tenant | null>(tenant);
let cartState = signal<Cart | null>(null);
let authUserState = signal<AuthUser | null>(null);
let checkoutServiceStub: { startCheckout: ReturnType<typeof vi.fn> };
beforeEach(async () => {
const tenantState = signal(tenant);
const authUserState = signal<AuthUser | null>(null);
tenantState = signal<Tenant | null>(tenant);
cartState = signal<Cart | null>(null);
authUserState = signal<AuthUser | null>(null);
const isAuthenticatedState = signal(false);
checkoutServiceStub = {
startCheckout: vi.fn().mockResolvedValue({ id: 55 }),
};
await TestBed.configureTestingModule({
imports: [StoreLayoutComponent],
@@ -97,17 +151,20 @@ describe('StoreLayoutComponent', () => {
tenant: tenantState.asReadonly(),
status: signal<'ready'>('ready').asReadonly(),
getTenant: () => tenantState(),
bootstrap: vi.fn().mockResolvedValue(undefined)
}
bootstrap: vi.fn().mockResolvedValue(undefined),
},
},
{
provide: CartService,
useValue: {
cart: signal(null).asReadonly(),
cart: cartState.asReadonly(),
loadCart: () => of({ id: 1, items: [], subtotal: '0' }),
updateItemQuantity: vi.fn().mockReturnValue(of({ data: { id: 1, items: [], subtotal: '0' } })),
removeItem: vi.fn().mockReturnValue(of({ data: { id: 1, items: [], subtotal: '0' } }))
}
updateItemQuantity: vi
.fn()
.mockReturnValue(of({ data: { id: 1, items: [], subtotal: '0' } })),
removeItem: vi.fn().mockReturnValue(of({ data: { id: 1, items: [], subtotal: '0' } })),
clearCart: vi.fn(() => cartState.set(null)),
},
},
{
provide: ToastService,
@@ -116,18 +173,22 @@ describe('StoreLayoutComponent', () => {
info: vi.fn(),
danger: vi.fn(),
success: vi.fn(),
dismiss: vi.fn()
}
dismiss: vi.fn(),
},
},
{
provide: AuthService,
useValue: {
user: authUserState,
isAuthenticated: isAuthenticatedState,
logout: vi.fn().mockReturnValue(of(void 0))
}
}
]
logout: vi.fn().mockReturnValue(of(void 0)),
},
},
{
provide: CheckoutService,
useValue: checkoutServiceStub,
},
],
}).compileComponents();
});
@@ -152,10 +213,14 @@ describe('StoreLayoutComponent', () => {
expect(compiled.querySelector('[data-testid="store-header-brand-slot"]')).not.toBeNull();
expect(compiled.querySelector('[data-testid="store-footer-brand-slot"]')).not.toBeNull();
expect(
compiled.querySelector(`app-store-header img.store-layout__brand-logo[src="${tenant.header_logo}"]`)
compiled.querySelector(
`app-store-header img.store-layout__brand-logo[src="${tenant.header_logo}"]`,
),
).not.toBeNull();
expect(
compiled.querySelector(`app-store-footer img.store-layout__brand-logo[src="${tenant.footer_logo}"]`)
compiled.querySelector(
`app-store-footer img.store-layout__brand-logo[src="${tenant.footer_logo}"]`,
),
).not.toBeNull();
expect(compiled.querySelector('.fa-cart-shopping')).not.toBeNull();
expect(compiled.querySelector('.fa-circle-user')).not.toBeNull();
@@ -165,6 +230,127 @@ describe('StoreLayoutComponent', () => {
expect(compiled.querySelector('.fa-linkedin-in')).not.toBeNull();
});
it('navigates to search results when the search button is clicked', () => {
const router = TestBed.inject(Router);
vi.spyOn(router, 'navigate');
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
const header = fixture.debugElement.query(By.directive(StoreHeaderComponent));
const searchButton = header.query(By.css('.store-layout__search-button'));
(header.componentInstance as any).searchControl.setValue(' zapatillas ');
searchButton.triggerEventHandler('click');
fixture.detectChanges();
expect(router.navigate).toHaveBeenCalledWith(['/buscar'], {
queryParams: { q: 'zapatillas', page: 1 },
});
});
it('prevents the native submit and searches when Enter is pressed', () => {
const router = TestBed.inject(Router);
vi.spyOn(router, 'navigate');
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
const header = fixture.debugElement.query(By.directive(StoreHeaderComponent));
const input = header.query(By.css('#store-search-input'));
const keyboardEvent = {
preventDefault: vi.fn(),
};
(header.componentInstance as any).searchControl.setValue('remera');
input.triggerEventHandler('keydown.enter', keyboardEvent);
expect(keyboardEvent.preventDefault).toHaveBeenCalled();
expect(router.navigate).toHaveBeenCalledWith(['/buscar'], {
queryParams: { q: 'remera', page: 1 },
});
});
it('requires at least three characters to search', () => {
const router = TestBed.inject(Router);
vi.spyOn(router, 'navigate');
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
const header = fixture.debugElement.query(By.directive(StoreHeaderComponent));
const input = header.query(By.css('#store-search-input'));
const searchButton = header.query(By.css('.store-layout__search-button'));
expect(input.nativeElement.getAttribute('minlength')).toBe('3');
(header.componentInstance as any).searchControl.setValue('ab');
searchButton.triggerEventHandler('click');
fixture.detectChanges();
expect(router.navigate).not.toHaveBeenCalled();
expect((fixture.nativeElement as HTMLElement).textContent).toContain(
'Ingresá al menos 3 caracteres para buscar.',
);
expect(input.nativeElement.getAttribute('aria-invalid')).toBe('true');
});
it('renders the primary tickets action when the tenant has the tickets menu', () => {
const router = TestBed.inject(Router);
vi.spyOn(router, 'navigate');
const configuredTicketsLabel = 'Entradas digitales';
tenantState.set({
...tenant,
menues: tenant.menues?.map((menu) =>
menu.code === 'account'
? {
...menu,
submenues: menu.submenues.map((submenu) =>
submenu.code === 'account.tickets'
? { ...submenu, label: configuredTicketsLabel }
: submenu,
),
}
: menu,
),
});
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
const ticketsAction = (fixture.nativeElement as HTMLElement).querySelector<HTMLElement>(
'app-button[data-testid="store-header-tickets"]',
);
const ticketsButton = ticketsAction?.querySelector('button');
expect(ticketsAction).not.toBeNull();
expect(ticketsButton).not.toBeNull();
expect(ticketsButton?.textContent?.trim()).toBe(configuredTicketsLabel);
expect(ticketsButton?.classList).toContain('btn-primary');
ticketsButton?.click();
expect(router.navigate).toHaveBeenCalledWith(['/mi-cuenta/tickets']);
});
it('does not render the tickets action when the tenant lacks the tickets menu', () => {
tenantState.set({
...tenant,
menues: tenant.menues?.map((menu) =>
menu.code === 'account'
? {
...menu,
submenues: menu.submenues.filter((submenu) => submenu.code !== 'account.tickets'),
}
: menu,
),
});
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
expect(
(fixture.nativeElement as HTMLElement).querySelector('[data-testid="store-header-tickets"]'),
).toBeNull();
});
it('redirects to /login when user icon is clicked and user is not logged in', () => {
const authService = TestBed.inject(AuthService);
const router = TestBed.inject(Router);
@@ -176,7 +362,9 @@ describe('StoreLayoutComponent', () => {
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const userButton = compiled.querySelector('app-icon-button[variant="user"] button') as HTMLButtonElement;
const userButton = compiled.querySelector(
'app-icon-button[variant="user"] button',
) as HTMLButtonElement;
expect(userButton).not.toBeNull();
userButton.click();
fixture.detectChanges();
@@ -194,25 +382,33 @@ describe('StoreLayoutComponent', () => {
(authService.user as any).set({
id: 1,
nombre_apellido: 'Ada Lovelace',
email: 'ada@example.com'
email: 'ada@example.com',
});
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const userButton = compiled.querySelector('app-icon-button[variant="user"] button') as HTMLButtonElement;
const userButton = compiled.querySelector(
'app-icon-button[variant="user"] button',
) as HTMLButtonElement;
expect(userButton).not.toBeNull();
userButton.click();
fixture.detectChanges();
expect(compiled.querySelector('app-user-dropdown')).not.toBeNull();
expect(compiled.textContent).toContain('Ada Lovelace');
expect(compiled.querySelector('[data-testid="user-dropdown-account"]')).not.toBeNull();
expect(
compiled.querySelector('[data-testid="user-dropdown-account.purchases"]'),
).not.toBeNull();
expect(compiled.querySelector('[data-testid="user-dropdown-account.tickets"]')).toBeNull();
expect(router.navigate).not.toHaveBeenCalled();
});
it('logs out from the authenticated user dropdown', () => {
it('logs out from the authenticated user dropdown, clears the cart and returns home', () => {
const authService = TestBed.inject(AuthService);
const cartService = TestBed.inject(CartService);
const router = TestBed.inject(Router);
vi.spyOn(router, 'navigate');
@@ -220,28 +416,34 @@ describe('StoreLayoutComponent', () => {
(authService.user as any).set({
id: 1,
nombre_apellido: 'Ada Lovelace',
email: 'ada@example.com'
email: 'ada@example.com',
});
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const userButton = compiled.querySelector('app-icon-button[variant="user"] button') as HTMLButtonElement;
const userButton = compiled.querySelector(
'app-icon-button[variant="user"] button',
) as HTMLButtonElement;
userButton.click();
fixture.detectChanges();
const logoutButton = compiled.querySelector('[data-testid="user-dropdown-logout"]') as HTMLButtonElement;
const logoutButton = compiled.querySelector(
'[data-testid="user-dropdown-logout"]',
) as HTMLButtonElement;
expect(logoutButton).not.toBeNull();
logoutButton.click();
fixture.detectChanges();
expect(authService.logout).toHaveBeenCalled();
expect(router.navigate).toHaveBeenCalledWith(['/login']);
expect(cartService.clearCart).toHaveBeenCalled();
expect(router.navigate).toHaveBeenCalledWith(['/']);
});
it('provides account actions from the footer', () => {
const authService = TestBed.inject(AuthService);
const cartService = TestBed.inject(CartService);
const router = TestBed.inject(Router);
vi.spyOn(router, 'navigate');
@@ -252,21 +454,44 @@ describe('StoreLayoutComponent', () => {
const footerLinks = Array.from(
compiled.querySelectorAll<HTMLAnchorElement>('app-store-footer a.store-layout__footer-link'),
);
const accountLink = footerLinks.find((link) => link.textContent?.trim() === 'Mi cuenta');
const profileLink = footerLinks.find((link) => link.textContent?.trim() === 'Mi cuenta');
const purchasesLink = footerLinks.find((link) => link.textContent?.trim() === 'Mis compras');
const ticketsLink = footerLinks.find((link) => link.textContent?.trim() === 'Mis tickets');
const logoutButton = Array.from(
compiled.querySelectorAll<HTMLButtonElement>('app-store-footer button'),
).find((button) => button.textContent?.trim() === 'Cerrar sesion');
expect(accountLink?.getAttribute('href')).toBe('/mi-cuenta/datos-personales');
expect(profileLink?.getAttribute('href')).toBe('/mi-cuenta/datos-personales');
expect(purchasesLink?.getAttribute('href')).toBe('/mi-cuenta/compras');
expect(ticketsLink).toBeUndefined();
expect(logoutButton).toBeDefined();
logoutButton!.click();
fixture.detectChanges();
expect(authService.logout).toHaveBeenCalled();
expect(router.navigate).toHaveBeenCalledWith(['/login']);
expect(cartService.clearCart).toHaveBeenCalled();
expect(router.navigate).toHaveBeenCalledWith(['/']);
});
it('leaves checkout before logging out so the pending purchase can be cancelled', async () => {
const authService = TestBed.inject(AuthService);
const cartService = TestBed.inject(CartService);
const router = TestBed.inject(Router);
vi.spyOn(router, 'url', 'get').mockReturnValue('/checkout?purchase=25');
const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
await (fixture.componentInstance as any).onLogoutClick();
expect(navigateSpy).toHaveBeenCalledWith(['/']);
expect(authService.logout).toHaveBeenCalled();
expect(navigateSpy.mock.invocationCallOrder[0]).toBeLessThan(
(authService.logout as any).mock.invocationCallOrder[0],
);
expect(cartService.clearCart).toHaveBeenCalled();
});
it('renders only the help submenus assigned to the tenant in the footer', () => {
@@ -275,11 +500,11 @@ describe('StoreLayoutComponent', () => {
const compiled = fixture.nativeElement as HTMLElement;
const footerLinks = Array.from(
compiled.querySelectorAll<HTMLAnchorElement>(
'app-store-footer a.store-layout__footer-link',
),
compiled.querySelectorAll<HTMLAnchorElement>('app-store-footer a.store-layout__footer-link'),
);
const helpLinks = footerLinks.filter((link) =>
link.getAttribute('href')?.startsWith('/ayuda/'),
);
const helpLinks = footerLinks.filter((link) => link.getAttribute('href')?.startsWith('/ayuda/'));
expect(helpLinks.map((link) => link.textContent?.trim())).toEqual([
'Contacto',
@@ -292,9 +517,35 @@ describe('StoreLayoutComponent', () => {
expect(compiled.textContent).not.toContain('Medios de pago');
});
it('redirects to /checkout when cart buy button is clicked', () => {
it('creates a purchase and redirects to checkout when cart buy is clicked', async () => {
const router = TestBed.inject(Router);
vi.spyOn(router, 'navigate');
cartState.set({
id: 1,
tenant_codigo: tenant.codigo,
status: 'active',
subtotal: '100.00',
items: [
{
id: 1,
cantidad: 1,
precio_unitario: '100.00',
catalog_item_id: 1,
variant_id: null,
product: {
nombre: 'Producto',
imagen: null,
},
},
],
});
authUserState.set({
id: 7,
nombre_apellido: 'Juan Perez',
email: 'juan@example.com',
dni: '12345678',
telefono: '3415555555',
});
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
@@ -303,14 +554,58 @@ describe('StoreLayoutComponent', () => {
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const buyButton = Array.from(compiled.querySelectorAll('app-button button'))
.find((button) => button.textContent?.trim() === 'Comprar') as HTMLButtonElement | undefined;
const buyButton = Array.from(compiled.querySelectorAll('app-button button')).find(
(button) => button.textContent?.trim() === 'Comprar',
) as HTMLButtonElement | undefined;
expect(buyButton).toBeDefined();
buyButton!.click();
await Promise.resolve();
fixture.detectChanges();
expect(router.navigate).toHaveBeenCalledWith(['/checkout']);
expect(checkoutServiceStub.startCheckout).toHaveBeenCalledWith('test', {
cart_id: 1,
});
expect(router.navigate).toHaveBeenCalledWith(['/checkout'], {
queryParams: { purchase: 55 },
});
expect((fixture.componentInstance as any).isCartOpen()).toBe(false);
});
it('allows modifying quantities directly in the regular cart without a toggle', () => {
cartState.set({
id: 1,
tenant_codigo: tenant.codigo,
status: 'active',
subtotal: '100.00',
items: [
{
id: 1,
cantidad: 1,
precio_unitario: '100.00',
catalog_item_id: 1,
variant_id: null,
product: {
nombre: 'Producto',
imagen: null,
},
},
],
});
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
(fixture.componentInstance as any).isCartOpen.set(true);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const cartItem = fixture.debugElement.query(By.css('app-cart-item'));
const buyButton = Array.from(
element.querySelectorAll<HTMLButtonElement>('app-button button'),
).find((button) => button.textContent?.trim() === 'Comprar');
expect(element.querySelector('.cart-edit-btn')).toBeNull();
expect(cartItem.componentInstance.quantityDisabled()).toBe(false);
expect(buyButton).toBeDefined();
});
});

View File

@@ -8,7 +8,10 @@ import { CartComponent, CartItemMock } from '../../../shared/components/cart/car
import { ButtonComponent } from '../../../shared/components/button/button.component';
import { CartItem } from '../../services/cart/cart.interface';
import { AuthService } from '../../services/auth/auth.service';
import { findMenu, getMenuLabel } from '../../services/menu.utils';
import { findMenu } from '../../services/menu.utils';
import { CheckoutService } from '../../services/checkout.service';
import { ToastService } from '../../services/toast.service';
import { Category } from '../../services/tenant.interface';
@Component({
selector: 'app-store-layout',
@@ -26,9 +29,12 @@ export class StoreLayoutComponent implements OnInit {
private readonly tenantService = inject(TenantService);
private readonly cartService = inject(CartService);
private readonly authService = inject(AuthService);
private readonly checkoutService = inject(CheckoutService);
private readonly toastService = inject(ToastService);
private readonly router = inject(Router);
protected readonly isCartOpen = signal(false);
protected readonly isCreatingPurchase = signal(false);
protected readonly cartSubtotal = computed(() => {
const cart = this.cartService.cart();
@@ -79,6 +85,24 @@ export class StoreLayoutComponent implements OnInit {
protected readonly tenant = this.tenantService.tenant;
protected readonly user = this.authService.user;
protected readonly isAuthenticated = this.authService.isAuthenticated;
protected readonly ticketsMenu = computed(() =>
findMenu(this.tenant()?.menues ?? [], 'account.tickets'),
);
protected readonly accountNavigationMenus = computed(() => {
const accountMenu = findMenu(this.tenant()?.menues ?? [], 'account');
if (!accountMenu) {
return [];
}
const profile = accountMenu.submenues.find((menu) => menu.code === 'account.profile');
const purchases = accountMenu.submenues.find((menu) => menu.code === 'account.purchases');
return [
...(profile ? [{ ...accountMenu, route: profile.route }] : []),
...(purchases ? [purchases] : []),
];
});
protected readonly cartQuantity = computed(() => {
const cart = this.cartService.cart();
@@ -96,35 +120,109 @@ export class StoreLayoutComponent implements OnInit {
void this.router.navigate(['/login']);
}
protected onLogoutClick(): void {
protected onSearch(term: string): void {
void this.router.navigate(['/buscar'], {
queryParams: { q: term, page: 1 },
});
}
protected onCategorySelect(category: Category): void {
void this.router.navigate(['/categoria', category.id], {
queryParams: { page: 1 },
});
}
protected onTicketsClick(): void {
const route = this.ticketsMenu()?.route;
if (route) {
void this.router.navigate([route]);
}
}
protected async onLogoutClick(): Promise<void> {
const isLeavingCheckout = this.router.url.startsWith('/checkout');
// Checkout must be left while the authenticated session is still valid so
// its CanDeactivate guard can cancel the pending purchase.
if (isLeavingCheckout) {
const navigationSucceeded = await this.router.navigate(['/']);
if (!navigationSucceeded) {
return;
}
}
this.authService.logout().subscribe({
next: () => void this.router.navigate(['/login']),
next: () => {
this.cartService.clearCart();
this.isCartOpen.set(false);
if (!isLeavingCheckout) {
void this.router.navigate(['/']);
}
},
error: (err) => console.error('Error logging out', err),
});
}
protected onCheckoutClick(): void {
this.isCartOpen.set(false);
void this.router.navigate(['/checkout']);
protected async onCheckoutClick(): Promise<void> {
if (this.isCreatingPurchase()) {
return;
}
const cart = this.cartService.cart();
const tenant = this.tenantService.tenant();
const user = this.authService.user();
if (!user) {
this.isCartOpen.set(false);
void this.router.navigate(['/login'], { queryParams: { returnUrl: '/' } });
return;
}
if (!cart?.id || !tenant) {
this.toastService.danger('No hay un carrito activo para iniciar la compra.');
return;
}
this.isCreatingPurchase.set(true);
try {
const purchase = await this.checkoutService.startCheckout(tenant.codigo, {
cart_id: cart.id,
});
this.cartService.clearCart();
this.isCartOpen.set(false);
await this.router.navigate(['/checkout'], {
queryParams: { purchase: purchase.id },
});
} catch (error) {
console.error('Failed to create cart purchase:', error);
this.toastService.danger('No se pudo iniciar la compra.');
} finally {
this.isCreatingPurchase.set(false);
}
}
protected readonly footerSections = computed<StoreFooterSection[]>(() => {
const helpSubmenues =
findMenu(this.tenant()?.menues ?? [], 'help')?.submenues ?? [];
const helpSubmenues = findMenu(this.tenant()?.menues ?? [], 'help')?.submenues ?? [];
return [
{
heading: 'Cuenta',
links: [
{ label: 'Mi cuenta', routerLink: '/mi-cuenta/datos-personales' },
{ label: 'Mis compras', routerLink: '/mi-cuenta/compras' },
...this.accountNavigationMenus().map((menu) => ({
label: menu.label,
routerLink: menu.route,
})),
{ label: 'Cerrar sesion', action: 'logout' },
],
},
{
heading: 'Ayuda',
links: helpSubmenues.map((menu) => ({
label: getMenuLabel(menu),
label: menu.label,
routerLink: menu.route,
})),
},

View File

@@ -23,10 +23,14 @@ describe('auth guards', () => {
providers: [provideRouter([]), { provide: AuthService, useValue: createAuthServiceStub() }]
});
const result = TestBed.runInInjectionContext(() => authGuard(null as never, null as never));
const result = TestBed.runInInjectionContext(() =>
authGuard(null as never, { url: '/checkout?mode=direct' } as never),
);
expect(result instanceof UrlTree).toBe(true);
expect(TestBed.inject(Router).serializeUrl(result as UrlTree)).toBe('/login');
expect(TestBed.inject(Router).serializeUrl(result as UrlTree)).toBe(
'/login?returnUrl=%2Fcheckout%3Fmode%3Ddirect',
);
});
it('allows authenticated users through authGuard', () => {
@@ -34,7 +38,9 @@ describe('auth guards', () => {
providers: [provideRouter([]), { provide: AuthService, useValue: createAuthServiceStub(true) }]
});
const result = TestBed.runInInjectionContext(() => authGuard(null as never, null as never));
const result = TestBed.runInInjectionContext(() =>
authGuard(null as never, { url: '/checkout' } as never),
);
expect(result).toBe(true);
});

View File

@@ -3,11 +3,15 @@ import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from './auth.service';
export const authGuard: CanActivateFn = () => {
export const authGuard: CanActivateFn = (_route, state) => {
const authService = inject(AuthService);
const router = inject(Router);
return authService.isAuthenticated() ? true : router.createUrlTree(['/login']);
return authService.isAuthenticated()
? true
: router.createUrlTree(['/login'], {
queryParams: { returnUrl: state.url },
});
};
export const guestOnlyGuard: CanActivateFn = () => {

View File

@@ -37,3 +37,25 @@ export interface RegisterResponse {
message: string;
data: AuthUser;
}
export interface ResetPasswordAttemptResponse {
message: string;
status: 'pending';
}
export interface ValidateResetPasswordAttemptResponse {
message: string;
status: 'validated';
}
export interface ResetPasswordPayload {
email: string;
codigo: string;
password: string;
password_confirmation: string;
}
export interface ResetPasswordResponse {
message: string;
status: 'used';
}

View File

@@ -19,8 +19,12 @@ describe('AuthService', () => {
function createCookieServiceStub() {
return {
get: (name: string) => cookieStore[name] || null,
set: (name: string, value: string) => { cookieStore[name] = value; },
delete: (name: string) => { delete cookieStore[name]; }
set: (name: string, value: string) => {
cookieStore[name] = value;
},
delete: (name: string) => {
delete cookieStore[name];
}
};
}
@@ -31,6 +35,10 @@ describe('AuthService', () => {
provideHttpClientTesting(),
AuthService,
{ provide: CookieService, useValue: createCookieServiceStub() },
{
provide: TenantService,
useValue: { getTenant: () => ({ codigo: 'tenant-test' }) }
},
TransferState
]
});
@@ -44,6 +52,12 @@ describe('AuthService', () => {
const request = httpController.expectOne(`${environment.url}login`);
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual({
email: 'ada@example.com',
password: 'secret123',
tenant_codigo: 'tenant-test'
});
expect(request.request.withCredentials).toBe(true);
request.flush({
message: 'Sesion iniciada correctamente.',
token: 'plain-text-token',
@@ -96,6 +110,47 @@ describe('AuthService', () => {
httpController.verify();
});
it('sends tenant and credentials when completing Google login', () => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
AuthService,
{ provide: CookieService, useValue: createCookieServiceStub() },
{
provide: TenantService,
useValue: { getTenant: () => ({ codigo: 'tenant-test' }) }
},
TransferState
]
});
const service = TestBed.inject(AuthService);
const httpController = TestBed.inject(HttpTestingController);
service.completeGoogleLogin('oauth-code').subscribe();
const request = httpController.expectOne(`${environment.url}auth/google/exchange`);
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual({
oauth_code: 'oauth-code',
tenant_codigo: 'tenant-test'
});
expect(request.request.withCredentials).toBe(true);
request.flush({
message: 'Sesion iniciada correctamente.',
token: 'google-token',
token_type: 'Bearer',
user: {
id: 1,
nombre_apellido: 'Ada Lovelace',
email: 'ada@example.com'
}
});
httpController.verify();
});
it('clears an expired session when /me returns 401', async () => {
cookieStore['shopit.auth.token'] = 'expired-token';
@@ -174,6 +229,121 @@ describe('AuthService', () => {
httpController.verify();
});
it('requests a password reset and exposes the final HTTP status', () => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
AuthService,
{ provide: CookieService, useValue: createCookieServiceStub() },
{
provide: TenantService,
useValue: { getTenant: () => ({ codigo: 'tenant-test' }) }
},
TransferState
]
});
const service = TestBed.inject(AuthService);
const httpController = TestBed.inject(HttpTestingController);
service.requestPasswordReset('ada@example.com').subscribe((response) => {
expect(response.status).toBe(202);
expect(response.body?.status).toBe('pending');
});
const request = httpController.expectOne(`${environment.url}password/reset-attempts`);
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual({
email: 'ada@example.com',
tenant_codigo: 'tenant-test'
});
request.flush(
{
message: 'Si el email está registrado, recibirás un código.',
status: 'pending'
},
{ status: 202, statusText: 'Accepted' }
);
httpController.verify();
});
it('validates a password reset code and exposes its final status', () => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
AuthService,
{ provide: CookieService, useValue: createCookieServiceStub() },
TransferState
]
});
const service = TestBed.inject(AuthService);
const httpController = TestBed.inject(HttpTestingController);
service.validatePasswordResetCode('ada@example.com', '0123').subscribe((response) => {
expect(response.status).toBe(200);
expect(response.body?.status).toBe('validated');
});
const request = httpController.expectOne(`${environment.url}password/reset-attempts/validate`);
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual({
email: 'ada@example.com',
codigo: '0123'
});
request.flush(
{
message: 'Código validado correctamente.',
status: 'validated'
},
{ status: 200, statusText: 'OK' }
);
httpController.verify();
});
it('resets the password and exposes the consumed attempt status', () => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
AuthService,
{ provide: CookieService, useValue: createCookieServiceStub() },
TransferState
]
});
const service = TestBed.inject(AuthService);
const httpController = TestBed.inject(HttpTestingController);
const payload = {
email: 'ada@example.com',
codigo: '0123',
password: 'NewSecret!456',
password_confirmation: 'NewSecret!456'
};
service.resetPassword(payload).subscribe((response) => {
expect(response.status).toBe(200);
expect(response.body?.status).toBe('used');
});
const request = httpController.expectOne(`${environment.url}password/reset`);
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual(payload);
request.flush(
{
message: 'Contraseña modificada correctamente.',
status: 'used'
},
{ status: 200, statusText: 'OK' }
);
httpController.verify();
});
it('propagates logout errors without clearing the local session', () => {
TestBed.configureTestingModule({
providers: [
@@ -181,6 +351,10 @@ describe('AuthService', () => {
provideHttpClientTesting(),
AuthService,
{ provide: CookieService, useValue: createCookieServiceStub() },
{
provide: TenantService,
useValue: { getTenant: () => ({ codigo: 'tenant-test' }) }
},
TransferState
]
});

View File

@@ -1,17 +1,22 @@
import { DOCUMENT } from '@angular/common';
import { HttpClient, HttpErrorResponse } 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,
LoginResponse,
RegisterPayload,
RegisterResponse,
UpdateProfilePayload
ResetPasswordPayload,
ResetPasswordAttemptResponse,
ResetPasswordResponse,
UpdateProfilePayload,
ValidateResetPasswordAttemptResponse
} from './auth.interfaces';
import { CookieService } from '../cookie/cookie.service';
import { TenantService } from '../tenant.service';
@@ -22,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);
@@ -38,18 +42,70 @@ export class AuthService {
readonly isAuthenticated = computed(() => this.tokenState() !== null);
login(payload: LoginPayload): Observable<AuthUser> {
return this.http.post<LoginResponse>(`${environment.url}login`, payload).pipe(
tap((response) => this.applyAuthenticatedState(response.token, response.user)),
map((response) => response.user)
);
const tenant = this.tenantService.getTenant();
if (!tenant) {
throw new Error('No se pudo resolver el tenant activo.');
}
return this.http
.post<LoginResponse>(
`${environment.url}login`,
{ ...payload, tenant_codigo: tenant.codigo },
{
withCredentials: true
}
)
.pipe(
tap((response) => this.applyAuthenticatedState(response.token, response.user)),
map((response) => response.user)
);
}
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>> {
const tenantCode = this.tenantService.getTenant()?.codigo;
return this.http.post<ResetPasswordAttemptResponse>(
`${environment.url}password/reset-attempts`,
{
email,
...(tenantCode ? { tenant_codigo: tenantCode } : {})
},
{
observe: 'response'
}
);
}
validatePasswordResetCode(
email: string,
codigo: string
): Observable<HttpResponse<ValidateResetPasswordAttemptResponse>> {
return this.http.post<ValidateResetPasswordAttemptResponse>(
`${environment.url}password/reset-attempts/validate`,
{ email, codigo },
{
observe: 'response'
}
);
}
resetPassword(payload: ResetPasswordPayload): Observable<HttpResponse<ResetPasswordResponse>> {
return this.http.post<ResetPasswordResponse>(`${environment.url}password/reset`, payload, {
observe: 'response'
});
}
@@ -68,16 +124,29 @@ export class AuthService {
}
completeGoogleLogin(oauthCode: string): Observable<AuthUser> {
return this.http.post<LoginResponse>(`${environment.url}auth/google/exchange`, { oauth_code: oauthCode }).pipe(
tap((response) => this.applyAuthenticatedState(response.token, response.user)),
map((response) => response.user)
);
const tenant = this.tenantService.getTenant();
if (!tenant) {
throw new Error('No se pudo resolver el tenant activo.');
}
return this.http
.post<LoginResponse>(
`${environment.url}auth/google/exchange`,
{ oauth_code: oauthCode, tenant_codigo: tenant.codigo },
{
withCredentials: true
}
)
.pipe(
tap((response) => this.applyAuthenticatedState(response.token, response.user)),
map((response) => response.user)
);
}
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> {
@@ -86,9 +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> {
@@ -127,9 +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

@@ -36,6 +36,10 @@ export type InventoryPolicy = 'tracked' | 'unlimited';
export interface CatalogItemVariant {
id: number;
stock_tecnico: number | null;
minimum_use_date?: string | null;
maximum_use_date?: string | null;
effective_minimum_use_date?: string | null;
effective_maximum_use_date?: string | null;
values: Record<string, string>;
}
@@ -84,7 +88,8 @@ export interface CatalogFeaturedItem {
}
export type CatalogFeaturedItems =
ApiPaginatedResponse<CatalogFeaturedItem[]> | CatalogFeaturedItem[];
| ApiPaginatedResponse<CatalogFeaturedItem[]>
| CatalogFeaturedItem[];
export interface CatalogFeaturedGroup {
id: number;
@@ -94,3 +99,15 @@ export interface CatalogFeaturedGroup {
group_order: number;
items: CatalogFeaturedItems;
}
export interface CatalogCategory {
id: number;
nombre: string;
categoria_id: number | null;
}
export interface CategoryItemsResponse extends ApiPaginatedResponse<CatalogFeaturedItem[]> {
category: CatalogCategory;
layout: CatalogProductLayout;
group_layout: CatalogGroupLayout;
}

View File

@@ -1,25 +1,31 @@
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,
CatalogFeaturedItem,
CatalogFeaturedItems,
CatalogItemDetail,
CategoryItemsResponse,
Product,
} from './catalog.interface';
type HttpParamValue = string | number | boolean | readonly (string | number | boolean)[];
export interface CatalogSearchQueryParams extends ApiPaginationQueryParams {
q: string;
}
@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 {
@@ -36,24 +42,51 @@ export class CatalogService {
return this.http.get<CatalogFeaturedGroup[]>(`${this.tenantApiUrl}/catalog`);
}
searchCatalog(
params: CatalogSearchQueryParams,
): Observable<ApiPaginatedResponse<CatalogFeaturedItem[]>> {
return this.http.get<ApiPaginatedResponse<CatalogFeaturedItem[]>>(
`${this.tenantApiUrl}/catalog-items`,
{
params: this.buildHttpParams(params),
},
);
}
getCategoryItems(
categoryId: number,
params?: ApiPaginationQueryParams,
): Observable<CategoryItemsResponse> {
return this.http.get<CategoryItemsResponse>(`${this.tenantApiUrl}/categories/${categoryId}`, {
params: this.buildHttpParams(params),
});
}
getFeaturedGroupItems(
featuredGroupId: number,
params?: ApiPaginationQueryParams,
): 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,17 +1,28 @@
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 CreatePurchasePayload {
cart_id: number;
export interface UpdatePurchaseCustomerPayload {
dni: string;
telefono: string;
nombre_apellido: string;
email: string;
}
export type StartCheckoutPayload =
| {
cart_id: number;
}
| {
direct_item: {
catalog_item_id: number;
variant_id: number | null;
cantidad: number;
};
};
export interface PurchaseStatusResponse {
status: string | null;
}
@@ -57,22 +68,28 @@ export interface PurchaseDetailResponse extends PurchaseStatusResponse {
email: string | null;
items_source: 'purchase';
items: PurchaseDetailItemResponse[];
tickets_count?: number;
has_generated_tickets?: boolean;
subtotal: string;
total: string;
}
@Injectable({
providedIn: 'root'
providedIn: 'root',
})
export class CheckoutService {
private readonly http = inject(HttpClient);
async createPurchase(tenantCode: string, payload: CreatePurchasePayload): Promise<{ id: number }> {
export class CheckoutService extends BaseApiService {
async startCheckout(
tenantCode: string,
payload: StartCheckoutPayload,
): Promise<PurchaseDetailResponse> {
const response = await firstValueFrom(
this.http.post<{ data?: { id: number }; id?: number }>(`${environment.url}tenants/${tenantCode}/compras`, payload)
this.http.post<{ data?: PurchaseDetailResponse } | PurchaseDetailResponse>(
`${environment.url}tenants/${tenantCode}/compras/start-checkout`,
payload,
),
);
const purchase = this.extractResponseData<{ id: number }>(response);
const purchase = this.extractResponseData<PurchaseDetailResponse>(response);
if (!purchase?.id) {
throw new Error('Error al crear la compra.');
@@ -81,13 +98,21 @@ export class CheckoutService {
return purchase;
}
async generatePaymentIntent(tenantCode: string, purchaseId: number, method: 'qr' | 'transfer' | 'telepagos', payerDni?: string): Promise<any> {
async generatePaymentIntent(
tenantCode: string,
purchaseId: number,
method: 'qr' | 'transfer' | 'telepagos',
payerDni?: string,
): Promise<any> {
const payload: any = { method };
if (payerDni) {
payload.transfer_payer_dni = payerDni;
}
const response = await firstValueFrom(
this.http.post<any>(`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/payment-intent`, payload)
this.http.post<any>(
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/payment-intent`,
payload,
),
);
if (!response) {
throw new Error('Error al generar la intención de pago.');
@@ -95,9 +120,75 @@ export class CheckoutService {
return response;
}
async completePurchase(tenantCode: string, purchaseId: number): Promise<PurchaseStatusResponse> {
async updateCustomerData(
tenantCode: string,
purchaseId: number,
payload: UpdatePurchaseCustomerPayload,
): Promise<PurchaseDetailResponse> {
const response = await firstValueFrom(
this.http.post<{ data?: PurchaseStatusResponse } | PurchaseStatusResponse>(`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/complete`, {})
this.http.patch<{ data?: PurchaseDetailResponse } | PurchaseDetailResponse>(
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/customer-data`,
payload,
),
);
const purchase = this.extractResponseData<PurchaseDetailResponse>(response);
if (!purchase) {
throw new Error('Error al actualizar los datos de la compra.');
}
return purchase;
}
async updateItemQuantity(
tenantCode: string,
purchaseId: number,
itemId: number,
quantity: number,
): Promise<PurchaseDetailResponse> {
const response = await firstValueFrom(
this.http.patch<{ data?: PurchaseDetailResponse } | PurchaseDetailResponse>(
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/items/${itemId}`,
{ quantity },
),
);
const purchase = this.extractResponseData<PurchaseDetailResponse>(response);
if (!purchase) {
throw new Error('Error al actualizar la cantidad del producto.');
}
return purchase;
}
async prepareItemEditing(
tenantCode: string,
purchaseId: number,
): Promise<PurchaseDetailResponse> {
const response = await firstValueFrom(
this.http.post<{ data?: PurchaseDetailResponse } | PurchaseDetailResponse>(
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/edit-items`,
{},
),
);
const purchase = this.extractResponseData<PurchaseDetailResponse>(response);
if (!purchase) {
throw new Error('Error al preparar la compra para editarla.');
}
return purchase;
}
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`,
{},
),
);
const purchase = this.extractResponseData<PurchaseStatusResponse>(response);
@@ -107,17 +198,58 @@ export class CheckoutService {
}
return {
status: purchase.status ?? null
status: purchase.status ?? null,
};
}
async getPurchases(tenantCode: string, status?: string): Promise<{ data: PurchaseSummaryResponse[] }> {
async submitPurchaseForReview(
tenantCode: string,
purchaseId: number,
): Promise<PurchaseStatusResponse> {
const response = await firstValueFrom(
this.http.post<{ data?: PurchaseStatusResponse } | PurchaseStatusResponse>(
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/review`,
{},
),
);
const purchase = this.extractResponseData<PurchaseStatusResponse>(response);
if (!purchase) {
throw new Error('Error al enviar la compra a revisi\u00f3n.');
}
return { status: purchase.status ?? null };
}
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`,
{},
),
);
const purchase = this.extractResponseData<PurchaseStatusResponse>(response);
if (!purchase) {
throw new Error('Error al cancelar la compra.');
}
return { status: purchase.status ?? null };
}
async getPurchases(
tenantCode: string,
status?: string,
): Promise<{ data: PurchaseSummaryResponse[] }> {
let url = `${environment.url}tenants/${tenantCode}/compras`;
if (status) {
url += `?status=${status}`;
}
const response = await firstValueFrom(
this.http.get<{ data: PurchaseSummaryResponse[] }>(url)
this.http.get<{ data: PurchaseSummaryResponse[] }>(url),
);
if (!response) {
throw new Error('Error al obtener las compras.');
@@ -125,9 +257,14 @@ export class CheckoutService {
return response;
}
async getPurchase(tenantCode: string, purchaseId: string | number): Promise<PurchaseDetailResponse> {
async getPurchase(
tenantCode: string,
purchaseId: string | number,
): Promise<PurchaseDetailResponse> {
const response = await firstValueFrom(
this.http.get<{ data?: PurchaseDetailResponse } | PurchaseDetailResponse>(`${environment.url}tenants/${tenantCode}/compras/${purchaseId}`)
this.http.get<{ data?: PurchaseDetailResponse } | PurchaseDetailResponse>(
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}`,
),
);
const purchase = this.extractResponseData<PurchaseDetailResponse>(response);

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,13 +1,5 @@
import { Menu } from './tenant.interface';
const MENU_LABELS: Record<string, string> = {
'help.faq': 'Preguntas frecuentes',
'help.contact': 'Contacto',
'help.payment-methods': 'Medios de pago',
'help.shipping': 'Envíos',
'help.terms-and-conditions': 'Términos y condiciones',
};
export function findMenu(menues: readonly Menu[], menuCode: string): Menu | undefined {
for (const menu of menues) {
if (menu.code === menuCode) {
@@ -23,15 +15,3 @@ export function findMenu(menues: readonly Menu[], menuCode: string): Menu | unde
return undefined;
}
export function getMenuLabel(menu: Menu): string {
const configuredLabel = MENU_LABELS[menu.code];
if (configuredLabel) {
return configuredLabel;
}
const label = menu.code.split('.').at(-1)?.replaceAll('-', ' ') ?? menu.code;
return label.charAt(0).toUpperCase() + label.slice(1);
}

View File

@@ -26,6 +26,7 @@ export interface EventConfig {
export interface Menu {
id: number;
code: string;
label: string;
parent_menu_code: string | null;
content_type: 'static' | 'dynamic';
route: string;
@@ -40,6 +41,12 @@ export interface SocialMedia {
url: string;
}
export interface Category {
id: number;
nombre: string;
subcategories: Category[];
}
export interface Tenant {
id: number;
codigo: string;
@@ -57,10 +64,13 @@ export interface Tenant {
selected_bank_account?: BankAccount | null;
hero_config?: HeroConfig | null;
event_config?: EventConfig | null;
search_product_layout?: 'row' | 'column_with_image' | 'column_with_cart';
search_group_layout?: 'paginated' | 'simple' | 'simple_vertical' | 'carousel';
search_items_per_page?: number;
main_carousel_images?: string[];
social_media?: SocialMedia[];
menues?: Menu[];
categories: Category[];
}
export type TenantBootstrapResponse = ApiResponse<Tenant>;

View File

@@ -20,7 +20,20 @@ const tenant: Tenant = {
header_bg_color: '#313131',
footer_bg_color: '#313131',
header_logo: 'https://example.com/header.png',
footer_logo: 'https://example.com/footer.png'
footer_logo: 'https://example.com/footer.png',
categories: [
{
id: 1,
nombre: 'Remeras',
subcategories: [
{
id: 2,
nombre: 'Manga corta',
subcategories: []
}
]
}
]
};
const tenantResponse: TenantBootstrapResponse = {

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

@@ -398,8 +398,8 @@
<div class="accordion-showcase">
<h2>Accordion</h2>
<p class="text-muted mb-4">
Accordion reutilizable basado en las clases globales de Bootstrap. Por defecto mantiene
un solo item abierto.
Accordion reutilizable basado en las clases globales de Bootstrap. Por defecto mantiene un
solo item abierto.
</p>
<app-accordion>
@@ -409,8 +409,8 @@
</app-accordion-item>
<app-accordion-item title="¿Qué medios de pago están disponibles?">
Los medios de pago dependen de la configuración de cada tienda y se muestran durante
el checkout.
Los medios de pago dependen de la configuración de cada tienda y se muestran durante el
checkout.
</app-accordion-item>
<app-accordion-item title="Item deshabilitado" [disabled]="true">
@@ -692,6 +692,23 @@
</div>
</section>
<section class="component-demo card shadow-sm mt-4" data-testid="location-map-demo">
<div class="card-body">
<span class="eyebrow">Reusable Component</span>
<h2 class="mb-2">Mapa de ubicaciones</h2>
<p class="text-muted mb-4">
Prueba de Leaflet con múltiples puntos, encuadre automático y acceso a indicaciones.
</p>
<app-location-map
[locations]="mapLocations"
[markerColor]="tenant()?.primary_color ?? '#24a9e0'"
mapStyle="minimal-light"
ariaLabel="Mapa de puntos de atención en Rosario"
/>
</div>
</section>
<section class="component-demo card shadow-sm mt-4">
<div class="card-body">
<span class="eyebrow">Reusable Component</span>

View File

@@ -23,6 +23,7 @@ const tenant: Tenant = {
footer_bg_color: '#313131',
header_logo: 'https://example.com/header.png',
footer_logo: 'https://example.com/footer.png',
categories: [],
};
function createTenantServiceStub(currentTenant: Tenant | null) {
@@ -141,6 +142,40 @@ describe('ReutilizablesTestPageComponent', () => {
);
});
it('renders the reusable map demo with all configured locations', async () => {
await TestBed.configureTestingModule({
imports: [ReutilizablesTestPageComponent],
providers: [
{
provide: TenantService,
useValue: createTenantServiceStub(tenant),
},
{
provide: ToastService,
useValue: createToastServiceStub(),
},
{
provide: ModalService,
useValue: createModalServiceStub(),
},
],
}).compileComponents();
const fixture = TestBed.createComponent(ReutilizablesTestPageComponent);
fixture.detectChanges();
const mapDemo = fixture.nativeElement.querySelector(
'[data-testid="location-map-demo"]',
) as HTMLElement;
const accessibleLocations = mapDemo.querySelectorAll('app-location-map .visually-hidden li');
expect(mapDemo.querySelector('app-location-map')).not.toBeNull();
expect(accessibleLocations).toHaveLength(3);
expect(mapDemo.textContent).toContain('Gigante de Arroyito');
fixture.destroy();
});
it('renders the carousel demo with mocked product images', async () => {
await TestBed.configureTestingModule({
imports: [ReutilizablesTestPageComponent],

View File

@@ -18,6 +18,10 @@ import { CarouselComponent } from '../../../../shared/components/carousel/carous
import { MainCarouselComponent } from '../../../../shared/components/main-carousel/main-carousel.component';
import { AccordionComponent } from '../../../../shared/components/accordion/accordion.component';
import { AccordionItemComponent } from '../../../../shared/components/accordion/accordion-item.component';
import {
LocationMapComponent,
MapLocation,
} from '../../../../shared/components/location-map/location-map.component';
interface CarouselProductMock {
id: number;
@@ -47,6 +51,7 @@ interface CarouselProductMock {
MainCarouselComponent,
AccordionComponent,
AccordionItemComponent,
LocationMapComponent,
],
templateUrl: './reutilizables-test-page.component.html',
styleUrl: './reutilizables-test-page.component.scss',
@@ -79,6 +84,29 @@ export class ReutilizablesTestPageComponent {
protected lastModalResult = 'Todavia no se abrio ningun modal.';
protected readonly paginatorTotalPages = 8;
protected readonly cartBackgroundColor = '#ffffff';
protected readonly mapLocations: readonly MapLocation[] = [
{
id: 'gigante-de-arroyito',
label: 'Gigante de Arroyito',
address: 'Av. Génova 640, Rosario',
latitude: -32.913997,
longitude: -60.674567,
},
{
id: 'punto-centro',
label: 'Punto Centro',
address: 'Centro, Rosario',
latitude: -32.94682,
longitude: -60.63932,
},
{
id: 'punto-sur',
label: 'Punto Sur',
address: 'Zona sur, Rosario',
latitude: -32.97151,
longitude: -60.65064,
},
];
protected readonly mainCarouselImages = [
'https://images.unsplash.com/photo-1441986300917-64674bd600d8?auto=format&fit=crop&w=1600&q=80',
'https://images.unsplash.com/photo-1529139574466-a303027c1d8b?auto=format&fit=crop&w=1600&q=80',

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">MI CUENTA</h2>
<div class="divider"></div>
<nav class="sidebar-nav">
<a routerLink="/mi-cuenta/datos-personales" routerLinkActive="active" class="nav-link"
>Datos Personales</a
>
<a routerLink="/mi-cuenta/compras" routerLinkActive="active" class="nav-link">Mis compras</a>
<a routerLink="/mi-cuenta/tickets" routerLinkActive="active" class="nav-link">Mis tickets</a>
</nav>
</div>

View File

@@ -1,34 +0,0 @@
.sidebar-container {
display: flex;
flex-direction: column;
}
.sidebar-title {
font-size: 16px;
font-weight: bold;
color: #1a1a1a;
margin-bottom: 12px;
}
.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,11 +0,0 @@
import { Component } from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router';
@Component({
selector: 'app-account-sidebar',
standalone: true,
imports: [RouterLink, RouterLinkActive],
templateUrl: './account-sidebar.html',
styleUrl: './account-sidebar.scss',
})
export class AccountSidebar {}

View File

@@ -13,3 +13,13 @@
flex: 1;
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 {
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

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

View File

@@ -1,13 +1,10 @@
.page-container {
display: flex;
flex-direction: column;
:host {
display: block;
width: 100%;
}
.account-page {
display: block;
width: 100%;
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 { MenuContentSectionComponent } from '../../../../components/menu-content-section/menu-content-section.component';
import { ProfileForm } from '../../components/profile-form/profile-form';
@Component({
selector: 'app-profile-page',
standalone: true,
imports: [ProfileForm],
imports: [ProfileForm, MenuContentSectionComponent],
templateUrl: './profile-page.html',
styleUrl: './profile-page.scss',
})

View File

@@ -1,26 +1,35 @@
<div class="page-container">
<div class="d-flex align-items-center mb-4">
<a routerLink="../" class="text-decoration-none " >
<i class="bi bi-arrow-left fs-5"></i>
</a>
<h2 class="page-title m-0">MIS COMPRAS</h2>
</div>
<app-menu-content-section
class="account-page"
menuCode="account.purchases"
titleId="purchase-detail-page-title"
>
<a
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()) {
<p class="purchase-loading">Cargando detalle de compra...</p>
} @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">
<span class="purchase-id">Compra {{ purchase.id }}.</span>
<span class="purchase-date">Fecha de compra: {{ purchase.date }}</span>
</div>
<div class="purchase-total ">
<div class="purchase-total">
<span class="purchase-total-label">Total:</span>
<span class="purchase-total-value">${{ purchase.total }}</span>
</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">
@for (item of purchase.items; track item.id) {
@@ -30,4 +39,4 @@
}
</div>
}
</div>
</app-menu-content-section>

View File

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

View File

@@ -8,6 +8,7 @@ import {
} from '../../../../../../core/services/checkout.service';
import { TenantService } from '../../../../../../core/services/tenant.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';
type PurchaseDetailViewModel = {
@@ -20,7 +21,7 @@ type PurchaseDetailViewModel = {
@Component({
selector: 'app-purchase-detail-page',
standalone: true,
imports: [CommonModule, RouterLink, PurchaseItem],
imports: [CommonModule, RouterLink, PurchaseItem, MenuContentSectionComponent],
templateUrl: './purchase-detail-page.html',
styleUrl: './purchase-detail-page.scss',
})
@@ -44,7 +45,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,4 +1,7 @@
<div class="page-container">
<h2 class="page-title">MIS COMPRAS</h2>
<app-purchase-list></app-purchase-list>
</div>
<app-menu-content-section
class="account-page"
menuCode="account.purchases"
titleId="purchases-page-title"
>
<app-purchase-list />
</app-menu-content-section>

View File

@@ -1,12 +1,10 @@
.page-container {
display: flex;
flex-direction: column;
:host {
display: block;
width: 100%;
}
.account-page {
display: block;
width: 100%;
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 { MenuContentSectionComponent } from '../../../../components/menu-content-section/menu-content-section.component';
import { PurchaseList } from '../../components/purchase-list/purchase-list';
@Component({
selector: 'app-purchases-page',
standalone: true,
imports: [PurchaseList],
imports: [PurchaseList, MenuContentSectionComponent],
templateUrl: './purchases-page.html',
styleUrl: './purchases-page.scss',
})

View File

@@ -41,9 +41,15 @@
<app-icon-button
variant="download"
ariaLabel="Descargar ticket"
[disabled]="isGeneratingPdf()"
(clicked)="download.emit()"
/>
<app-icon-button variant="share" ariaLabel="Compartir ticket" (clicked)="share.emit()" />
<app-icon-button
variant="share"
ariaLabel="Compartir ticket"
[disabled]="isGeneratingPdf()"
(clicked)="share.emit()"
/>
}
</div>
</article>

View File

@@ -16,6 +16,7 @@ export class TicketComponent {
readonly date = input<string | null>(null);
readonly disabled = input(false);
readonly expired = input(false);
readonly isGeneratingPdf = input(false);
readonly selected = model(false);
readonly viewQr = output<void>();

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,26 @@ 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 ?? []);
}
downloadPdf(ticketIds: number[]): Promise<Blob> {
return firstValueFrom(
this.http.post(
`${this.tenantService.getTenantApiUrl()}/tickets/pdf`,
{ ticket_ids: ticketIds },
{
responseType: 'blob',
},
),
);
}
}

View File

@@ -1,33 +1,76 @@
<section class="tickets-page">
<h1 class="tickets-page__title">MIS TICKETS</h1>
<div class="tickets-list" aria-busy="{{ isLoading() }}">
<header class="tickets-list__header">
<label class="tickets-list__select-all">
<input
type="checkbox"
[checked]="allSelected()"
[disabled]="activeTickets().length === 0"
(change)="toggleAll($any($event.target).checked)"
/>
<span>TICKETS ACTIVOS</span>
</label>
<div class="tickets-list__bulk-actions">
<span class="tickets-list__bulk-action">
<app-icon-button variant="download" ariaLabel="Descargar tickets seleccionados" />
<small>Descargar</small>
</span>
<span class="tickets-list__bulk-action">
<app-icon-button variant="share" ariaLabel="Compartir tickets seleccionados" />
<small>Compartir</small>
</span>
<app-menu-content-section
class="tickets-page"
menuCode="account.tickets"
titleId="tickets-page-title"
>
<div class="tickets-list" [attr.aria-busy]="isLoading() || isGeneratingPdf()">
@if (isGeneratingPdf()) {
<div class="tickets-list__generating" role="status" aria-live="polite">
<span class="tickets-list__generating-spinner" aria-hidden="true"></span>
<span>Generando PDF...</span>
</div>
</header>
}
@if (activeTickets().length) {
<header class="tickets-list__header">
<label class="tickets-list__select-all">
<input
type="checkbox"
[checked]="allSelected()"
(change)="toggleAll($any($event.target).checked)"
/>
<span>TICKETS ACTIVOS</span>
</label>
<div class="tickets-list__bulk-actions">
<span class="tickets-list__bulk-action">
<app-icon-button
variant="download"
ariaLabel="Descargar tickets seleccionados"
[disabled]="isGeneratingPdf()"
(clicked)="downloadSelected()"
/>
<small>Descargar</small>
</span>
<span class="tickets-list__bulk-action">
<app-icon-button
variant="share"
ariaLabel="Compartir tickets seleccionados"
[disabled]="isGeneratingPdf()"
(clicked)="shareSelected()"
/>
<small>Compartir</small>
</span>
</div>
</header>
}
@if (isLoading()) {
<header class="tickets-list__skeleton-header" aria-hidden="true">
<div class="tickets-list__skeleton-selection">
<span class="tickets-list__skeleton-check"></span>
<span class="tickets-list__skeleton-line tickets-list__skeleton-line--section"></span>
</div>
<div class="tickets-list__skeleton-bulk-actions">
<span class="tickets-list__skeleton-icon"></span>
<span class="tickets-list__skeleton-icon"></span>
</div>
</header>
@for (item of [1, 2, 3, 4]; track item) {
<div class="tickets-list__skeleton" aria-hidden="true"></div>
<div class="tickets-list__skeleton" aria-hidden="true">
<span class="tickets-list__skeleton-check"></span>
<div class="tickets-list__skeleton-details">
<span class="tickets-list__skeleton-line tickets-list__skeleton-line--title"></span>
<span class="tickets-list__skeleton-line tickets-list__skeleton-line--id"></span>
</div>
<span class="tickets-list__skeleton-line tickets-list__skeleton-line--date"></span>
<div class="tickets-list__skeleton-actions">
<span class="tickets-list__skeleton-button"></span>
<span class="tickets-list__skeleton-icon"></span>
<span class="tickets-list__skeleton-icon"></span>
</div>
</div>
}
} @else if (tickets().length) {
@for (ticket of activeTickets(); track ticket.id) {
@@ -36,8 +79,11 @@
[ticketId]="ticket.id"
[date]="formatDate(ticket)"
[selected]="isSelected(ticket.id)"
[isGeneratingPdf]="isGeneratingPdf()"
(selectedChange)="toggleTicket(ticket.id, $event)"
(viewQr)="viewQr(ticket)"
(download)="downloadTicket(ticket)"
(share)="shareTicket(ticket)"
/>
}
@@ -62,4 +108,4 @@
<p class="tickets-list__empty">Aún no tenés tickets disponibles.</p>
}
</div>
</section>
</app-menu-content-section>

View File

@@ -1,13 +1,36 @@
.tickets-page {
display: block;
width: 100%;
max-width: 720px;
}
.tickets-page__title {
margin: 0 0 20px;
color: #a0a0a0;
font-size: 15px;
.tickets-list {
position: relative;
}
.tickets-list__generating {
position: absolute;
z-index: 10;
inset: 0;
display: flex;
min-height: 180px;
align-items: center;
justify-content: center;
gap: 12px;
border-radius: 8px;
color: #26322a;
font-size: 14px;
font-weight: 700;
backdrop-filter: blur(2px);
}
.tickets-list__generating-spinner {
width: 24px;
height: 24px;
border: 3px solid #dce8e0;
border-top-color: var(--tenant-primary, #009933);
border-radius: 50%;
animation: tickets-spinner 0.75s linear infinite;
}
.tickets-list__header {
@@ -75,22 +98,124 @@
text-align: center;
}
.tickets-list__skeleton {
height: 77px;
.tickets-list__skeleton-header {
display: flex;
min-height: 58px;
align-items: center;
justify-content: space-between;
padding-bottom: 12px;
border-bottom: 1px solid #dddddd;
background: linear-gradient(90deg, transparent, #f3f3f3, transparent);
background-size: 200% 100%;
animation: loading 1.4s infinite;
}
@keyframes loading {
.tickets-list__skeleton-selection,
.tickets-list__skeleton-bulk-actions,
.tickets-list__skeleton-actions {
display: flex;
align-items: center;
}
.tickets-list__skeleton-selection {
gap: 16px;
}
.tickets-list__skeleton-bulk-actions,
.tickets-list__skeleton-actions {
gap: 8px;
}
.tickets-list__skeleton {
display: grid;
grid-template-columns: auto minmax(0, 1fr) 78px auto;
min-height: 76px;
align-items: center;
gap: 16px;
padding: 16px 0;
border-bottom: 1px solid #dddddd;
}
.tickets-list__skeleton-check,
.tickets-list__skeleton-line,
.tickets-list__skeleton-icon,
.tickets-list__skeleton-button {
display: block;
border-radius: 4px;
background: linear-gradient(90deg, #eeeeee 20%, #f7f7f7 50%, #eeeeee 80%);
background-size: 200% 100%;
animation: tickets-loading 1.4s ease-in-out infinite;
}
.tickets-list__skeleton-check {
width: 21px;
height: 21px;
border-radius: 2px;
}
.tickets-list__skeleton-details {
display: grid;
gap: 8px;
}
.tickets-list__skeleton-line--section {
width: 120px;
height: 14px;
}
.tickets-list__skeleton-line--title {
width: min(240px, 75%);
height: 13px;
}
.tickets-list__skeleton-line--id {
width: 86px;
height: 11px;
}
.tickets-list__skeleton-line--date {
width: 64px;
height: 11px;
justify-self: center;
}
.tickets-list__skeleton-button {
width: 115px;
height: 36px;
}
.tickets-list__skeleton-icon {
width: 28px;
height: 28px;
border-radius: 50%;
}
@keyframes tickets-loading {
to {
background-position: -200% 0;
}
}
@media (max-width: 767.98px) {
.tickets-page__title {
margin-top: 24px;
@keyframes tickets-spinner {
to {
transform: rotate(360deg);
}
}
@media (max-width: 767.98px) {
.tickets-list__skeleton {
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 10px;
}
.tickets-list__skeleton-line--date {
grid-column: 2;
justify-self: start;
}
.tickets-list__skeleton-actions {
grid-column: 3;
grid-row: 1 / span 2;
}
.tickets-list__skeleton-actions .tickets-list__skeleton-icon {
display: none;
}
}

View File

@@ -1,13 +1,21 @@
import { signal } from '@angular/core';
import { By } from '@angular/platform-browser';
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 { ModalService } from '../../../../../../core/services/modal.service';
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',
@@ -26,6 +34,177 @@ const ticket = (overrides: Partial<TicketResponse>): TicketResponse => ({
});
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 () => {
const share = vi.fn().mockResolvedValue(undefined);
const canShare = vi.fn().mockReturnValue(true);
vi.stubGlobal('navigator', { canShare, share });
await TestBed.configureTestingModule({
imports: [TicketsPage],
providers: [{ provide: ToastService, useValue: { danger: vi.fn() } }],
})
.overrideComponent(TicketsPage, {
set: {
providers: [
{
provide: TicketService,
useValue: withCustomLoading({
getTickets: () => Promise.resolve([ticket({})]),
downloadPdf: () => Promise.resolve(new Blob(['pdf-content'])),
}),
},
],
},
})
.compileComponents();
const fixture = TestBed.createComponent(TicketsPage);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const page = fixture.nativeElement as HTMLElement;
page.querySelector<HTMLButtonElement>('[aria-label="Compartir ticket"]')?.click();
await vi.waitFor(() => expect(share).toHaveBeenCalledOnce());
const shareData = share.mock.calls[0][0] as ShareData;
const [sharedFile] = shareData.files ?? [];
expect(Object.keys(shareData)).toEqual(['files']);
expect(canShare).toHaveBeenCalledWith({ files: [sharedFile] });
expect(sharedFile).toBeInstanceOf(File);
expect(sharedFile.name).toBe('tickets_1.pdf');
expect(sharedFile.type).toBe('application/pdf');
expect(sharedFile.size).toBeGreaterThan(0);
vi.unstubAllGlobals();
});
it('shows a loading overlay and disables PDF actions while the file is generated', async () => {
let resolvePdf!: (pdf: Blob) => void;
const downloadPdf = vi.fn(
() =>
new Promise<Blob>((resolve) => {
resolvePdf = resolve;
}),
);
const createObjectUrl = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:ticket');
const revokeObjectUrl = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined);
let downloadedFilename = '';
const linkClick = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (
this: HTMLAnchorElement,
) {
downloadedFilename = this.download;
});
await TestBed.configureTestingModule({
imports: [TicketsPage],
providers: [{ provide: ToastService, useValue: { danger: vi.fn() } }],
})
.overrideComponent(TicketsPage, {
set: {
providers: [
{
provide: TicketService,
useValue: withCustomLoading({
getTickets: () => Promise.resolve([ticket({})]),
downloadPdf,
}),
},
],
},
})
.compileComponents();
const fixture = TestBed.createComponent(TicketsPage);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const page = fixture.nativeElement as HTMLElement;
page.querySelector<HTMLButtonElement>('[aria-label="Descargar ticket"]')?.click();
fixture.detectChanges();
expect(page.querySelector('.tickets-list__generating')?.textContent).toContain(
'Generando PDF...',
);
expect(page.querySelector('.tickets-list')?.getAttribute('aria-busy')).toBe('true');
expect(page.querySelector<HTMLButtonElement>('[aria-label="Descargar ticket"]')?.disabled).toBe(
true,
);
expect(page.querySelector<HTMLButtonElement>('[aria-label="Compartir ticket"]')?.disabled).toBe(
true,
);
resolvePdf(new Blob(['pdf'], { type: 'application/pdf' }));
await fixture.whenStable();
await vi.waitFor(() => expect(downloadedFilename).toBe('tickets_1.pdf'));
fixture.detectChanges();
expect(page.querySelector('.tickets-list__generating')).toBeNull();
expect(page.querySelector('.tickets-list')?.getAttribute('aria-busy')).toBe('false');
createObjectUrl.mockRestore();
revokeObjectUrl.mockRestore();
linkClick.mockRestore();
});
it('hides the active tickets header when there are no available tickets', async () => {
await TestBed.configureTestingModule({
imports: [TicketsPage],
providers: [{ provide: ToastService, useValue: { danger: vi.fn() } }],
})
.overrideComponent(TicketsPage, {
set: {
providers: [
{
provide: TicketService,
useValue: withCustomLoading({ getTickets: () => Promise.resolve([]) }),
},
],
},
})
.compileComponents();
const fixture = TestBed.createComponent(TicketsPage);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const page = fixture.nativeElement as HTMLElement;
expect(page.querySelector('.tickets-list__select-all')).toBeNull();
expect(page.querySelector('.tickets-list__bulk-actions')).toBeNull();
expect(page.textContent).not.toContain('TICKETS ACTIVOS');
expect(page.textContent).toContain('Aún no tenés tickets disponibles.');
});
it('separates expired and used tickets into the disabled inactive list', async () => {
const tickets = [
ticket({ id: 1 }),
@@ -40,7 +219,10 @@ describe('TicketsPage', () => {
.overrideComponent(TicketsPage, {
set: {
providers: [
{ provide: TicketService, useValue: { getTickets: () => Promise.resolve(tickets) } },
{
provide: TicketService,
useValue: withCustomLoading({ getTickets: () => Promise.resolve(tickets) }),
},
],
},
})
@@ -93,7 +275,9 @@ describe('TicketsPage', () => {
providers: [
{
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 { ToastService } from '../../../../../../core/services/toast.service';
import { IconButtonComponent } from '../../../../../../shared/components/icon-button/icon-button.component';
import { MenuContentSectionComponent } from '../../../../components/menu-content-section/menu-content-section.component';
import { TicketComponent } from './components/ticket/ticket.component';
import { TicketResponse, TicketService } from './ticket.service';
@Component({
selector: 'app-tickets-page',
imports: [IconButtonComponent, TicketComponent],
imports: [IconButtonComponent, TicketComponent, MenuContentSectionComponent],
providers: [TicketService],
templateUrl: './tickets-page.html',
styleUrl: './tickets-page.scss',
@@ -27,6 +28,7 @@ export class TicketsPage implements OnInit {
);
protected readonly selectedIds = signal<Set<number>>(new Set());
protected readonly isLoading = signal(true);
protected readonly isGeneratingPdf = signal(false);
protected readonly allSelected = computed(
() =>
this.activeTickets().length > 0 && this.selectedIds().size === this.activeTickets().length,
@@ -34,7 +36,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 {
@@ -86,4 +88,93 @@ export class TicketsPage implements OnInit {
ticket: ticket.ticket,
});
}
protected async downloadTicket(ticket: TicketResponse): Promise<void> {
await this.downloadTickets([ticket]);
}
protected async downloadSelected(): Promise<void> {
const tickets = this.activeTickets().filter((ticket) => this.selectedIds().has(ticket.id));
if (!tickets.length) {
this.toastService.danger('Seleccioná al menos un ticket para descargar.');
return;
}
await this.downloadTickets(tickets);
}
protected async shareTicket(ticket: TicketResponse): Promise<void> {
await this.shareTickets([ticket]);
}
protected async shareSelected(): Promise<void> {
const tickets = this.activeTickets().filter((ticket) => this.selectedIds().has(ticket.id));
if (!tickets.length) {
this.toastService.danger('Seleccioná al menos un ticket para compartir.');
return;
}
await this.shareTickets(tickets);
}
private async downloadTickets(tickets: TicketResponse[]): Promise<void> {
const pdf = await this.generateTicketsPdf(tickets);
if (pdf) this.downloadPdf(pdf, this.pdfFilename(tickets));
}
private async shareTickets(tickets: TicketResponse[]): Promise<void> {
const pdf = await this.generateTicketsPdf(tickets);
if (!pdf) return;
try {
const filename = this.pdfFilename(tickets);
const file = new File([pdf], filename, { type: 'application/pdf' });
if (navigator.canShare?.({ files: [file] })) {
await navigator.share({ files: [file] });
return;
}
this.downloadPdf(pdf, filename);
this.toastService.success('Tu navegador no permite compartir archivos; descargamos el PDF.');
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') return;
this.toastService.danger('No se pudo compartir el PDF de los tickets.');
}
}
private async generateTicketsPdf(tickets: TicketResponse[]): Promise<Blob | null> {
if (this.isGeneratingPdf()) return null;
this.isGeneratingPdf.set(true);
try {
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;
} finally {
this.isGeneratingPdf.set(false);
}
}
private pdfFilename(tickets: TicketResponse[]): string {
return `tickets_${tickets.map((ticket) => ticket.id).join('_')}.pdf`;
}
private downloadPdf(pdf: Blob, filename: string): void {
const url = URL.createObjectURL(pdf);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 0);
}
}

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

@@ -0,0 +1,26 @@
<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 (results(); as categoryResults) {
<header class="category-items__header mb-5">
<h1 id="category-items-title" class="category-items__title mb-0">
{{ categoryResults.category.nombre }}
</h1>
</header>
@if (!categoryResults.data.length) {
<p class="alert alert-light border text-center mb-0">
No hay productos disponibles en esta categoría.
</p>
} @else {
<app-product-list
[layout]="categoryResults.layout"
[groupLayout]="paginatedLayout"
[items]="categoryResults"
(buy)="onBuyProduct($event)"
(addToCart)="onAddToCart($event)"
(pageChange)="onPageChange($event)"
/>
}
}
</section>

View File

@@ -0,0 +1,18 @@
.category-items__header {
display: flex;
align-items: center;
justify-content: center;
padding: 16px 28px;
}
.category-items__title {
font-size: 24px;
font-weight: bold;
line-height: 1.2;
text-align: center;
color:var(--bs-primary)
}
.category-items__alert-error {
color: var(--tenant-danger-color, var(--bs-danger));
}

View File

@@ -0,0 +1,126 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { CartService } from '../../../../core/services/cart/cart.service';
import { CatalogService } from '../../../../core/services/catalog/catalog.service';
import { Tenant } from '../../../../core/services/tenant.interface';
import { TenantService } from '../../../../core/services/tenant.service';
import { ToastService } from '../../../../core/services/toast.service';
import { ProductListComponent } from '../../../../shared/components/product-list/product-list.component';
import { CategoryItemsPageComponent } from './category-items-page.component';
describe('CategoryItemsPageComponent', () => {
const getCategoryItems = vi.fn();
const tenant = signal({ codigo: 'shop' } as Tenant);
beforeEach(async () => {
getCategoryItems.mockReset();
getCategoryItems.mockReturnValue(
of({
category: {
id: 7,
nombre: 'Remeras',
categoria_id: null,
},
layout: 'row',
group_layout: 'simple_vertical',
data: [
{
id: 1,
nombre: 'Remera clásica',
descripcion: 'Algodón',
precio: '100.00',
variants: [],
},
],
links: {
first: '/categories/7?page=1',
last: '/categories/7?page=2',
prev: null,
next: '/categories/7?page=2',
},
meta: {
current_page: 1,
from: 1,
last_page: 2,
links: [],
path: '/categories/7',
per_page: 12,
to: 1,
total: 13,
},
}),
);
await TestBed.configureTestingModule({
imports: [CategoryItemsPageComponent],
providers: [
provideRouter([]),
{
provide: ActivatedRoute,
useValue: {
paramMap: of(convertToParamMap({ id: '7' })),
queryParamMap: of(convertToParamMap({ page: '1' })),
},
},
{
provide: CatalogService,
useValue: {
getCategoryItems,
},
},
{
provide: TenantService,
useValue: { tenant: tenant.asReadonly() },
},
{
provide: CartService,
useValue: { addItem: vi.fn() },
},
{
provide: ToastService,
useValue: {
success: vi.fn(),
danger: vi.fn(),
},
},
],
}).compileComponents();
});
it('loads the category and renders its products with the configured product layout', () => {
const fixture = TestBed.createComponent(CategoryItemsPageComponent);
fixture.detectChanges();
expect(getCategoryItems).toHaveBeenCalledWith(7, { page: 1 });
expect((fixture.nativeElement as HTMLElement).textContent).toContain('Remeras');
const productList = fixture.debugElement.query(By.directive(ProductListComponent))
.componentInstance as ProductListComponent;
expect(productList.layout()).toBe('row');
expect(productList.groupLayout()).toBe('paginated');
expect(Array.isArray(productList.items())).toBe(false);
});
it('does not request the API when the category id is invalid', () => {
TestBed.overrideProvider(ActivatedRoute, {
useValue: {
paramMap: of(convertToParamMap({ id: 'invalid' })),
queryParamMap: of(convertToParamMap({ page: '1' })),
},
});
const fixture = TestBed.createComponent(CategoryItemsPageComponent);
fixture.detectChanges();
expect(getCategoryItems).not.toHaveBeenCalled();
expect((fixture.nativeElement as HTMLElement).textContent).toContain(
'La categoría solicitada no es válida.',
);
});
});

View File

@@ -0,0 +1,162 @@
import { HttpErrorResponse } from '@angular/common/http';
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
Injector,
inject,
signal,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router } from '@angular/router';
import { catchError, combineLatest, distinctUntilChanged, map, of, switchMap, tap } from 'rxjs';
import { AuthService } from '../../../../core/services/auth/auth.service';
import { CartService } from '../../../../core/services/cart/cart.service';
import { CheckoutService } from '../../../../core/services/checkout.service';
import {
CatalogGroupLayout,
CategoryItemsResponse,
} from '../../../../core/services/catalog/catalog.interface';
import { CatalogService } from '../../../../core/services/catalog/catalog.service';
import { TenantService } from '../../../../core/services/tenant.service';
import { ToastService } from '../../../../core/services/toast.service';
import {
ProductListBuyEvent,
ProductListCartEvent,
ProductListComponent,
} from '../../../../shared/components/product-list/product-list.component';
interface CategoryRouteState {
categoryId: number;
page: number;
}
@Component({
selector: 'app-category-items-page',
imports: [ProductListComponent],
templateUrl: './category-items-page.component.html',
styleUrl: './category-items-page.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CategoryItemsPageComponent {
private readonly cartService = inject(CartService);
private readonly catalogService = inject(CatalogService);
private readonly injector = inject(Injector);
private readonly destroyRef = inject(DestroyRef);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly tenantService = inject(TenantService);
private readonly toastService = inject(ToastService);
protected readonly results = signal<CategoryItemsResponse | null>(null);
protected readonly error = signal<string | null>(null);
protected readonly creatingDirectPurchase = signal(false);
protected readonly paginatedLayout: CatalogGroupLayout = 'paginated';
constructor() {
combineLatest([this.route.paramMap, this.route.queryParamMap])
.pipe(
map(
([params, queryParams]): CategoryRouteState => ({
categoryId: this.parsePositiveInteger(params.get('id')),
page: this.parsePositiveInteger(queryParams.get('page'), 1),
}),
),
distinctUntilChanged(
(previous, current) =>
previous.categoryId === current.categoryId && previous.page === current.page,
),
tap(() => {
this.results.set(null);
this.error.set(null);
}),
switchMap(({ categoryId, page }) => {
if (categoryId === 0) {
this.error.set('La categoría solicitada no es válida.');
return of(null);
}
return this.catalogService.getCategoryItems(categoryId, { page }).pipe(
catchError(() => {
this.error.set('No pudimos cargar los productos de esta categoría.');
return of(null);
}),
);
}),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((results) => {
this.results.set(results);
});
}
protected onPageChange(page: number): void {
void this.router.navigate([], {
relativeTo: this.route,
queryParams: { page },
queryParamsHandling: 'merge',
});
}
protected async onBuyProduct(event: ProductListBuyEvent): Promise<void> {
if (!event.directPurchase) {
await this.router.navigate(['/producto', event.product.id]);
return;
}
if (this.creatingDirectPurchase()) {
return;
}
if (!this.injector.get(AuthService).user()) {
await this.router.navigate(['/login'], {
queryParams: { returnUrl: `/producto/${event.product.id}` },
});
return;
}
const tenant = this.tenantService.tenant();
if (!tenant) {
this.toastService.danger('No se pudo identificar la tienda.');
return;
}
this.creatingDirectPurchase.set(true);
try {
const purchase = await this.injector.get(CheckoutService).startCheckout(tenant.codigo, {
direct_item: {
catalog_item_id: event.product.id,
variant_id: event.variant ?? null,
cantidad: event.quantity,
},
});
await this.router.navigate(['/checkout'], { queryParams: { purchase: purchase.id } });
} catch (error) {
console.error('Failed to create direct purchase:', error);
this.toastService.danger('No se pudo iniciar la compra directa.');
} finally {
this.creatingDirectPurchase.set(false);
}
}
protected onAddToCart(event: ProductListCartEvent): void {
this.cartService.addItem(event.product.id, event.variant ?? null, event.quantity).subscribe({
next: (response) => {
this.toastService.success(response.message || 'Producto agregado al carrito');
},
error: (error: HttpErrorResponse) => {
const message = error.error?.message || 'No se pudo agregar el producto al carrito.';
this.toastService.danger(message);
},
});
}
private parsePositiveInteger(value: string | null, fallback = 0): number {
const parsed = Number(value ?? fallback);
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
}
}

View File

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

View File

@@ -1,7 +1,17 @@
@if (isLoadingPurchase()) {
<div class="checkout-page__loading" role="status" aria-live="polite">
<span class="checkout-page__spinner" aria-hidden="true"></span>
<span>Cargando compra...</span>
</div>
} @else {
<div class="checkout-page">
<div class="checkout-page__stepper-col ">
<app-stepper #stepper>
<div
class="checkout-page__stepper-col"
[class.checkout-page__stepper-col--editing]="isEditingItems()"
[attr.aria-hidden]="isEditingItems()"
[attr.inert]="isEditingItems() ? '' : null"
>
<app-stepper #stepper [initialStepIndex]="checkoutStepIndex()">
<app-step label="Datos" [isValid]="isStep1Valid()">
<app-checkout-data-step
[form]="form"
@@ -21,6 +31,8 @@
[qrData]="qrData()"
[qrPaymentStatus]="qrPaymentStatus()"
[isCheckingQrPayment]="isCheckingQrPayment()"
[qrPaymentAmount]="cartTotal()"
[whatsappUrl]="whatsappUrl()"
[transferValidationStatus]="transferValidationStatus()"
(paymentMethodChange)="selectPaymentMethod($event)"
(copyTransferValue)="copyTransferValue($event.field, $event.value)"
@@ -33,13 +45,30 @@
</app-stepper>
</div>
@if (isEditingItems()) {
<div class="checkout-page__editing-notice" role="status">
<p>Terminá de modificar las cantidades para continuar con el pago.</p>
</div>
}
<div class="checkout-page__cart-col">
<app-cart
title="COMPRA"
[items]="mappedCartItems()"
[subtotal]="cartSubtotal()"
[discount]="cartDiscount()"
[total]="cartTotal()"
[allowEditing]="
createdPurchase()?.status === 'created' || createdPurchase()?.status === 'pending_payment'
"
[allowRemove]="false"
[persistQuantityChanges]="false"
[editing]="isEditingItems()"
[editingDisabled]="isUpdatingItem() || isPreparingItemEdit()"
backgroundColor="transparent"
(editingChange)="onEditingItemsChange($event)"
(itemQuantityChange)="onPurchaseItemQuantityChange($event)"
/>
</div>
</div>
}

View File

@@ -6,19 +6,72 @@
@media (max-width: 900px) {
grid-template-columns: 1fr;
.checkout-page__cart-col {
order: 1;
}
.checkout-page__stepper-col,
.checkout-page__editing-notice {
order: 2;
}
}
&__stepper-col {
min-width: 0;
border-radius: 4px;
min-height: 420px;
&--editing {
display: none;
}
}
&__editing-notice {
display: grid;
min-height: 420px;
place-items: center;
padding: 2rem;
border-radius: 4px;
background: #f5f5f5;
color: #666666;
text-align: center;
p {
max-width: 360px;
margin: 0;
font-size: 14px;
}
}
&__cart-col {
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
}
.checkout-page__loading {
display: grid;
min-height: 420px;
place-items: center;
align-content: center;
gap: 1rem;
color: #666666;
}
.checkout-page__spinner {
width: 3rem;
height: 3rem;
border: 0.25rem solid rgba(32, 32, 32, 0.08);
border-top-color: var(--tenant-primary);
border-radius: 50%;
animation: checkout-page-spin 0.8s linear infinite;
}
@keyframes checkout-page-spin {
to {
transform: rotate(360deg);
}
}

View File

@@ -1,20 +1,28 @@
import { signal } from '@angular/core';
import { getTestBed, TestBed } from '@angular/core/testing';
import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
import { Router } from '@angular/router';
import { ActivatedRoute, convertToParamMap, Router } from '@angular/router';
import { of } from 'rxjs';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { AuthService } from '../../../../core/services/auth/auth.service';
import { CartService } from '../../../../core/services/cart/cart.service';
import { CheckoutService } from '../../../../core/services/checkout.service';
import { CatalogService } from '../../../../core/services/catalog/catalog.service';
import { TenantService } from '../../../../core/services/tenant.service';
import { CheckoutPageComponent } from './checkout-page.component';
describe('CheckoutPageComponent payment validation', () => {
let checkoutServiceStub: {
startCheckout: ReturnType<typeof vi.fn>;
updateCustomerData: ReturnType<typeof vi.fn>;
updateItemQuantity: ReturnType<typeof vi.fn>;
prepareItemEditing: ReturnType<typeof vi.fn>;
cancelPurchase: ReturnType<typeof vi.fn>;
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>;
@@ -23,6 +31,8 @@ describe('CheckoutPageComponent payment validation', () => {
clearCart: ReturnType<typeof vi.fn>;
};
let routerStub: { navigate: ReturnType<typeof vi.fn> };
let routeQueryParamMap: ReturnType<typeof convertToParamMap>;
let authUserState: ReturnType<typeof signal>;
beforeAll(() => {
try {
@@ -36,11 +46,24 @@ describe('CheckoutPageComponent payment validation', () => {
vi.useFakeTimers();
checkoutServiceStub = {
startCheckout: vi.fn().mockResolvedValue({
id: 25,
items: [],
subtotal: '0.00',
total: '0.00',
}),
updateCustomerData: vi.fn(),
updateItemQuantity: vi.fn(),
prepareItemEditing: vi.fn(),
cancelPurchase: vi.fn().mockResolvedValue({ status: 'cancelled' }),
generatePaymentIntent: vi.fn().mockResolvedValue({
qr_data: { qr_code: 'qr-value' },
}),
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,
@@ -63,14 +86,27 @@ describe('CheckoutPageComponent payment validation', () => {
});
});
routerStub = { navigate: vi.fn() };
routeQueryParamMap = convertToParamMap({});
authUserState = signal(null);
await TestBed.configureTestingModule({
imports: [CheckoutPageComponent],
providers: [
{ provide: CheckoutService, useValue: checkoutServiceStub },
{ provide: CatalogService, useValue: { getCatalogItem: vi.fn() } },
{ provide: CartService, useValue: cartServiceStub },
{ provide: TenantService, useValue: { tenant: signal({ codigo: 'tenant-test' }) } },
{ provide: AuthService, useValue: { user: signal(null) } },
{ provide: AuthService, useValue: { user: authUserState } },
{
provide: ActivatedRoute,
useValue: {
snapshot: {
get queryParamMap() {
return routeQueryParamMap;
},
},
},
},
{ provide: Router, useValue: routerStub },
],
})
@@ -87,6 +123,7 @@ describe('CheckoutPageComponent payment validation', () => {
const fixture = TestBed.createComponent(CheckoutPageComponent);
fixture.detectChanges();
fixture.componentInstance['createdPurchaseId'].set(25);
routerStub.navigate.mockClear();
return { fixture, component: fixture.componentInstance as any };
}
@@ -105,7 +142,7 @@ describe('CheckoutPageComponent payment validation', () => {
await vi.advanceTimersByTimeAsync(5_000);
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(2);
expect(cartServiceStub.clearCart).toHaveBeenCalledOnce();
expect(cartServiceStub.clearCart).not.toHaveBeenCalled();
expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25]);
expect(checkoutServiceStub.generatePaymentIntent).toHaveBeenCalledTimes(1);
});
@@ -128,19 +165,19 @@ describe('CheckoutPageComponent payment validation', () => {
fixture.destroy();
});
it('stops QR polling after five minutes and can restart it', async () => {
it('stops QR polling after 45 seconds and can restart it', async () => {
const { component } = createComponent();
await component.selectPaymentMethod('qr');
await vi.advanceTimersByTimeAsync(300_000);
await vi.advanceTimersByTimeAsync(45_000);
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(60);
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(9);
expect(component.qrPaymentStatus()).toBe('timed_out');
component.retryQrPolling();
expect(component.qrPaymentStatus()).toBe('waiting');
await vi.advanceTimersByTimeAsync(5_000);
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(61);
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(10);
});
it('cancels QR polling when payment method changes or component is destroyed', async () => {
@@ -181,13 +218,14 @@ describe('CheckoutPageComponent payment validation', () => {
await component.onComplete();
expect(cartServiceStub.clearCart).toHaveBeenCalledOnce();
expect(checkoutServiceStub.submitPurchaseForReview).toHaveBeenCalledWith('tenant-test', 25);
expect(cartServiceStub.clearCart).not.toHaveBeenCalled();
expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25]);
});
it('shows a retryable state when transfer validation fails', async () => {
vi.spyOn(console, 'error').mockImplementation(() => undefined);
checkoutServiceStub.getPurchase.mockRejectedValue(new Error('network error'));
checkoutServiceStub.submitPurchaseForReview.mockRejectedValue(new Error('network error'));
const { component } = createComponent();
component.selectedPaymentMethod.set('transfer');
@@ -197,4 +235,266 @@ describe('CheckoutPageComponent payment validation', () => {
expect(cartServiceStub.clearCart).not.toHaveBeenCalled();
expect(routerStub.navigate).not.toHaveBeenCalled();
});
it('loads purchase items but prefills customer data from the user', async () => {
const purchase = {
id: 25,
cart_id: null,
status: 'created',
payment_method: null,
transfer_payer_dni: null,
nombre_apellido: 'Datos de la compra',
email: 'compra@example.com',
dni: '11111111',
telefono: '1111111111',
items: [
{
id: 91,
quantity: 2,
unit_price: '1250.50',
line_total: '2501.00',
source_catalog_item_id: 8,
source_variant_id: 21,
item_details: {
nombre: 'Remera',
descripcion: null,
slug: 'remera',
imagen: 'https://example.com/remera.jpg',
attributes: [{ name: 'Color', value: 'Negro' }],
},
},
],
subtotal: '2501.00',
total: '2501.00',
};
routeQueryParamMap = convertToParamMap({ purchase: 25 });
checkoutServiceStub.getPurchase.mockResolvedValue(purchase);
authUserState.set({
id: 7,
nombre_apellido: 'Juan Perez',
email: 'juan@example.com',
dni: '12345678',
telefono: '3415555555',
});
const { component } = createComponent();
await Promise.resolve();
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledWith('tenant-test', 25);
expect(component.mappedCartItems()).toEqual([
{
cartItemId: 91,
imageUrl: 'https://example.com/remera.jpg',
product: 'Remera',
originalPrice: null,
discountedPrice: 1250.5,
discountPercentage: null,
attributes: [{ label: 'Color', value: 'Negro' }],
quantity: 2,
},
]);
expect(component.cartSubtotal()).toBe(2501);
expect(component.cartTotal()).toBe(2501);
expect(component.form.getRawValue()).toEqual({
nombre: 'Juan Perez',
email: 'juan@example.com',
dni: '12345678',
telefono: '3415555555',
});
});
it('keeps the checkout hidden while the purchase is loading', async () => {
let resolvePurchase!: (purchase: any) => void;
routeQueryParamMap = convertToParamMap({ purchase: 25 });
checkoutServiceStub.getPurchase.mockReturnValue(
new Promise((resolve) => {
resolvePurchase = resolve;
}),
);
const { component } = createComponent();
expect(component.isLoadingPurchase()).toBe(true);
resolvePurchase({
id: 25,
status: 'created',
payment_method: null,
transfer_payer_dni: null,
items: [],
subtotal: '0.00',
total: '0.00',
});
await Promise.resolve();
expect(component.isLoadingPurchase()).toBe(false);
expect(component.checkoutStepIndex()).toBe(0);
});
it('opens a pending purchase on the payment step and restores its payment method', async () => {
routeQueryParamMap = convertToParamMap({ purchase: 25 });
checkoutServiceStub.getPurchase.mockResolvedValue({
id: 25,
status: 'pending_payment',
payment_method: 'transfer',
transfer_payer_dni: '12345678',
items: [],
subtotal: '100.00',
total: '100.00',
});
const { component } = createComponent();
await Promise.resolve();
expect(component.checkoutStepIndex()).toBe(1);
expect(component.selectedPaymentMethod()).toBe('transfer');
expect(component.transferDni()).toBe('12345678');
expect(component.isLoadingPurchase()).toBe(false);
});
it('generates a new QR when reopening a pending QR purchase', async () => {
routeQueryParamMap = convertToParamMap({ purchase: 25 });
checkoutServiceStub.getPurchase.mockResolvedValue({
id: 25,
status: 'pending_payment',
payment_method: 'qr',
transfer_payer_dni: null,
items: [],
subtotal: '100.00',
total: '100.00',
});
const { component } = createComponent();
await Promise.resolve();
await Promise.resolve();
expect(component.checkoutStepIndex()).toBe(1);
expect(component.selectedPaymentMethod()).toBe('qr');
expect(checkoutServiceStub.generatePaymentIntent).toHaveBeenCalledWith(
'tenant-test',
25,
'qr',
);
expect(component.qrData()).toBe('qr-value');
expect(component.qrPaymentStatus()).toBe('waiting');
});
it.each(['in_review', 'paid', 'cancelled', 'rejected', 'expired'])(
'redirects a %s purchase to its status page',
async (status) => {
routeQueryParamMap = convertToParamMap({ purchase: 25 });
checkoutServiceStub.getPurchase.mockResolvedValue({
id: 25,
status,
payment_method: null,
transfer_payer_dni: null,
items: [],
subtotal: '100.00',
total: '100.00',
});
createComponent();
await Promise.resolve();
expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25]);
},
);
it('updates a purchase item while editing and refreshes checkout totals', async () => {
const updatedPurchase = {
id: 25,
items: [],
subtotal: '300.00',
total: '300.00',
};
checkoutServiceStub.updateItemQuantity.mockResolvedValue(updatedPurchase);
const { component } = createComponent();
component.isEditingItems.set(true);
await component.onPurchaseItemQuantityChange({
item: {
cartItemId: 91,
imageUrl: null,
product: 'Remera',
originalPrice: null,
discountedPrice: 100,
discountPercentage: null,
attributes: [],
quantity: 2,
},
quantity: 3,
});
expect(checkoutServiceStub.updateItemQuantity).toHaveBeenCalledWith('tenant-test', 25, 91, 3);
expect(component.createdPurchase()).toBe(updatedPurchase);
expect(component.isUpdatingItem()).toBe(false);
});
it('keeps the payment step selected while editing and regenerates payment afterward', async () => {
const editablePurchase = {
id: 25,
status: 'created',
items: [],
subtotal: '100.00',
total: '100.00',
};
checkoutServiceStub.prepareItemEditing.mockResolvedValue(editablePurchase);
const { component } = createComponent();
component.stepper = { currentStepIndex: signal(1) };
const selectPaymentMethod = vi
.spyOn(component, 'selectPaymentMethod')
.mockResolvedValue(undefined);
await component.onEditingItemsChange(true);
expect(checkoutServiceStub.prepareItemEditing).toHaveBeenCalledWith('tenant-test', 25);
expect(component.isEditingItems()).toBe(true);
expect(component.createdPurchase()).toBe(editablePurchase);
await component.onEditingItemsChange(false);
expect(component.stepper.currentStepIndex()).toBe(1);
expect(selectPaymentMethod).toHaveBeenCalledWith('qr');
expect(component.isEditingItems()).toBe(false);
});
it('updates customer data on the existing purchase before payment', async () => {
const updatedPurchase = {
id: 25,
cart_id: 10,
items: [],
subtotal: '100.00',
total: '100.00',
};
checkoutServiceStub.updateCustomerData.mockResolvedValue(updatedPurchase);
const { component } = createComponent();
component.form.setValue({
nombre: 'Juan Perez',
email: 'juan@example.com',
dni: '12345678',
telefono: '3415555555',
});
component.stepper = { next: vi.fn() };
await component.onStep1Continue();
expect(checkoutServiceStub.startCheckout).not.toHaveBeenCalled();
expect(checkoutServiceStub.updateCustomerData).toHaveBeenCalledWith('tenant-test', 25, {
dni: '12345678',
telefono: '3415555555',
email: 'juan@example.com',
nombre_apellido: 'Juan Perez',
});
expect(component.createdPurchase()).toEqual({
...updatedPurchase,
status: 'pending_payment',
});
expect(component.stepper.next).toHaveBeenCalledOnce();
});
it('cancels the pending purchase before allowing navigation away', async () => {
const { component } = createComponent();
await expect(component.canDeactivate()).resolves.toBe(true);
expect(checkoutServiceStub.cancelPurchase).toHaveBeenCalledWith('tenant-test', 25);
expect(cartServiceStub.loadCart).toHaveBeenCalled();
expect(cartServiceStub.clearCart).not.toHaveBeenCalled();
expect(component.createdPurchaseId()).toBeNull();
});
});

View File

@@ -2,24 +2,25 @@ import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
OnDestroy,
OnInit,
signal,
untracked,
ViewChild,
} from '@angular/core';
import { FormBuilder, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { startWith } from 'rxjs';
import { ActivatedRoute, Router } from '@angular/router';
import { firstValueFrom, startWith } from 'rxjs';
import { CartService } from '../../../../core/services/cart/cart.service';
import { TenantService } from '../../../../core/services/tenant.service';
import { CheckoutService, CreatePurchasePayload } from '../../../../core/services/checkout.service';
import {
CheckoutService,
PurchaseDetailItemResponse,
PurchaseDetailResponse,
} from '../../../../core/services/checkout.service';
import { AuthService } from '../../../../core/services/auth/auth.service';
import { CartService } from '../../../../core/services/cart/cart.service';
import { BankAccount } from '../../../../core/services/tenant.interface';
import { CartItem } from '../../../../core/services/cart/cart.interface';
import { CartComponent, CartItemMock } from '../../../../shared/components/cart/cart.component';
import { StepComponent } from '../../../../shared/components/stepper/step.component';
import { StepperComponent } from '../../../../shared/components/stepper/stepper.component';
@@ -51,13 +52,14 @@ import {
})
export class CheckoutPageComponent implements OnInit, OnDestroy {
private readonly formBuilder = inject(FormBuilder);
private readonly cartService = inject(CartService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly tenantService = inject(TenantService);
private readonly checkoutService = inject(CheckoutService);
private readonly authService = inject(AuthService);
private readonly cartService = inject(CartService);
private readonly qrPollingIntervalMs = 5_000;
private readonly qrPollingMaxAttempts = 60;
private readonly qrPollingMaxAttempts = 9;
private qrPollingTimeoutId: ReturnType<typeof setTimeout> | null = null;
private qrPollingAttempts = 0;
@@ -74,19 +76,25 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
telefono: ['', [Validators.required]],
});
protected readonly createdPurchase = signal<PurchaseDetailResponse | null>(null);
protected readonly isLoadingPurchase = signal(true);
protected readonly checkoutStepIndex = signal(0);
protected readonly cartSubtotal = computed(() => {
const cart = this.cartService.cart();
return cart ? parseFloat(cart.subtotal) : 0;
const purchase = this.createdPurchase();
return purchase ? parseFloat(purchase.subtotal) : 0;
});
protected readonly cartDiscount = computed(() => 0);
protected readonly cartTotal = computed(() => this.cartSubtotal() - this.cartDiscount());
protected readonly cartTotal = computed(() => {
const purchase = this.createdPurchase();
return purchase ? parseFloat(purchase.total) : this.cartSubtotal() - this.cartDiscount();
});
protected readonly mappedCartItems = computed<CartItemMock[]>(() => {
const cart = this.cartService.cart();
if (!cart || !cart.items) return [];
return cart.items.map((item) => this.mapCartItemToMock(item));
const purchase = this.createdPurchase();
return purchase ? purchase.items.map((item) => this.mapPurchaseItemToMock(item)) : [];
});
protected readonly isStep1Valid = signal(this.form.valid);
@@ -100,32 +108,25 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
protected readonly transferAccount = signal<TransferAccount | null>(null);
protected readonly transferDni = signal<string>('');
protected readonly isCreatingPurchase = signal(false);
protected readonly isUpdatingPurchase = signal(false);
protected readonly isEditingItems = signal(false);
protected readonly isUpdatingItem = signal(false);
protected readonly isPreparingItemEdit = signal(false);
protected readonly createdPurchaseId = signal<number | null>(null);
protected readonly isGeneratingIntent = signal(false);
protected readonly isPaymentLoading = computed(
() => this.isGeneratingIntent() || this.cartService.isUpdating(),
);
protected readonly isPaymentLoading = computed(() => this.isGeneratingIntent());
protected readonly qrData = signal<string | null>(null);
protected readonly qrPaymentStatus = signal<QrPaymentStatus>('idle');
protected readonly isCheckingQrPayment = signal(false);
protected readonly transferValidationStatus = signal<TransferValidationStatus>('idle');
protected readonly whatsappUrl = computed(
() =>
this.tenantService
.tenant()
?.social_media?.find((socialMedia) => socialMedia.code === 'whatsapp')?.url ?? null,
);
constructor() {
effect(() => {
// We only want to trigger the intent generation when the cart changes.
// So we track the cart, but untrack the other signals to prevent duplicate calls.
const cart = this.cartService.cart();
untracked(() => {
const purchaseId = this.createdPurchaseId();
if (cart && purchaseId && !this.navigationStarted) {
// Trigger payment intent generation when cart changes and we are on the payment step
void this.selectPaymentMethod(this.selectedPaymentMethod());
}
});
});
this.form.statusChanges
.pipe(startWith(this.form.status))
.subscribe(() => this.isStep1Valid.set(this.form.valid));
@@ -144,68 +145,123 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
}
ngOnInit(): void {
this.cartService.loadCart().subscribe();
const purchaseId = Number(this.route.snapshot.queryParamMap.get('purchase'));
if (!Number.isInteger(purchaseId) || purchaseId <= 0) {
void this.router.navigate(['/']);
return;
}
void this.loadPurchase(purchaseId);
}
ngOnDestroy(): void {
this.stopQrPolling();
}
private mapCartItemToMock(item: CartItem): CartItemMock {
const fullName = item.product?.nombre ?? '';
let product = fullName;
let attributes: { label: string; value: string }[] = [];
const match = fullName.match(/^(.*?)\s*\((.*?)\)$/);
if (match) {
product = match[1];
const attributesString = match[2];
attributes = attributesString.split(',').map((attr) => {
const parts = attr.split(':');
if (parts.length === 2) {
return { label: parts[0].trim(), value: parts[1].trim() };
}
return { label: '', value: attr.trim() };
});
}
private mapPurchaseItemToMock(item: PurchaseDetailItemResponse): CartItemMock {
return {
cartItemId: item.id,
imageUrl: item.product?.imagen ?? null,
product,
imageUrl: item.item_details.imagen,
product: item.item_details.nombre,
originalPrice: null,
discountedPrice: parseFloat(item.precio_unitario),
discountedPrice: parseFloat(item.unit_price),
discountPercentage: null,
attributes,
quantity: item.cantidad,
attributes: item.item_details.attributes.map((attribute) => ({
label: attribute.name,
value: attribute.value === null ? '' : String(attribute.value),
})),
quantity: item.quantity,
};
}
protected async onStep1Continue(): Promise<void> {
if (this.form.invalid) return;
protected async onEditingItemsChange(editing: boolean): Promise<void> {
if (this.isUpdatingItem() || this.isPreparingItemEdit()) {
return;
}
const tenant = this.tenantService.tenant();
if (!tenant) return;
if (!editing) {
this.isEditingItems.set(false);
this.isCreatingPurchase.set(true);
try {
const cart = this.cartService.cart();
if (!cart?.id) {
throw new Error('No hay un carrito activo para finalizar la compra.');
if (this.stepper?.currentStepIndex() === 1) {
void this.selectPaymentMethod(this.selectedPaymentMethod());
}
return;
}
this.isEditingItems.set(true);
this.stopQrPolling();
this.qrData.set(null);
this.qrPaymentStatus.set('idle');
this.transferAccount.set(null);
this.transferValidationStatus.set('idle');
const tenant = this.tenantService.tenant();
const purchaseId = this.createdPurchaseId();
if (!tenant || !purchaseId) {
this.isEditingItems.set(false);
return;
}
this.isPreparingItemEdit.set(true);
try {
const purchase = await this.checkoutService.prepareItemEditing(tenant.codigo, purchaseId);
this.createdPurchase.set(purchase);
} catch (error) {
console.error('Failed to prepare purchase item editing:', error);
this.isEditingItems.set(false);
} finally {
this.isPreparingItemEdit.set(false);
}
}
protected async onPurchaseItemQuantityChange(event: {
item: CartItemMock;
quantity: number;
}): Promise<void> {
const tenant = this.tenantService.tenant();
const purchaseId = this.createdPurchaseId();
const itemId = event.item.cartItemId;
if (!tenant || !purchaseId || !itemId || this.isUpdatingItem() || !this.isEditingItems()) {
return;
}
this.isUpdatingItem.set(true);
try {
const purchase = await this.checkoutService.updateItemQuantity(
tenant.codigo,
purchaseId,
itemId,
event.quantity,
);
this.createdPurchase.set(purchase);
} catch (error) {
console.error('Failed to update purchase item quantity:', error);
} finally {
this.isUpdatingItem.set(false);
}
}
protected async onStep1Continue(): Promise<void> {
if (this.form.invalid || this.isUpdatingPurchase() || this.isEditingItems()) return;
const tenant = this.tenantService.tenant();
const purchaseId = this.createdPurchaseId();
if (!tenant || !purchaseId) return;
this.isUpdatingPurchase.set(true);
try {
const formValue = this.form.getRawValue();
const payload: CreatePurchasePayload = {
cart_id: cart.id,
const purchase = await this.checkoutService.updateCustomerData(tenant.codigo, purchaseId, {
dni: formValue.dni,
telefono: formValue.telefono,
email: formValue.email,
nombre_apellido: formValue.nombre,
};
const response = await this.checkoutService.createPurchase(tenant.codigo, payload);
this.createdPurchaseId.set(response.id);
});
this.createdPurchase.set(purchase);
this.stepper.next();
@@ -215,17 +271,50 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
console.error('Failed to create purchase:', error);
// Here we could show an alert or toast
} finally {
this.isCreatingPurchase.set(false);
this.isUpdatingPurchase.set(false);
}
}
protected onCancel(): void {
protected async onCancel(): Promise<void> {
if (await this.canDeactivate()) {
void this.router.navigate(['/']);
}
}
public async canDeactivate(): Promise<boolean> {
this.stopQrPolling();
void this.router.navigate(['/']);
if (this.navigationStarted) {
return true;
}
const purchaseId = this.createdPurchaseId();
const tenant = this.tenantService.tenant();
if (!purchaseId || !tenant) {
return true;
}
try {
await this.checkoutService.cancelPurchase(tenant.codigo, purchaseId);
this.createdPurchaseId.set(null);
this.createdPurchase.set(null);
try {
await firstValueFrom(this.cartService.loadCart());
} catch (error) {
console.error('Failed to restore cart after cancelling checkout:', error);
}
return true;
} catch (error) {
console.error('Failed to cancel purchase:', error);
return false;
}
}
protected async selectPaymentMethod(method: PaymentMethod): Promise<void> {
if (this.navigationStarted) {
if (this.navigationStarted || this.isEditingItems()) {
return;
}
@@ -254,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') {
@@ -266,6 +353,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
}
this.qrData.set(response.qr_data.qr_code);
this.markPurchasePendingPayment();
this.startQrPolling();
}
} catch (error) {
@@ -276,6 +364,10 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
}
protected async generateTransferIntent(dni: string): Promise<void> {
if (this.isEditingItems()) {
return;
}
const purchaseId = this.createdPurchaseId();
const tenant = this.tenantService.tenant();
@@ -287,14 +379,12 @@ 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();
this.transferAccount.set({
titular: response.transfer_data.titular,
entidad: response.transfer_data.entidad,
@@ -334,6 +424,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
!purchaseId ||
!tenant ||
this.navigationStarted ||
this.isEditingItems() ||
this.transferValidationStatus() === 'checking'
) {
return;
@@ -342,22 +433,18 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
this.transferValidationStatus.set('checking');
try {
const purchase = await this.checkoutService.getPurchase(tenant.codigo, purchaseId);
const purchase = await this.checkoutService
.withCustomLoading()
.submitPurchaseForReview(tenant.codigo, purchaseId);
if (purchase.status === 'paid') {
this.handleConfirmedPayment(purchaseId);
return;
}
if (purchase.status === 'pending_payment') {
this.transferValidationStatus.set('pending');
this.navigateToPurchaseStatus(purchaseId);
return;
}
this.transferValidationStatus.set('pending');
this.transferValidationStatus.set('error');
} catch (error) {
console.error('Failed to validate transfer payment:', error);
console.error('Failed to submit purchase for review:', error);
this.transferValidationStatus.set('error');
}
}
@@ -401,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;
@@ -448,11 +537,22 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
}
}
private handleConfirmedPayment(purchaseId: number): void {
this.navigateToPurchaseStatus(purchaseId, true);
private markPurchasePendingPayment(): void {
this.createdPurchase.update((purchase) =>
purchase
? {
...purchase,
status: 'pending_payment',
}
: purchase,
);
}
private navigateToPurchaseStatus(purchaseId: number, clearCart = false): void {
private handleConfirmedPayment(purchaseId: number): void {
this.navigateToPurchaseStatus(purchaseId);
}
private navigateToPurchaseStatus(purchaseId: number): void {
if (this.navigationStarted) {
return;
}
@@ -460,10 +560,57 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
this.navigationStarted = true;
this.stopQrPolling();
if (clearCart) {
this.cartService.clearCart();
}
void this.router.navigate(['/checkout/status', purchaseId]);
}
private async loadPurchase(purchaseId: number): Promise<void> {
const tenant = this.tenantService.tenant();
if (!tenant) {
void this.router.navigate(['/']);
return;
}
try {
const purchase = await this.checkoutService
.withCustomLoading()
.getPurchase(tenant.codigo, purchaseId);
if (
purchase.status === 'in_review' ||
purchase.status === 'paid' ||
purchase.status === 'cancelled' ||
purchase.status === 'rejected' ||
purchase.status === 'expired'
) {
this.navigateToPurchaseStatus(purchaseId);
return;
}
if (purchase.status !== 'created' && purchase.status !== 'pending_payment') {
void this.router.navigate(['/']);
return;
}
this.createdPurchaseId.set(purchase.id);
this.createdPurchase.set(purchase);
this.checkoutStepIndex.set(purchase.status === 'pending_payment' ? 1 : 0);
if (
purchase.status === 'pending_payment' &&
this.paymentMethods.some((method) => method.id === purchase.payment_method)
) {
this.selectedPaymentMethod.set(purchase.payment_method as PaymentMethod);
this.transferDni.set(purchase.transfer_payer_dni ?? '');
}
this.isLoadingPurchase.set(false);
if (purchase.status === 'pending_payment' && purchase.payment_method === 'qr') {
await this.selectPaymentMethod('qr');
}
} catch (error) {
console.error('Failed to load purchase:', error);
void this.router.navigate(['/']);
}
}
}

View File

@@ -0,0 +1,8 @@
import { CanDeactivateFn } from '@angular/router';
interface PendingCheckoutComponent {
canDeactivate(): Promise<boolean>;
}
export const checkoutPendingPurchaseGuard: CanDeactivateFn<PendingCheckoutComponent> = (component) =>
component.canDeactivate();

View File

@@ -1,14 +1,13 @@
<div class="checkout-payment">
<div class="checkout-payment__content">
<section class="payment-methods" aria-labelledby="payment-methods-title">
<h2 id="payment-methods-title" class="payment-methods__title">Selecciona el metodo de pago</h2>
<h2 id="payment-methods-title" class="payment-methods__title">
Selecciona el metodo de pago
</h2>
<div class="payment-methods__list" role="radiogroup" aria-label="Metodo de pago">
@for (method of paymentMethods(); track method.id) {
<label
class="payment-method"
[class.is-selected]="selectedPaymentMethod() === method.id"
>
<label class="payment-method" [class.is-selected]="selectedPaymentMethod() === method.id">
<input
class="payment-method__radio"
type="radio"
@@ -21,7 +20,8 @@
<span class="payment-method__label">
@if (method.id === 'telepagos') {
<span class="telepagos-logo" aria-label="TelePagos">
<span class="telepagos-logo__tele">tele</span><span class="telepagos-logo__pagos">pagos</span>
<span class="telepagos-logo__tele">tele</span
><span class="telepagos-logo__pagos">pagos</span>
</span>
} @else {
{{ method.label }}
@@ -34,9 +34,9 @@
</div>
</section>
<section class="payment-panel d-flex justify-content-end" aria-live="polite">
<section class="payment-panel" aria-live="polite">
@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>
</div>
} @else if (selectedPaymentMethod() === 'qr') {
@@ -44,7 +44,8 @@
[qrData]="qrData()"
[paymentStatus]="qrPaymentStatus()"
[isCheckingPayment]="isCheckingQrPayment()"
(retryPolling)="retryQrPolling.emit()"
[paymentAmount]="qrPaymentAmount()"
[whatsappUrl]="whatsappUrl()"
/>
} @else if (selectedPaymentMethod() === 'transfer') {
<app-checkout-payment-transfer
@@ -52,6 +53,8 @@
[transferDni]="transferDni()"
[copiedTransferField]="copiedTransferField()"
[validationStatus]="transferValidationStatus()"
[paymentAmount]="qrPaymentAmount()"
[whatsappUrl]="whatsappUrl()"
(copyTransferValue)="requestCopy($event.field, $event.value)"
(submitDni)="generateTransferIntent.emit($event)"
(completePurchase)="complete.emit()"
@@ -62,4 +65,3 @@
</section>
</div>
</div>

View File

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

View File

@@ -30,6 +30,8 @@ export class CheckoutPaymentStepComponent {
readonly qrData = input<string | null>(null);
readonly qrPaymentStatus = input<QrPaymentStatus>('idle');
readonly isCheckingQrPayment = input<boolean>(false);
readonly qrPaymentAmount = input<number>(0);
readonly whatsappUrl = input<string | null>(null);
readonly transferValidationStatus = input<TransferValidationStatus>('idle');
readonly paymentMethodChange = output<PaymentMethod>();
@@ -37,7 +39,6 @@ export class CheckoutPaymentStepComponent {
readonly cancelStep = output<void>();
readonly complete = output<void>();
readonly generateTransferIntent = output<string>();
readonly retryQrPolling = output<void>();
protected selectPaymentMethod(method: PaymentMethod): void {
this.paymentMethodChange.emit(method);

View File

@@ -1,34 +1,34 @@
<div class="payment-panel__card">
<h3 class="payment-panel__title">Ingresa a tu billetera y escanea el siguiente QR</h3>
<div class="payment-panel__divider"></div>
<div class="qr-code" aria-label="QR de pago">
@if (qrData()) {
<qrcode [qrdata]="qrData()!" [width]="200" [errorCorrectionLevel]="'M'"></qrcode>
} @else {
<span class="qr-code__finder qr-code__finder--tl"></span>
<span class="qr-code__finder qr-code__finder--tr"></span>
<span class="qr-code__finder qr-code__finder--bl"></span>
}
@if (isCheckingPayment()) {
<div class="payment-verification" role="status" aria-live="polite">
<span class="payment-verification__spinner" aria-hidden="true"></span>
<span class="payment-verification__message">Verificando pago</span>
</div>
}
</div>
@if (paymentStatus() === 'timed_out') {
<p class="payment-status payment-status--warning" role="status">
Todavía no recibimos el pago.
</p>
<app-button type="button" variant="secondary" (click)="retryPolling.emit()">
Volver a verificar
</app-button>
} @else if (paymentStatus() === 'failed') {
<p class="payment-status payment-status--warning" role="alert">
El pago fue rechazado o cancelado.
</p>
<app-payment-verification-error
[paymentAmount]="paymentAmount()"
[whatsappUrl]="whatsappUrl()"
/>
} @else {
<h3 class="payment-panel__title">Ingresa a tu billetera y escanea el siguiente QR</h3>
<div class="payment-panel__divider"></div>
<div class="qr-code" aria-label="QR de pago">
@if (qrData()) {
<qrcode [qrdata]="qrData()!" [width]="200" [errorCorrectionLevel]="'M'"></qrcode>
} @else {
<span class="qr-code__finder qr-code__finder--tl"></span>
<span class="qr-code__finder qr-code__finder--tr"></span>
<span class="qr-code__finder qr-code__finder--bl"></span>
}
@if (isCheckingPayment()) {
<div class="payment-verification" role="status" aria-live="polite">
<span class="payment-verification__spinner" aria-hidden="true"></span>
<span class="payment-verification__message">Verificando pago</span>
</div>
}
</div>
@if (paymentStatus() === 'failed') {
<p class="payment-status payment-status--warning" role="alert">
El pago fue rechazado o cancelado.
</p>
}
}
</div>

View File

@@ -57,7 +57,9 @@
rgba(17, 17, 17, 0.95) 84% 100%
);
background-size: 18px 18px;
background-position: 0 0, 9px 9px;
background-position:
0 0,
9px 9px;
display: flex;
justify-content: center;
align-items: center;
@@ -86,7 +88,9 @@
height: 38px;
border: 5px solid #111111;
background: #ffffff;
box-shadow: inset 0 0 0 8px #ffffff, inset 0 0 0 14px #111111;
box-shadow:
inset 0 0 0 8px #ffffff,
inset 0 0 0 14px #111111;
&--tl {
top: 10px;

View File

@@ -27,4 +27,25 @@ describe('CheckoutPaymentQrComponent', () => {
expect(overlay.textContent).toContain('Verificando pago');
expect(overlay.querySelector('.payment-verification__spinner')).not.toBeNull();
});
it('shows the payment verification error and WhatsApp action after timeout', async () => {
await TestBed.configureTestingModule({
imports: [CheckoutPaymentQrComponent],
}).compileComponents();
const fixture = TestBed.createComponent(CheckoutPaymentQrComponent);
fixture.componentRef.setInput('paymentStatus', 'timed_out');
fixture.componentRef.setInput('paymentAmount', 300000);
fixture.componentRef.setInput('whatsappUrl', 'https://wa.me/543412602222');
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const whatsapp = Array.from(element.querySelectorAll('button')).find((button) =>
button.textContent?.includes('WhatsApp'),
);
expect(element.textContent).toMatch(/No pudimos verificar el pago de \$\s*300\.000\./);
expect(element.textContent).toContain('Por favor contactate con nosotros para resolverlo.');
expect(whatsapp).toBeDefined();
expect(element.textContent).not.toContain('Volver a verificar');
});
});

View File

@@ -1,19 +1,20 @@
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { QRCodeComponent } from '../../../../../../shared/components/qrcode/qrcode.component';
import { ButtonComponent } from '../../../../../../shared/components/button/button.component';
import { QrPaymentStatus } from '../../checkout-page.models';
import { PaymentVerificationErrorComponent } from '../payment-verification-error/payment-verification-error.component';
@Component({
selector: 'app-checkout-payment-qr',
standalone: true,
imports: [QRCodeComponent, ButtonComponent],
imports: [PaymentVerificationErrorComponent, QRCodeComponent],
templateUrl: './checkout-payment-qr.component.html',
styleUrl: './checkout-payment-qr.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CheckoutPaymentQrComponent {
readonly qrData = input<string | null>(null);
readonly paymentStatus = input<QrPaymentStatus>('idle');
readonly isCheckingPayment = input<boolean>(false);
readonly retryPolling = output<void>();
readonly paymentAmount = input<number>(0);
readonly whatsappUrl = input<string | null>(null);
}

View File

@@ -1,105 +1,106 @@
<div class="payment-panel__card">
<div class="dni-form-container">
<h3 class="dni-form-title">Ingresá DNI de quién va a transferir</h3>
<form class="dni-form row gx-2" (submit)="$event.preventDefault(); onSubmitDni()">
<div class="col-8">
<app-input
type="text"
[value]="dniControl.value"
(valueChange)="dniControl.setValue($event.toString()); dniControl.markAsDirty()"
[disabled]="isDniDisabled()"
[invalid]="dniControl.invalid && (dniControl.dirty || dniControl.touched)"
/>
</div>
<div class="col-4">
@if (isDniDisabled()) {
<app-button
type="button"
variant="secondary"
hostClass="w-100 h-100"
buttonClass="w-100 h-100 px-0"
(click)="enableEditing()"
>
Modificar
</app-button>
} @else {
<app-button
type="submit"
variant="primary"
hostClass="w-100 h-100"
buttonClass="w-100 h-100 px-0"
[disabled]="dniControl.invalid"
>
Continuar
</app-button>
}
</div>
</form>
</div>
@if (transferAccount() && !isEditing()) {
<div class="payment-panel__divider"></div>
<div class="payment-panel__account">
<p class="payment-panel__eyebrow">DATOS DE CUENTA:</p>
<div class="payment-detail">
<span class="payment-detail__label">Titular:</span>
<span class="payment-detail__value">{{ transferAccount()?.titular }}</span>
</div>
<div class="payment-detail">
<span class="payment-detail__label">Entidad:</span>
<span class="payment-detail__value">{{ transferAccount()?.entidad }}</span>
</div>
<div class="payment-detail payment-detail--copy">
<span class="payment-detail__label">CVU:</span>
<span class="payment-detail__value">{{ transferAccount()?.cvu }}</span>
<app-icon-button
variant="copy"
ariaLabel="Copiar CVU"
(click)="requestCopy('cvu', transferAccount()!.cvu)"
/>
@if (copiedTransferField() === 'cvu') {
<span class="payment-detail__feedback" style="grid-column: 1 / -1; text-align: center;">¡Copiado!</span>
}
</div>
<div class="payment-detail payment-detail--copy">
<span class="payment-detail__label">Alias:</span>
<span class="payment-detail__value">{{ transferAccount()?.alias }}</span>
<app-icon-button
variant="copy"
ariaLabel="Copiar Alias"
(click)="requestCopy('alias', transferAccount()!.alias)"
/>
@if (copiedTransferField() === 'alias') {
<span class="payment-detail__feedback" style="grid-column: 1 / -1; text-align: center;">¡Copiado!</span>
}
</div>
@if (validationStatus() === 'pending' || validationStatus() === 'error') {
<app-payment-verification-error
[paymentAmount]="paymentAmount()"
[whatsappUrl]="whatsappUrl()"
/>
} @else {
<div class="dni-form-container">
<h3 class="dni-form-title">Ingresá DNI de quién va a transferir</h3>
<form class="dni-form row gx-2" (submit)="$event.preventDefault(); onSubmitDni()">
<div class="col-8">
<app-input
type="text"
[value]="dniControl.value"
(valueChange)="dniControl.setValue($event.toString()); dniControl.markAsDirty()"
[disabled]="isDniDisabled()"
[invalid]="dniControl.invalid && (dniControl.dirty || dniControl.touched)"
/>
</div>
<div class="col-4">
@if (isDniDisabled()) {
<app-button
type="button"
variant="secondary"
hostClass="w-100 h-100"
buttonClass="w-100 h-100 px-0"
(click)="enableEditing()"
>
Modificar
</app-button>
} @else {
<app-button
type="submit"
variant="primary"
hostClass="w-100 h-100"
buttonClass="w-100 h-100 px-0"
[disabled]="dniControl.invalid"
>
Continuar
</app-button>
}
</div>
</form>
</div>
<div class="mt-4">
<app-button
type="button"
variant="primary"
hostClass="w-100"
buttonClass="w-100"
[disabled]="validationStatus() === 'checking'"
(click)="completePurchase.emit()"
>
{{ validationStatus() === 'checking' ? 'Validando pago...' : 'Ya transferí' }}
</app-button>
@if (transferAccount() && !isEditing()) {
<div class="payment-panel__divider"></div>
<div class="payment-panel__account">
<p class="payment-panel__eyebrow">DATOS DE CUENTA:</p>
@if (validationStatus() === 'pending') {
<p class="payment-validation payment-validation--pending" role="status">
Todavía no recibimos la transferencia. Podés volver a verificar.
</p>
} @else if (validationStatus() === 'error') {
<p class="payment-validation payment-validation--error" role="alert">
No pudimos validar la transferencia. Intentá nuevamente.
</p>
}
</div>
<div class="payment-detail">
<span class="payment-detail__label">Titular:</span>
<span class="payment-detail__value">{{ transferAccount()?.titular }}</span>
</div>
<div class="payment-detail">
<span class="payment-detail__label">Entidad:</span>
<span class="payment-detail__value">{{ transferAccount()?.entidad }}</span>
</div>
<div class="payment-detail payment-detail--copy">
<span class="payment-detail__label">CVU:</span>
<span class="payment-detail__value">{{ transferAccount()?.cvu }}</span>
<app-icon-button
variant="copy"
ariaLabel="Copiar CVU"
(click)="requestCopy('cvu', transferAccount()!.cvu)"
/>
@if (copiedTransferField() === 'cvu') {
<span class="payment-detail__feedback" style="grid-column: 1 / -1; text-align: center"
>¡Copiado!</span
>
}
</div>
<div class="payment-detail payment-detail--copy">
<span class="payment-detail__label">Alias:</span>
<span class="payment-detail__value">{{ transferAccount()?.alias }}</span>
<app-icon-button
variant="copy"
ariaLabel="Copiar Alias"
(click)="requestCopy('alias', transferAccount()!.alias)"
/>
@if (copiedTransferField() === 'alias') {
<span class="payment-detail__feedback" style="grid-column: 1 / -1; text-align: center"
>¡Copiado!</span
>
}
</div>
</div>
<div class="mt-4">
<app-button
type="button"
variant="primary"
hostClass="w-100"
buttonClass="w-100"
[disabled]="validationStatus() === 'checking'"
(click)="completePurchase.emit()"
>
{{ validationStatus() === 'checking' ? 'Validando pago...' : 'Ya transferí' }}
</app-button>
</div>
}
}
</div>

View File

@@ -115,17 +115,3 @@
height: 100%;
}
}
.payment-validation {
margin: 0.75rem 0 0;
font-size: 0.8rem;
line-height: 1.35;
&--pending {
color: #8a681d;
}
&--error {
color: #b42318;
}
}

View File

@@ -1,23 +1,46 @@
import { ChangeDetectionStrategy, Component, computed, input, output, signal, OnInit, effect, untracked } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
signal,
OnInit,
effect,
untracked,
} from '@angular/core';
import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
import { IconButtonComponent } from '../../../../../../shared/components/icon-button/icon-button.component';
import { ButtonComponent } from '../../../../../../shared/components/button/button.component';
import { InputComponent } from '../../../../../../shared/components/input/input.component';
import { TransferAccount, TransferField, TransferValidationStatus } from '../../checkout-page.models';
import {
TransferAccount,
TransferField,
TransferValidationStatus,
} from '../../checkout-page.models';
import { PaymentVerificationErrorComponent } from '../payment-verification-error/payment-verification-error.component';
@Component({
selector: 'app-checkout-payment-transfer',
standalone: true,
imports: [IconButtonComponent, ButtonComponent, InputComponent, ReactiveFormsModule],
imports: [
IconButtonComponent,
ButtonComponent,
InputComponent,
ReactiveFormsModule,
PaymentVerificationErrorComponent,
],
templateUrl: './checkout-payment-transfer.component.html',
styleUrl: './checkout-payment-transfer.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CheckoutPaymentTransferComponent implements OnInit {
readonly transferAccount = input<TransferAccount | null>(null);
readonly transferDni = input<string>('');
readonly copiedTransferField = input<TransferField | null>(null);
readonly validationStatus = input<TransferValidationStatus>('idle');
readonly paymentAmount = input<number>(0);
readonly whatsappUrl = input<string | null>(null);
readonly copyTransferValue = output<{ field: TransferField; value: string }>();
readonly submitDni = output<string>();
@@ -25,7 +48,7 @@ export class CheckoutPaymentTransferComponent implements OnInit {
protected readonly dniControl = new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.pattern(/^\d{7,8}$/)]
validators: [Validators.required, Validators.pattern(/^\d{7,8}$/)],
});
protected readonly isEditing = signal<boolean>(false);

View File

@@ -0,0 +1,17 @@
<div class="payment-timeout" role="alert">
<div class="payment-timeout__icon" aria-hidden="true">
<i class="fa-solid fa-xmark"></i>
</div>
<h4 class="payment-timeout__title">No pudimos verificar el pago de {{ formattedAmount() }}.</h4>
<p class="payment-timeout__message">Por favor contactate con nosotros para resolverlo.</p>
@if (whatsappUrl()) {
<app-button
type="button"
variant="primary"
buttonClass="d-inline-flex align-items-center justify-content-center gap-2"
(click)="openWhatsApp()"
>
<span>WhatsApp</span>
</app-button>
}
</div>

View File

@@ -0,0 +1,35 @@
.payment-timeout {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
padding-top: 0.85rem;
&__icon {
display: grid;
width: 84px;
height: 84px;
place-items: center;
border: 1px solid var(--tenant-danger, #dc3545);
border-radius: 50%;
color: var(--tenant-danger, #dc3545);
font-size: 2.7rem;
}
&__title {
max-width: 250px;
margin: 1.35rem 0 0;
color: var(--tenant-danger, #dc3545);
font-size: 1.05rem;
font-weight: 700;
line-height: 1.3;
}
&__message {
max-width: 250px;
margin: 2rem 0 1.75rem;
color: #666666;
font-size: 1rem;
line-height: 1.25;
}
}

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