122 Commits

Author SHA1 Message Date
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
1d5c94b9bc feat(help-page): enhance help section with dynamic footer links and add pending help page 2026-07-23 17:13:43 -03:00
afaa761acc feat(help-page): implement help page layout with FAQ section and sidebar navigation 2026-07-23 17:13:33 -03:00
d50bd40602 feat(menu-guard): enhance menu guard logic to support nested menus 2026-07-23 17:10:04 -03:00
aa51742627 feat(accordion): implement reusable accordion component with multiple item support 2026-07-23 16:50:12 -03:00
21119ff303 feat(account-page): implement sidebar layout with account sidebar and routing 2026-07-23 15:40:55 -03:00
44bcb9aeba feat(social-media): implement social media component and integrate into store footer 2026-07-23 15:07:29 -03:00
67702ffdd7 feat(carousel): enhance image loading strategy and improve product display in carousel 2026-07-23 14:44:52 -03:00
b04c33bbff feat(carousel): add main carousel component with image support and integrate into store home page 2026-07-23 14:27:03 -03:00
5d1ed7efa2 feat(carousel): enhance product list layout with adaptive grid and simple vertical options 2026-07-23 13:41:40 -03:00
37bbd65cfe feat(carousel): adjust item width calculation for better distribution and responsiveness 2026-07-23 13:37:10 -03:00
862751fec8 feat(carousel): implement reusable carousel for product list and enhance layout options 2026-07-23 13:27:07 -03:00
251411d200 feat(carousel): introduce new catalog group layout options and update related components for enhanced product display 2026-07-23 13:25:35 -03:00
4f444fd757 feat(carousel): enhance carousel layout and control positioning for improved responsiveness 2026-07-23 12:29:08 -03:00
ca1cacc9ea feat(carousel): add new product images and update carousel item details 2026-07-23 12:23:48 -03:00
ea0bd4a956 feat(carousel): enhance item retrieval logic for improved circular navigation 2026-07-23 12:15:44 -03:00
8963bd5460 feat(carousel): update test description and improve item selection logic in carousel component 2026-07-23 12:09:28 -03:00
a1d5ecb6fa feat(carousel): implement circular layout and enhance navigation logic 2026-07-23 12:09:21 -03:00
fb6ab2fca2 feat(carousel): add circular scrolling support and improve button disable logic 2026-07-23 11:53:56 -03:00
326f2d9bcd feat(carousel): add viewport adjustment test and refine gap handling in layout calculations 2026-07-23 11:49:34 -03:00
8268e58547 feat(carousel): enhance carousel component with gap support and layout adjustments 2026-07-23 11:49:23 -03:00
3e98d08d59 feat(carousel): implement product carousel component with mock data and styles 2026-07-23 11:47:10 -03:00
20c8b04638 feat(auth): implement Google OAuth login flow and handle session clearing on 401 2026-07-22 17:01:11 -03:00
c662c27fc7 feat(auth): integrate TenantService to include tenant code in registration payload 2026-07-22 11:58:31 -03:00
3cefaf85d9 feat(qr-modal): implement QR modal component and integrate with tickets page 2026-07-21 14:55:46 -03:00
472dead0ff feat(tickets): separate active and inactive tickets, update selection logic and styles 2026-07-21 14:35:38 -03:00
fa8032c557 feat(ticket): enhance ticket component with disabled and expired states, update styles and tests 2026-07-21 14:25:52 -03:00
6c5e10feb2 feat(ticket): update ticket title styles and adjust QR button dimensions 2026-07-21 14:14:35 -03:00
63541a3593 feat(ticket): adjust QR button styles for better responsiveness 2026-07-21 14:12:50 -03:00
bc37a91c7f feat(tickets): implement tickets page with ticket component and service 2026-07-21 14:12:23 -03:00
54826a7f8a feat(ticket): add reusable ticket component with actions and styles 2026-07-21 13:37:31 -03:00
8f57af3cf5 feat(purchase-item): enhance layout for items without images and add description handling 2026-07-21 12:21:26 -03:00
da6614a9f8 feat(auth): update session handling on 401 responses and refactor error propagation 2026-07-21 10:36:48 -03:00
8e44fa5a46 feat(purchase-detail): update item details structure and handle null values in mapping 2026-07-21 10:22:32 -03:00
938d15bf83 feat(checkout): implement QR payment polling and validation logic 2026-07-21 09:52:19 -03:00
dafaa85480 feat(cart): implement add to cart functionality and display empty cart message 2026-07-20 17:11:06 -03:00
98e899c5bf feat(product-list): enhance layout handling with responsive design and computed properties 2026-07-20 16:59:57 -03:00
0c0d2b559d feat(catalog): refactor product and variant interfaces for improved structure and clarity 2026-07-20 16:46:17 -03:00
a233a93d16 feat(store-home-page): add margin styling for adjacent store sections 2026-07-20 16:24:24 -03:00
b0e97fc577 feat(store-home-page): refactor to use catalog structure and update product loading logic 2026-07-20 16:08:18 -03:00
5165ea0716 feat(product-list): implement pagination and update items to use paginated response 2026-07-20 14:39:12 -03:00
c0eb3369c5 feat(product-list): replace ProductCard with ProductColumnWithImage and update related components 2026-07-20 14:27:24 -03:00
625990f594 feat(product-vertical-with-cart-card): add reusable vertical product card component with cart functionality 2026-07-17 14:14:05 -03:00
d4c671dc7a refactor(product-row-card): move variant selection logic into constructor for better clarity 2026-07-17 13:47:40 -03:00
944c12a7f3 feat(product-row-card): update variant selection logic to default to first variant if none selected 2026-07-17 13:45:36 -03:00
9a4e2a03e9 feat(product-card): update title color for better visibility and readability
feat(quantity-selector): change background to inherit for consistent styling
2026-07-17 13:45:16 -03:00
40cc2b840d feat(product-row-card): add reusable product row card component with styling and functionality 2026-07-17 11:54:12 -03:00
45c2ee7a2f feat(store-header): update brand slot dimensions for improved layout 2026-07-16 16:07:41 -03:00
b64c627702 feat(checkout): update payment intent payload to use 'transfer_payer_dni' and remove placeholder from DNI input 2026-07-16 11:38:10 -03:00
fc425543ee feat(checkout): add DNI input handling for transfer payments and update related components 2026-07-16 11:28:33 -03:00
36045656e7 feat(checkout): enhance transfer payment flow with DNI input handling and completion action 2026-07-16 11:18:26 -03:00
ada832cbba feat(checkout): update button width and background color for payment components 2026-07-16 11:02:30 -03:00
524eea38f4 feat(checkout): enhance transfer payment flow with DNI input and validation 2026-07-16 10:55:28 -03:00
f163e8a353 feat(checkout): refactor payment step components for QR, transfer, and TelePagos methods 2026-07-16 10:21:59 -03:00
8b785fd631 feat(cart): refactor cart item structure and update related components for consistency 2026-07-16 10:02:24 -03:00
e4195a8567 feat(product-detail): enhance product variant handling with inventory policies and update UI components 2026-07-15 14:33:39 -03:00
25085f6f85 feat(hero-banner): update event icons and add gradient overlay to hero banner 2026-07-14 14:30:01 -03:00
257e4fbeb8 feat(hero-banner): add HeroBanner component with dynamic hero and event configurations 2026-07-14 14:03:51 -03:00
cc87169c1c feat(menu-guard): implement hasMenuGuard for route access control based on tenant menu permissions 2026-07-14 12:22:26 -03:00
e383a8f159 feat(tenant): add HeroConfig, EventConfig, and Menu interfaces to enhance tenant structure 2026-07-14 12:17:40 -03:00
993aa5823a feat(product-detail): implement resolver for product details and update component logic for improved error handling
feat(product-carousel): enhance image loading with optimized attributes and improve layout consistency
style: refactor HTML structure for better readability and maintainability
test(product-detail): add unit tests for product detail resolver and component behavior
2026-07-08 16:12:35 -03:00
6c00a3e2bb feat(store-home): implement resolver for product data and enhance component logic for improved error handling and loading states 2026-07-08 16:04:11 -03:00
efa0d48a40 feat(purchase-item): enhance attributes display for quantity-only items 2026-07-08 15:42:09 -03:00
9fcbf34573 feat(purchase): enhance purchase detail and summary interfaces, update purchase item component for improved data handling 2026-07-08 15:31:37 -03:00
451dc58cd7 style: update cart item layout and typography for improved consistency and readability 2026-07-08 14:27:01 -03:00
26466f57db style: update page title color to #A0A0A0 for consistency across components 2026-07-08 14:16:06 -03:00
a9dc55c274 feat(purchase-detail): implement purchase item component with dynamic attributes and styling 2026-07-08 14:09:51 -03:00
5e9a5917fd feat(purchase-detail): restructure cart item layout for improved readability and alignment 2026-07-08 13:47:23 -03:00
e06edaaa42 feat(purchase-detail): add purchase detail page with routing and mock data 2026-07-08 13:40:29 -03:00
1f2ca09b81 style: update font sizes for labels and page titles across multiple components 2026-07-08 13:16:50 -03:00
56355569f2 fix: correct spelling of "contraseña" in various components and templates 2026-07-08 12:26:46 -03:00
036b2a950f feat(profile): enhance profile form with validation messages and submission logic 2026-07-08 12:12:22 -03:00
7507580c2c feat(profile): add updateProfile method and enhance password validation in register form 2026-07-08 12:06:36 -03:00
b016569b10 feat(purchase-list): implement loading skeleton and enhance purchase list fetching logic 2026-07-08 11:31:52 -03:00
23f48ccec0 style(account-sidebar): update active nav link color to use CSS variable for consistency 2026-07-08 11:20:17 -03:00
6a5683f6b7 feat(purchase-list): refactor purchase list item structure and styles for improved layout 2026-07-08 10:12:20 -03:00
b7ed16966c feat(profile): update profile form layout and functionality, enhance styles for better responsiveness 2026-07-08 09:34:24 -03:00
baf5e2d4e7 feat(layout): enhance layout structure by adding padding and container classes 2026-07-08 09:14:33 -03:00
64ade4d595 feat(profile-form): refactor form-group styles and update error handling in checkout form 2026-07-08 09:05:15 -03:00
b9b8a70ca1 style(profile-form): adjust label styles and remove gap in form group 2026-07-08 08:52:04 -03:00
28ffe3c788 feat(user-dropdown): convert buttons to links for account and purchases navigation 2026-07-08 08:44:07 -03:00
4969026fe9 feat(purchase-list-item): create purchase list item component with styles and template 2026-07-08 08:39:57 -03:00
09d88e6324 feat(account-page): implement account layout with sidebar, profile form, and purchase list components 2026-07-08 08:39:52 -03:00
1a2d303b4e feat(account-page): implement account layout, profile, and purchases pages with corresponding components 2026-07-08 08:36:17 -03:00
253 changed files with 15869 additions and 2197 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"

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",

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

View File

@@ -1,20 +1,40 @@
import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http';
import { ApplicationConfig, provideAppInitializer, provideBrowserGlobalErrorListeners } from '@angular/core';
import { HttpRequest, provideHttpClient, withFetch, withInterceptors } from '@angular/common/http';
import {
ApplicationConfig,
provideAppInitializer,
provideBrowserGlobalErrorListeners,
} from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideClientHydration } from '@angular/platform-browser';
import { provideClientHydration, withHttpTransferCacheOptions } from '@angular/platform-browser';
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 {
return (
request.method === 'GET' &&
/\/api\/tenants\/[^/]+\/productos(?:\/\d+)?(?:\?|$)/.test(request.urlWithParams)
);
}
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
provideClientHydration(),
provideHttpClient(withFetch(), withInterceptors([authInterceptor])),
provideClientHydration(
withHttpTransferCacheOptions({
includeRequestsWithAuthHeaders: true,
filter: isStoreCatalogRequest,
}),
),
provideHttpClient(
withFetch(),
withInterceptors([authInterceptor, globalLoadingInterceptor])
),
provideAppInitializer(authBootstrap),
provideAppInitializer(tenantBootstrap)
]
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(
@@ -160,11 +161,60 @@ describe('app routes', () => {
expect(placeholders).toEqual([
'Nombre y Apellido',
'Email',
'Contrasena',
'Repetir Contrasena'
'Contraseña',
'Repetir Contraseña'
]);
});
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: {

View File

@@ -0,0 +1,25 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { findMenu } from '../services/menu.utils';
import { TenantService } from '../services/tenant.service';
export const hasMenuGuard = (menuCode: string): CanActivateFn => {
return () => {
const tenantService = inject(TenantService);
const router = inject(Router);
const tenant = tenantService.tenant();
if (!tenant) {
return router.createUrlTree(['/']);
}
const hasMenu = findMenu(tenant.menues ?? [], menuCode) !== undefined;
if (hasMenu) {
return true;
}
return router.createUrlTree(['/']);
};
};

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 col-md-6 col-xl-5 d-grid gap-3 align-content-start">
<div class="col-12">
<div
class="store-layout__brand-slot d-inline-flex align-items-center gap-3"
data-testid="store-footer-brand-slot"
@@ -15,40 +15,60 @@
</div>
}
</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 text-light-emphasis">
<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 text-light-emphasis">
<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">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 gap-3 align-content-start">
<h2 class="store-layout__footer-heading">{{ section.heading }}</h2>
<div class="col-12 col-md-6 col-xl-2 d-grid align-content-start">
<div class="d-grid gap-2">
@for (link of section.links; track link) {
<span class="store-layout__muted-text">{{ link }}</span>
@for (link of section.links; track link.label) {
@if (link.routerLink) {
<a
class="store-layout__footer-link"
[routerLink]="link.routerLink"
>
{{ link.label }}
</a>
} @else if (link.action === 'logout') {
<button
type="button"
class="store-layout__footer-link store-layout__footer-action"
(click)="logoutClick.emit()"
>
{{ link.label }}
</button>
} @else {
<span class="store-layout__muted-text">{{ link.label }}</span>
}
}
</div>
</div>
}
<div class="col-12 col-md-6 col-xl-3 d-grid gap-3 align-content-start">
<h2 class="store-layout__footer-heading">Seguinos:</h2>
<div class="col-12 col-md-6 col-xl-3 d-grid align-content-start">
<div class="d-flex align-items-center gap-3">
@for (social of socialLinks; track social.label) {
<span class="fs-5 text-white">
<i [class]="social.iconClass" aria-hidden="true"></i>
<span class="visually-hidden">{{ social.label }}</span>
</span>
@for (social of socialMedia; track social.code) {
<app-social-media
class="text-white"
[code]="social.code"
[icon]="social.icon"
[name]="social.name"
[url]="social.url"
/>
}
</div>
</div>

View File

@@ -3,6 +3,7 @@
}
.store-layout__brand-slot {
width: min(100%, 12rem);
min-width: 10rem;
}
@@ -18,6 +19,7 @@
height: 100%;
max-height: 2.75rem;
object-fit: contain;
object-position: left center;
}
.store-layout__brand-logo-skeleton {
@@ -55,17 +57,30 @@
color: #ffffff;
}
.store-layout__footer-heading {
margin: 0;
font-size: 0.95rem;
font-weight: 500;
color: #f8f8f8;
}
.store-layout__muted-text {
color: rgba(255, 255, 255, 0.72);
}
.store-layout__footer-link {
width: fit-content;
color: rgba(255, 255, 255, 0.72);
text-decoration: none;
&:hover,
&:focus-visible {
color: #ffffff;
text-decoration: underline;
}
}
.store-layout__footer-action {
padding: 0;
border: 0;
background: transparent;
font: inherit;
text-align: left;
}
@keyframes store-footer-logo-skeleton {
from {
background-position: 200% 0;

View File

@@ -1,24 +1,30 @@
import { Component, Input } from '@angular/core';
import { Component, Input, output } from '@angular/core';
import { RouterLink } from '@angular/router';
import { SocialMedia } from '../../../services/tenant.interface';
import { SocialMediaComponent } from '../../../../shared/components/social-media/social-media.component';
export type StoreFooterLink = {
label: string;
routerLink?: string;
action?: 'logout';
};
export type StoreFooterSection = {
heading: string;
links: string[];
};
export type StoreSocialLink = {
label: string;
iconClass: string;
links: StoreFooterLink[];
};
@Component({
selector: 'app-store-footer',
imports: [],
imports: [RouterLink, SocialMediaComponent],
templateUrl: './store-footer.component.html',
styleUrl: './store-footer.component.scss'
styleUrl: './store-footer.component.scss',
})
export class StoreFooterComponent {
@Input({ required: true }) currentYear = new Date().getFullYear();
@Input({ required: true }) footerSections: StoreFooterSection[] = [];
@Input({ required: true }) socialLinks: StoreSocialLink[] = [];
@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,6 +1,8 @@
<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">
<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"
@@ -16,24 +18,57 @@
}
</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="d-flex flex-row align-items-center gap-3 ms-auto flex-nowrap flex-grow-1 justify-content-between justify-content-md-end"
>
<div class="store-layout__search-wrapper">
<div class="input-group store-layout__search" role="search">
<label class="visually-hidden" for="store-search-input">Buscar productos</label>
<input
id="store-search-input"
type="search"
class="form-control store-layout__search-input border-end-0 rounded-start"
placeholder="Buscar productos"
autocomplete="off"
[attr.aria-describedby]="showSearchError() ? 'store-search-error' : null"
[attr.aria-invalid]="showSearchError()"
[minlength]="minSearchLength"
[formControl]="searchControl"
(input)="clearSearchError()"
(keydown.enter)="submitSearch($event)"
/>
<button
type="button"
class="btn store-layout__search-button border-start-0 rounded-end px-3"
aria-label="Buscar"
(click)="submitSearch()"
>
<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
</button>
</div>
<div class="d-flex align-items-center justify-content-end gap-1" aria-label="Acciones de usuario">
@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>
@if (ticketsMenu(); as menu) {
<app-button
variant="primary"
hostClass="flex-shrink-0"
data-testid="store-header-tickets"
(click)="ticketsClick.emit()"
>
{{ menu.label }}
</app-button>
}
<div
class="d-flex align-items-center justify-content-end gap-1"
aria-label="Acciones de usuario"
>
<app-cart-icon
[quantity]="cartQuantity()"
ariaLabel="Carrito de compras"
@@ -54,6 +89,7 @@
<app-user-dropdown
id="store-user-dropdown"
[user]="user()"
[accountNavigationMenus]="accountNavigationMenus()"
(logoutClick)="onLogoutClick()"
/>
}
@@ -63,16 +99,28 @@
</div>
</div>
<div>
<div class="store-layout__categories">
<div class="container-xl px-3 px-md-4">
<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

@@ -9,7 +9,8 @@
}
.store-layout__brand-slot {
min-width: 10rem;
min-width: 150px;
max-width:40px;
}
.store-layout__brand-logo-shell {
@@ -40,11 +41,22 @@
animation: store-header-logo-skeleton 1.2s ease-in-out infinite;
}
.store-layout__search {
.store-layout__search-wrapper {
width: min(100%, 260px);
min-width: 0;
}
.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;
@@ -99,6 +111,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;

View File

@@ -4,12 +4,25 @@ 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>);
@@ -18,27 +31,39 @@ export class StoreHeaderComponent {
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 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()) {
return;
}
const target = event.target;
if (target instanceof Node && !this.elementRef.nativeElement.contains(target)) {
this.isUserDropdownOpen.set(false);
this.isCategoryDropdownOpen.set(false);
}
}
@HostListener('document:keydown.escape')
protected onEscapeKey(): void {
this.isUserDropdownOpen.set(false);
this.isCategoryDropdownOpen.set(false);
}
protected toggleUserDropdown(): void {
@@ -59,4 +84,31 @@ 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.categorySelect.emit(category);
}
}

View File

@@ -5,12 +5,16 @@
<div class="user-dropdown__divider"></div>
<button type="button" class="user-dropdown__item" role="menuitem" data-testid="user-dropdown-account">
Mi cuenta
</button>
<button type="button" class="user-dropdown__item" role="menuitem" data-testid="user-dropdown-purchases">
Mis compras
</button>
@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

@@ -35,6 +35,7 @@
}
.user-dropdown__item {
display: block;
width: 100%;
border: 0;
border-radius: 6px;
@@ -46,6 +47,7 @@
background: transparent;
cursor: pointer;
transition: color 0.15s ease;
text-decoration: none;
}
.user-dropdown__item:active,

View File

@@ -1,13 +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,20 +30,38 @@
[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>
}
<main class="flex-grow-1 d-block">
<section class="py-5">
<div class="container-xl px-3 px-md-4">
<router-outlet />
</div>
</section>
</main>
<app-store-footer
[currentYear]="currentYear"
[footerSections]="footerSections"
[socialLinks]="socialLinks"
[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,
@@ -25,14 +29,117 @@ 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: [],
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',
submenues: [
{
id: 2,
code: 'help.contact',
label: 'Contacto',
parent_menu_code: 'help',
content_type: 'static',
route: '/ayuda/contacto',
submenues: [],
},
{
id: 3,
code: 'help.faq',
label: 'Preguntas frecuentes',
parent_menu_code: 'help',
content_type: 'static',
route: '/ayuda/preguntas-frecuentes',
submenues: [],
},
],
},
],
social_media: [
{
code: 'instagram',
icon: 'instagram',
name: 'Instagram',
url: 'https://instagram.com/test',
},
{
code: 'whatsapp',
icon: 'whatsapp',
name: 'WhatsApp',
url: 'https://wa.me/5493410000000',
},
{
code: 'facebook',
icon: 'facebook',
name: 'Facebook',
url: 'https://facebook.com/test',
},
{
code: 'linkedin',
icon: 'fa-brands fa-linkedin-in',
name: 'LinkedIn',
url: 'https://linkedin.com/company/test',
},
],
};
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],
@@ -44,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,
@@ -63,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();
});
@@ -99,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();
@@ -112,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);
@@ -123,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();
@@ -141,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');
@@ -167,45 +416,196 @@ 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('redirects to /checkout when cart buy button is clicked', () => {
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');
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const footerLinks = Array.from(
compiled.querySelectorAll<HTMLAnchorElement>('app-store-footer a.store-layout__footer-link'),
);
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(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(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', () => {
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const footerLinks = Array.from(
compiled.querySelectorAll<HTMLAnchorElement>('app-store-footer a.store-layout__footer-link'),
);
const helpLinks = footerLinks.filter((link) =>
link.getAttribute('href')?.startsWith('/ayuda/'),
);
expect(helpLinks.map((link) => link.textContent?.trim())).toEqual([
'Contacto',
'Preguntas frecuentes',
]);
expect(helpLinks.map((link) => link.getAttribute('href'))).toEqual([
'/ayuda/contacto',
'/ayuda/preguntas-frecuentes',
]);
expect(compiled.textContent).not.toContain('Medios de pago');
});
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();
(fixture.componentInstance as any).isCartOpen.set(true);
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

@@ -2,26 +2,39 @@ import { Component, computed, inject, OnInit, signal } from '@angular/core';
import { Router, RouterOutlet } from '@angular/router';
import { TenantService } from '../../services/tenant.service';
import { CartService } from '../../services/cart/cart.service';
import { StoreFooterComponent, StoreFooterSection, StoreSocialLink } from './store-footer/store-footer.component';
import { StoreFooterComponent, StoreFooterSection } from './store-footer/store-footer.component';
import { StoreHeaderComponent } from './store-header/store-header.component';
import { CartComponent, CartItemMock } from '../../../shared/components/cart/cart.component';
import { ButtonComponent } from '../../../shared/components/button/button.component';
import { CartItem } from '../../services/cart/cart.interface';
import { AuthService } from '../../services/auth/auth.service';
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',
imports: [RouterOutlet, StoreHeaderComponent, StoreFooterComponent, CartComponent, ButtonComponent],
imports: [
RouterOutlet,
StoreHeaderComponent,
StoreFooterComponent,
CartComponent,
ButtonComponent,
],
templateUrl: './store-layout.component.html',
styleUrl: './store-layout.component.scss'
styleUrl: './store-layout.component.scss',
})
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();
@@ -57,14 +70,14 @@ export class StoreLayoutComponent implements OnInit {
}
return {
productVariantId: item.product_variant_id,
cartItemId: item.id,
imageUrl: item.product?.imagen ?? null,
product,
originalPrice: null,
discountedPrice: parseFloat(item.precio_unitario),
discountPercentage: null,
attributes,
quantity: item.cantidad
quantity: item.cantidad,
};
}
@@ -72,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();
@@ -81,7 +112,7 @@ export class StoreLayoutComponent implements OnInit {
ngOnInit(): void {
this.cartService.loadCart().subscribe({
error: (err) => console.error('Error loading cart', err)
error: (err) => console.error('Error loading cart', err),
});
}
@@ -89,46 +120,112 @@ export class StoreLayoutComponent implements OnInit {
void this.router.navigate(['/login']);
}
protected onLogoutClick(): void {
this.authService.logout().subscribe({
next: () => void this.router.navigate(['/login']),
error: (err) => console.error('Error logging out', err)
protected onSearch(term: string): void {
void this.router.navigate(['/buscar'], {
queryParams: { q: term, page: 1 },
});
}
protected onCheckoutClick(): void {
this.isCartOpen.set(false);
void this.router.navigate(['/checkout']);
protected onCategorySelect(category: Category): void {
void this.router.navigate(['/categoria', category.id], {
queryParams: { page: 1 },
});
}
protected onTicketsClick(): void {
const route = this.ticketsMenu()?.route;
protected readonly footerSections: StoreFooterSection[] = [
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: () => {
this.cartService.clearCart();
this.isCartOpen.set(false);
if (!isLeavingCheckout) {
void this.router.navigate(['/']);
}
},
error: (err) => console.error('Error logging out', err),
});
}
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 ?? [];
return [
{
heading: 'Cuenta',
links: ['Mi cuenta', 'Mis compras', 'Cerrar sesion']
links: [
...this.accountNavigationMenus().map((menu) => ({
label: menu.label,
routerLink: menu.route,
})),
{ label: 'Cerrar sesion', action: 'logout' },
],
},
{
heading: 'Ayuda',
links: ['Contacto', 'Ayuda', 'Preguntas frecuentes']
}
];
protected readonly socialLinks: StoreSocialLink[] = [
{
label: 'Instagram',
iconClass: 'fa-brands fa-instagram'
},
{
label: 'WhatsApp',
iconClass: 'fa-brands fa-whatsapp'
},
{
label: 'Facebook',
iconClass: 'fa-brands fa-facebook'
},
{
label: 'LinkedIn',
iconClass: 'fa-brands fa-linkedin-in'
}
links: helpSubmenues.map((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

@@ -1,4 +1,4 @@
import { HttpClient } from '@angular/common/http';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { computed, signal } from '@angular/core';
@@ -69,7 +69,7 @@ describe('authInterceptor', () => {
httpController.verify();
});
it('clears the local session when an authenticated request returns 401', () => {
it('propagates a 401 without clearing the local session', () => {
const authService = createAuthServiceStub('expired-token');
TestBed.configureTestingModule({
@@ -83,14 +83,21 @@ describe('authInterceptor', () => {
const client = TestBed.inject(HttpClient);
const httpController = TestBed.inject(HttpTestingController);
let receivedError: unknown;
let receivedStatus: number | null = null;
client.get(`${environment.url}me`).subscribe({
error: () => undefined
error: (error: HttpErrorResponse) => {
receivedError = error;
receivedStatus = error.status;
}
});
const request = httpController.expectOne(`${environment.url}me`);
request.flush({ message: 'Unauthorized' }, { status: 401, statusText: 'Unauthorized' });
expect(authService.clearSession).toHaveBeenCalledTimes(1);
expect(receivedError).toBeInstanceOf(HttpErrorResponse);
expect(receivedStatus).toBe(401);
expect(authService.clearSession).not.toHaveBeenCalled();
httpController.verify();
});
});

View File

@@ -1,12 +1,6 @@
import {
HttpErrorResponse,
HttpEvent,
HttpHandlerFn,
HttpInterceptorFn,
HttpRequest
} from '@angular/common/http';
import { HttpEvent, HttpHandlerFn, HttpInterceptorFn, HttpRequest } from '@angular/common/http';
import { inject } from '@angular/core';
import { Observable, catchError, throwError } from 'rxjs';
import { Observable } from 'rxjs';
import { AuthService } from './auth.service';
@@ -25,13 +19,5 @@ export const authInterceptor: HttpInterceptorFn = (
})
: request;
return next(authenticatedRequest).pipe(
catchError((error: unknown) => {
if (error instanceof HttpErrorResponse && error.status === 401 && token) {
authService.clearSession();
}
return throwError(() => error);
})
);
return next(authenticatedRequest);
};

View File

@@ -18,6 +18,14 @@ export interface RegisterPayload {
password_confirmation: string;
}
export interface UpdateProfilePayload {
nombre_apellido: string;
email: string;
dni?: string;
telefono?: string;
password?: string;
}
export interface LoginResponse {
message: string;
token: string;
@@ -29,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

@@ -6,6 +6,7 @@ import { HttpTestingController, provideHttpClientTesting } from '@angular/common
import { environment } from '../../../../environments/environment';
import { AuthService } from './auth.service';
import { CookieService } from '../cookie/cookie.service';
import { TenantService } from '../tenant.service';
describe('AuthService', () => {
let cookieStore: Record<string, string> = {};
@@ -18,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];
}
};
}
@@ -30,6 +35,10 @@ describe('AuthService', () => {
provideHttpClientTesting(),
AuthService,
{ provide: CookieService, useValue: createCookieServiceStub() },
{
provide: TenantService,
useValue: { getTenant: () => ({ codigo: 'tenant-test' }) }
},
TransferState
]
});
@@ -43,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',
@@ -95,7 +110,48 @@ describe('AuthService', () => {
httpController.verify();
});
it('clears the session when bootstrap receives 401 from /me', async () => {
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';
TestBed.configureTestingModule({
@@ -132,6 +188,10 @@ describe('AuthService', () => {
provideHttpClientTesting(),
AuthService,
{ provide: CookieService, useValue: createCookieServiceStub() },
{
provide: TenantService,
useValue: { getTenant: () => ({ codigo: 'tenant-test' }) }
},
TransferState
]
});
@@ -152,6 +212,7 @@ describe('AuthService', () => {
const request = httpController.expectOne(`${environment.url}register`);
expect(request.request.method).toBe('POST');
expect(request.request.body.tenant_codigo).toBe('tenant-test');
request.flush({
message: 'Usuario registrado correctamente.',
data: {
@@ -168,13 +229,132 @@ describe('AuthService', () => {
httpController.verify();
});
it('clears local session even when logout request fails', () => {
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: [
provideHttpClient(),
provideHttpClientTesting(),
AuthService,
{ provide: CookieService, useValue: createCookieServiceStub() },
{
provide: TenantService,
useValue: { getTenant: () => ({ codigo: 'tenant-test' }) }
},
TransferState
]
});
@@ -202,9 +382,9 @@ describe('AuthService', () => {
expect(logoutRequest.request.method).toBe('POST');
logoutRequest.flush({ message: 'Unauthorized' }, { status: 401, statusText: 'Unauthorized' });
expect(service.token()).toBeNull();
expect(service.user()).toBeNull();
expect(cookieStore['shopit.auth.token']).toBeUndefined();
expect(service.token()).toBe('plain-text-token');
expect(service.user()?.email).toBe('ada@example.com');
expect(cookieStore['shopit.auth.token']).toBe('plain-text-token');
httpController.verify();
});

View File

@@ -1,17 +1,25 @@
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { DOCUMENT } from '@angular/common';
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
RegisterResponse,
ResetPasswordPayload,
ResetPasswordAttemptResponse,
ResetPasswordResponse,
UpdateProfilePayload,
ValidateResetPasswordAttemptResponse
} from './auth.interfaces';
import { CookieService } from '../cookie/cookie.service';
import { TenantService } from '../tenant.service';
const AUTH_TOKEN_COOKIE_KEY = 'shopit.auth.token';
const AUTH_USER_SSR_STATE_KEY = makeStateKey<AuthUser>('shopit.auth.user');
@@ -19,11 +27,12 @@ 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);
private readonly transferState = inject(TransferState);
private readonly tenantService = inject(TenantService);
private readonly userState = signal<AuthUser | null>(null);
private readonly tokenState = signal<string | null>(null);
@@ -33,14 +42,111 @@ export class AuthService {
readonly isAuthenticated = computed(() => this.tokenState() !== null);
login(payload: LoginPayload): Observable<AuthUser> {
return this.http.post<LoginResponse>(`${environment.url}login`, payload).pipe(
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> {
return this.http.post<RegisterResponse>(`${environment.url}register`, payload);
const tenantCode = this.tenantService.getTenant()?.codigo;
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'
});
}
loginWithGoogle(): void {
const tenant = this.tenantService.getTenant();
if (!tenant) {
throw new Error('No se pudo resolver el tenant activo.');
}
const apiUrl = new URL(environment.url);
const authorizationUrl = new URL('/auth/google/redirect', apiUrl.origin);
authorizationUrl.searchParams.set('tenant', tenant.codigo);
authorizationUrl.searchParams.set('return_url', this.document.location.origin);
this.document.location.assign(authorizationUrl.toString());
}
completeGoogleLogin(oauthCode: string): Observable<AuthUser> {
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)));
}
logout(): Observable<void> {
@@ -49,12 +155,9 @@ export class AuthService {
return of(void 0);
}
return this.http.post<void>(`${environment.url}logout`, {}).pipe(
tap({
next: () => this.clearSession(),
error: () => this.clearSession()
})
);
return this.http
.post<void>(`${environment.url}logout`, {})
.pipe(tap(() => this.clearSession()));
}
async bootstrap(): Promise<void> {
@@ -73,12 +176,18 @@ export class AuthService {
try {
const user = await firstValueFrom(this.loadCurrentUser());
if (isPlatformServer(this.platformId)) {
this.transferState.set(AUTH_USER_SSR_STATE_KEY, user);
}
} catch (error) {
if (this.isUnauthorizedError(error)) {
// An expired token is an expected state, including while rendering on the
// server. Do not let it reject the application initializer: Angular's SSR
// pipeline can otherwise attempt to serialize HttpErrorResponse internals
// as HTTP headers.
if (error instanceof HttpErrorResponse && error.status === 401) {
this.clearSession();
return;
}
@@ -87,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 {
@@ -108,8 +217,4 @@ export class AuthService {
this.userState.set(user);
this.cookieService.set(AUTH_TOKEN_COOKIE_KEY, token);
}
private isUnauthorizedError(error: unknown): error is HttpErrorResponse {
return error instanceof HttpErrorResponse && error.status === 401;
}
}

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

@@ -7,8 +7,8 @@ export interface CartItem {
id: number;
cantidad: number;
precio_unitario: string;
product_id: number;
product_variant_id: number;
catalog_item_id: number;
variant_id: number | null;
product: CartItemProduct | null;
}

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;
@@ -21,21 +22,21 @@ describe('CartService', () => {
id: 1,
cantidad: 2,
precio_unitario: '10.00',
product_id: 5,
product_variant_id: 10,
catalog_item_id: 5,
variant_id: 10,
product: {
nombre: 'Test Product (Size: M)',
imagen: null
}
}
imagen: null,
},
},
],
subtotal: '20.00'
subtotal: '20.00',
};
beforeEach(() => {
tenantServiceMock = {
getTenantApiUrl: vi.fn().mockReturnValue('http://api.test/tenants/acme'),
tenant: vi.fn().mockReturnValue({ codigo: 'acme' })
tenant: vi.fn().mockReturnValue({ codigo: 'acme' }),
};
TestBed.configureTestingModule({
@@ -43,8 +44,8 @@ describe('CartService', () => {
CartService,
provideHttpClient(),
provideHttpClientTesting(),
{ provide: TenantService, useValue: tenantServiceMock }
]
{ provide: TenantService, useValue: tenantServiceMock },
],
});
service = TestBed.inject(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();
@@ -83,19 +92,23 @@ describe('CartService', () => {
tenant_codigo: 'acme',
status: 'active',
items: [],
subtotal: '0.00'
subtotal: '0.00',
});
});
it('should add item and update signal', () => {
service.addItem(10, 2).subscribe((res) => {
service.addItem(5, 10, 2).subscribe((res) => {
expect(res.data).toEqual(mockCart);
expect(service.cart()).toEqual(mockCart);
});
const req = httpMock.expectOne('http://api.test/tenants/acme/cart/items');
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual({ product_variant_id: 10, cantidad: 2 });
expect(req.request.body).toEqual({
catalog_item_id: 5,
variant_id: 10,
cantidad: 2,
});
expect(req.request.withCredentials).toBe(true);
req.flush({ data: mockCart });
});
@@ -122,7 +135,7 @@ describe('CartService', () => {
tenant_codigo: 'acme',
status: 'active',
items: [],
subtotal: '0.00'
subtotal: '0.00',
};
service.removeItem(10).subscribe((res) => {

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'
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);
@@ -29,28 +28,36 @@ export class CartService {
tenant_codigo: this.tenantService.tenant()?.codigo ?? '',
status: 'active',
items: [],
subtotal: '0.00'
subtotal: '0.00',
});
}
loadCart(): Observable<Cart> {
return this.http
.get<ApiResponse<Cart>>(`${this.tenantApiUrl}/cart`, {
withCredentials: true
withCredentials: true,
})
.pipe(
map((response) => response.data),
tap((cart) => this.cartState.set(cart))
tap((cart) => this.cartState.set(cart)),
);
}
addItem(productVariantId: number, cantidad: number): Observable<ApiResponse<Cart>> {
addItem(
catalogItemId: number,
variantId: number | null,
cantidad: number,
): Observable<ApiResponse<Cart>> {
this.isUpdatingState.set(true);
return this.http
.post<ApiResponse<Cart>>(
.post<
ApiResponse<Cart>
>(
`${this.tenantApiUrl}/cart/items`,
{ product_variant_id: productVariantId, cantidad },
{ withCredentials: true }
{ catalog_item_id: catalogItemId, variant_id: variantId, cantidad },
{
withCredentials: true,
},
)
.pipe(
tap((response) => {
@@ -60,17 +67,24 @@ export class CartService {
catchError((error) => {
this.isUpdatingState.set(false);
throw error;
})
}),
);
}
updateItemQuantity(productVariantId: 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/${productVariantId}`,
.patch<
ApiResponse<Cart>
>(
`${this.tenantApiUrl}/cart/items/${cartItemId}`,
{ cantidad },
{ withCredentials: true }
{
withCredentials: true,
},
)
.pipe(
tap((response) => {
@@ -80,17 +94,18 @@ export class CartService {
catchError((error) => {
this.isUpdatingState.set(false);
throw error;
})
}),
);
}
removeItem(productVariantId: number): Observable<ApiResponse<Cart>> {
removeItem(cartItemId: number): Observable<ApiResponse<Cart>> {
this.isUpdatingState.set(true);
return this.http
.delete<ApiResponse<Cart>>(
`${this.tenantApiUrl}/cart/items/${productVariantId}`,
{ withCredentials: true }
)
.delete<
ApiResponse<Cart>
>(`${this.tenantApiUrl}/cart/items/${cartItemId}`, {
withCredentials: true,
})
.pipe(
tap((response) => {
this.cartState.set(response.data);
@@ -99,7 +114,7 @@ export class CartService {
catchError((error) => {
this.isUpdatingState.set(false);
throw error;
})
}),
);
}
}

View File

@@ -1,3 +1,5 @@
import { ApiPaginatedResponse } from '../api-paginated-response.interface';
export interface Product {
id: number;
category_id: number;
@@ -29,22 +31,83 @@ export interface ProductAttribute {
options: ProductAttributeOption[];
}
export interface ProductVariant {
export type InventoryPolicy = 'tracked' | 'unlimited';
export interface CatalogItemVariant {
id: number;
cantidad_maxima: number;
definitions: Record<string, string>;
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>;
}
export interface SelectedCatalogItemVariant extends CatalogItemVariant {
images: string[];
}
export interface ProductVariantMap {
variant_id: number;
cantidad_maxima: number;
attributes: Record<string, string>;
}
export interface ProductDetail extends Product {
export interface CatalogItemDetail {
id: number;
category_id: number | null;
brand_id: number | null;
slug: string;
nombre: string;
descripcion: string | null;
precio: string;
category: string | null;
brand: string | null;
inventory_policy: InventoryPolicy;
has_tickets: boolean;
minimum_use_date: string | null;
maximum_use_date: string | null;
attributes: ProductAttribute[];
variants_map: ProductVariantMap[];
variant: ProductVariant | null;
variants: CatalogItemVariant[];
selected_variant?: SelectedCatalogItemVariant;
stock_tecnico?: number | null;
images?: string[];
}
export type CatalogProductLayout = 'row' | 'column_with_image' | 'column_with_cart';
export type CatalogGroupLayout = 'paginated' | 'simple' | 'simple_vertical' | 'carousel';
export interface CatalogFeaturedItemVariant {
id: number;
stock_tecnico: number | null;
values: Record<string, string>;
}
export interface CatalogFeaturedItem {
id: number;
nombre: string;
descripcion?: string | null;
precio: number | string;
image?: string | null;
stock_tecnico?: number | null;
variants?: CatalogFeaturedItemVariant[];
}
export type CatalogFeaturedItems =
| ApiPaginatedResponse<CatalogFeaturedItem[]>
| CatalogFeaturedItem[];
export interface CatalogFeaturedGroup {
id: number;
title: string;
layout: CatalogProductLayout;
group_layout: CatalogGroupLayout;
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,48 +1,92 @@
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 { Product, ProductDetail } from './catalog.interface';
import {
CatalogFeaturedGroup,
CatalogFeaturedItem,
CatalogFeaturedItems,
CatalogItemDetail,
CategoryItemsResponse,
Product,
} from './catalog.interface';
type HttpParamValue =
| string
| number
| boolean
| readonly (string | number | boolean)[];
type HttpParamValue = string | number | boolean | readonly (string | number | boolean)[];
export interface CatalogSearchQueryParams extends ApiPaginationQueryParams {
q: string;
}
@Injectable({
providedIn: 'root'
providedIn: 'root',
})
export class CatalogService {
private readonly http = inject(HttpClient);
export class CatalogService extends BaseApiService {
private readonly tenantService = inject(TenantService);
private get tenantApiUrl(): string {
return this.tenantService.getTenantApiUrl();
}
getProductos(params?: ApiPaginationQueryParams): Observable<ApiPaginatedResponse<Product[]>> {
return this.http.get<ApiPaginatedResponse<Product[]>>(`${this.tenantApiUrl}/productos`, {
params: this.buildHttpParams(params),
});
}
getProductos(
params?: ApiPaginationQueryParams
): Observable<ApiPaginatedResponse<Product[]>> {
return this.http.get<ApiPaginatedResponse<Product[]>>(
`${this.tenantApiUrl}/productos`,
{ params: this.buildHttpParams(params) }
getCatalog(): Observable<CatalogFeaturedGroup[]> {
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),
},
);
}
getProducto(id: number, variantId?: number): Observable<ProductDetail> {
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),
},
);
}
getCatalogItem(
id: number,
variantId?: number,
): Observable<CatalogItemDetail> {
let params = new HttpParams();
if (variantId) {
params = params.set('variant_id', variantId);
}
return this.http
.get<ApiResponse<ProductDetail>>(`${this.tenantApiUrl}/productos/${id}`, { params })
.get<ApiResponse<CatalogItemDetail>>(`${this.tenantApiUrl}/catalog-items/${id}`, {
params,
})
.pipe(map((response) => response.data));
}
@@ -59,7 +103,7 @@ export class CatalogService {
return acc;
},
{}
{},
);
return new HttpParams({ fromObject });

View File

@@ -1,33 +1,95 @@
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;
}
@Injectable({
providedIn: 'root'
})
export class CheckoutService {
private readonly http = inject(HttpClient);
export interface PurchaseSummaryResponse extends PurchaseStatusResponse {
id: number;
created_at: string | null;
total: string;
}
async createPurchase(tenantCode: string, payload: CreatePurchasePayload): Promise<{ id: number }> {
export interface PurchaseDetailAttributeResponse {
name: string;
value: string | number | boolean | null;
}
export interface PurchaseDetailItemResponse {
id: number;
quantity: number;
unit_price: string;
line_total: string;
source_catalog_item_id: number | null;
source_variant_id: number | null;
item_details: {
nombre: string;
descripcion: string | null;
slug: string;
imagen: string | null;
attributes: PurchaseDetailAttributeResponse[];
};
}
export interface PurchaseDetailResponse extends PurchaseStatusResponse {
id: number;
cart_id: number | null;
tenant_codigo: string;
user_id: number;
created_at: string | null;
payment_method: string | null;
dni: string | null;
transfer_payer_dni: string | null;
telefono: string | null;
nombre_apellido: string | null;
email: string | null;
items_source: 'purchase';
items: PurchaseDetailItemResponse[];
tickets_count?: number;
has_generated_tickets?: boolean;
subtotal: string;
total: string;
}
@Injectable({
providedIn: 'root',
})
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.');
@@ -36,9 +98,21 @@ export class CheckoutService {
return purchase;
}
async generatePaymentIntent(tenantCode: string, purchaseId: number, method: 'qr' | 'transfer' | 'telepagos'): 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`, { method })
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.');
@@ -46,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);
@@ -58,24 +198,82 @@ export class CheckoutService {
}
return {
status: purchase.status ?? null
status: purchase.status ?? null,
};
}
async getPurchase(tenantCode: string, purchaseId: string | number): Promise<PurchaseStatusResponse> {
async submitPurchaseForReview(
tenantCode: string,
purchaseId: number,
): Promise<PurchaseStatusResponse> {
const response = await firstValueFrom(
this.http.get<{ data?: PurchaseStatusResponse } | PurchaseStatusResponse>(`${environment.url}tenants/${tenantCode}/compras/${purchaseId}`)
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),
);
if (!response) {
throw new Error('Error al obtener las compras.');
}
return response;
}
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}`,
),
);
const purchase = this.extractResponseData<PurchaseDetailResponse>(response);
if (!purchase) {
throw new Error('Error al obtener la compra.');
}
return {
status: purchase.status ?? null
};
return purchase;
}
private extractResponseData<T>(response: T | { data?: T } | null | undefined): T | null {

View File

@@ -23,12 +23,9 @@ export class CookieService {
return null;
}
set(name: string, value: string, days = 7): void {
set(name: string, value: string): void {
if (isPlatformBrowser(this.platformId)) {
const date = new Date();
date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000);
const expires = `expires=${date.toUTCString()}`;
this.document.cookie = `${name}=${value};${expires};path=/;SameSite=Lax`;
this.document.cookie = `${name}=${value};path=/;SameSite=Lax`;
}
}

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

@@ -0,0 +1,17 @@
import { Menu } from './tenant.interface';
export function findMenu(menues: readonly Menu[], menuCode: string): Menu | undefined {
for (const menu of menues) {
if (menu.code === menuCode) {
return menu;
}
const submenu = findMenu(menu.submenues ?? [], menuCode);
if (submenu) {
return submenu;
}
}
return undefined;
}

View File

@@ -1,37 +1,23 @@
import '@angular/compiler';
import { Component, inject } from '@angular/core';
import { TestBed, getTestBed } from '@angular/core/testing';
import {
BrowserTestingModule,
platformBrowserTesting
} from '@angular/platform-browser/testing';
import {
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { firstValueFrom } from 'rxjs';
import { ConfirmDeleteModalComponent } from '../../shared/components/confirm-delete-modal/confirm-delete-modal.component';
import { ConfirmModalComponent } from '../../shared/components/confirm-modal/confirm-modal.component';
import { QrModalComponent } from '../../shared/components/qr-modal/qr-modal.component';
import { SimpleModalComponent } from '../../shared/components/simple-modal/simple-modal.component';
import {
MODAL_DATA,
ModalRef,
ModalService
} from './modal.service';
import { MODAL_DATA, ModalRef, ModalService } from './modal.service';
@Component({
template: ''
template: '',
})
class FirstTestModalComponent {}
@Component({
template: ''
template: '',
})
class SecondTestModalComponent {}
@@ -40,10 +26,7 @@ describe('ModalService', () => {
beforeAll(() => {
try {
getTestBed().initTestEnvironment(
BrowserTestingModule,
platformBrowserTesting()
);
getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
} catch {
// Test environment may already be initialized by another setup entrypoint.
}
@@ -55,7 +38,7 @@ describe('ModalService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [ModalService]
providers: [ModalService],
});
service = TestBed.inject(ModalService);
@@ -66,7 +49,7 @@ describe('ModalService', () => {
title: 'Confirmar compra',
data: { productId: 10 },
size: 'lg',
closeOnBackdrop: false
closeOnBackdrop: false,
});
const activeModal = service.activeModal();
@@ -80,7 +63,7 @@ describe('ModalService', () => {
size: 'lg',
closeOnBackdrop: false,
closeOnEscape: true,
showCloseButton: true
showCloseButton: true,
});
});
@@ -115,7 +98,7 @@ describe('ModalService', () => {
firstRef.afterClosed$.subscribe(firstClosedSpy);
const secondRef = service.open(SecondTestModalComponent, {
title: 'Segundo modal'
title: 'Segundo modal',
});
expect(firstClosedSpy).toHaveBeenCalledWith(undefined);
@@ -126,7 +109,7 @@ describe('ModalService', () => {
it('injects MODAL_DATA and ModalRef into opened components through the host injector contract', () => {
const ref = service.open(DataTestModalComponent, {
data: { amount: 3 }
data: { amount: 3 },
});
const activeModal = service.activeModal();
@@ -138,7 +121,7 @@ describe('ModalService', () => {
it('opens the standard confirm modal with default labels', () => {
const result$ = service.openConfirm({
title: 'Confirmar compra',
content: 'Esto confirmara la compra actual.'
content: 'Esto confirmara la compra actual.',
});
const activeModal = service.activeModal();
@@ -150,19 +133,19 @@ describe('ModalService', () => {
data: {
content: 'Esto confirmara la compra actual.',
confirmLabel: 'Confirmar',
cancelLabel: 'Cancelar'
cancelLabel: 'Cancelar',
},
size: 'md',
closeOnBackdrop: true,
closeOnEscape: true,
showCloseButton: true
showCloseButton: true,
});
});
it('maps the confirm modal close result to true', async () => {
const result$ = service.openConfirm({
title: 'Confirmar compra',
content: 'Esto confirmara la compra actual.'
content: 'Esto confirmara la compra actual.',
});
const activeModal = service.activeModal();
const resultPromise = firstValueFrom(result$);
@@ -175,7 +158,7 @@ describe('ModalService', () => {
it('maps dismissing a confirm modal to false', async () => {
const result$ = service.openConfirmDelete({
title: 'Eliminar producto',
content: 'Se eliminara el producto.'
content: 'Se eliminara el producto.',
});
const activeModal = service.activeModal();
const resultPromise = firstValueFrom(result$);
@@ -194,7 +177,7 @@ describe('ModalService', () => {
size: 'lg',
closeOnBackdrop: false,
closeOnEscape: false,
showCloseButton: false
showCloseButton: false,
});
const activeModal = service.activeModal();
@@ -205,19 +188,19 @@ describe('ModalService', () => {
data: {
content: 'Se eliminara el producto.',
confirmLabel: 'Eliminar',
cancelLabel: 'Conservar'
cancelLabel: 'Conservar',
},
size: 'lg',
closeOnBackdrop: false,
closeOnEscape: false,
showCloseButton: false
showCloseButton: false,
});
});
it('opens the simple modal with default button label', () => {
service.openSimple({
title: 'Aviso',
content: 'Este es un aviso simple.'
content: 'Este es un aviso simple.',
});
const activeModal = service.activeModal();
@@ -227,19 +210,19 @@ describe('ModalService', () => {
title: 'Aviso',
data: {
content: 'Este es un aviso simple.',
buttonLabel: 'Entendido'
buttonLabel: 'Entendido',
},
size: 'md',
closeOnBackdrop: true,
closeOnEscape: true,
showCloseButton: true
showCloseButton: true,
});
});
it('maps the simple modal close result to undefined', async () => {
const result$ = service.openSimple({
title: 'Aviso',
content: 'Este es un aviso simple.'
content: 'Este es un aviso simple.',
});
const activeModal = service.activeModal();
const resultPromise = firstValueFrom(result$);
@@ -248,10 +231,35 @@ describe('ModalService', () => {
await expect(resultPromise).resolves.toBeUndefined();
});
it('opens the QR modal with the ticket information and compact size', () => {
service.openQr({
title: 'Entrada general',
id: 42,
date: '09 de octubre',
ticket: 'ticket-payload',
});
const activeModal = service.activeModal();
expect(activeModal?.component).toBe(QrModalComponent);
expect(activeModal?.config).toEqual({
title: 'Entrada general',
data: {
id: 42,
date: '09 de octubre',
ticket: 'ticket-payload',
},
size: 'qr',
closeOnBackdrop: true,
closeOnEscape: true,
showCloseButton: true,
});
});
});
@Component({
template: ''
template: '',
})
class DataTestModalComponent {
readonly data = inject(MODAL_DATA);

View File

@@ -2,14 +2,11 @@ import { InjectionToken, Injectable, Type, signal } from '@angular/core';
import { Observable, Subject, map } from 'rxjs';
import { ConfirmDeleteModalComponent } from '../../shared/components/confirm-delete-modal/confirm-delete-modal.component';
import { ConfirmModalComponent } from '../../shared/components/confirm-modal/confirm-modal.component';
import { QrModalComponent } from '../../shared/components/qr-modal/qr-modal.component';
import { SimpleModalComponent } from '../../shared/components/simple-modal/simple-modal.component';
export type ModalSize = 'sm' | 'md' | 'lg' | 'xl' | 'full';
export type ModalDismissReason =
| 'backdrop'
| 'escape'
| 'programmatic'
| 'replaced';
export type ModalSize = 'qr' | 'sm' | 'md' | 'lg' | 'xl' | 'full';
export type ModalDismissReason = 'backdrop' | 'escape' | 'programmatic' | 'replaced';
export interface ModalConfig<TData = unknown> {
title?: string;
@@ -20,8 +17,7 @@ export interface ModalConfig<TData = unknown> {
showCloseButton?: boolean;
}
export interface NormalizedModalConfig<TData = unknown>
extends Omit<
export interface NormalizedModalConfig<TData = unknown> extends Omit<
ModalConfig<TData>,
'size' | 'closeOnBackdrop' | 'closeOnEscape' | 'showCloseButton'
> {
@@ -37,8 +33,7 @@ export interface ConfirmModalData {
cancelLabel: string;
}
export interface ConfirmModalConfig
extends Omit<ModalConfig<ConfirmModalData>, 'data'> {
export interface ConfirmModalConfig extends Omit<ModalConfig<ConfirmModalData>, 'data'> {
content: string;
confirmLabel?: string;
cancelLabel?: string;
@@ -49,12 +44,24 @@ export interface SimpleModalData {
buttonLabel: string;
}
export interface SimpleModalConfig
extends Omit<ModalConfig<SimpleModalData>, 'data'> {
export interface SimpleModalConfig extends Omit<ModalConfig<SimpleModalData>, 'data'> {
content: string;
buttonLabel?: string;
}
export interface QrModalData {
id: string | number;
date: string;
ticket: string;
}
export interface QrModalConfig extends Omit<ModalConfig<QrModalData>, 'data' | 'title'> {
title: string;
id: string | number;
date: string;
ticket: string;
}
export interface ActiveModalState<TResult = unknown, TData = unknown> {
component: Type<unknown>;
config: NormalizedModalConfig<TData>;
@@ -70,25 +77,24 @@ const DEFAULT_MODAL_CONFIG: Pick<
size: 'md',
closeOnBackdrop: true,
closeOnEscape: true,
showCloseButton: true
showCloseButton: true,
};
const DEFAULT_CONFIRM_MODAL_LABELS = {
confirmLabel: 'Confirmar',
cancelLabel: 'Cancelar'
cancelLabel: 'Cancelar',
} satisfies Pick<ConfirmModalData, 'confirmLabel' | 'cancelLabel'>;
export class ModalRef<TResult = unknown> {
private readonly afterClosedSubject = new Subject<TResult | undefined>();
private closed = false;
readonly afterClosed$: Observable<TResult | undefined> =
this.afterClosedSubject.asObservable();
readonly afterClosed$: Observable<TResult | undefined> = this.afterClosedSubject.asObservable();
readonly dismissReason = signal<ModalDismissReason | null>(null);
constructor(
private readonly closeHandler: (result?: TResult) => void,
private readonly dismissHandler: (reason: ModalDismissReason) => void
private readonly dismissHandler: (reason: ModalDismissReason) => void,
) {}
close(result?: TResult): void {
@@ -120,7 +126,7 @@ export class ModalRef<TResult = unknown> {
}
@Injectable({
providedIn: 'root'
providedIn: 'root',
})
export class ModalService {
private readonly activeModalState = signal<ActiveModalState | null>(null);
@@ -128,35 +134,31 @@ export class ModalService {
open<TComponent, TResult = unknown, TData = unknown>(
component: Type<TComponent>,
config: ModalConfig<TData> = {}
config: ModalConfig<TData> = {},
): ModalRef<TResult> {
this.activeModalState()?.ref.dismiss('replaced');
let ref!: ModalRef<TResult>;
ref = new ModalRef<TResult>(
(result) => this.close(ref, result),
(reason) => this.dismiss(ref, reason)
(reason) => this.dismiss(ref, reason),
);
this.activeModalState.set({
component,
config: this.normalizeConfig(config),
ref: ref as ModalRef<unknown>
ref: ref as ModalRef<unknown>,
});
return ref;
}
openConfirm(config: ConfirmModalConfig): Observable<boolean> {
return this.openConfirmRef(config).afterClosed$.pipe(
map((result) => result === true)
);
return this.openConfirmRef(config).afterClosed$.pipe(map((result) => result === true));
}
openConfirmDelete(config: ConfirmModalConfig): Observable<boolean> {
return this.openConfirmDeleteRef(config).afterClosed$.pipe(
map((result) => result === true)
);
return this.openConfirmDeleteRef(config).afterClosed$.pipe(map((result) => result === true));
}
openConfirmRef(config: ConfirmModalConfig): ModalRef<boolean> {
@@ -164,23 +166,30 @@ export class ModalService {
}
openConfirmDeleteRef(config: ConfirmModalConfig): ModalRef<boolean> {
return this.open(
ConfirmDeleteModalComponent,
this.buildConfirmModalConfig(config)
);
return this.open(ConfirmDeleteModalComponent, this.buildConfirmModalConfig(config));
}
openSimple(config: SimpleModalConfig): Observable<void> {
return this.openSimpleRef(config).afterClosed$.pipe(
map(() => undefined)
);
return this.openSimpleRef(config).afterClosed$.pipe(map(() => undefined));
}
openSimpleRef(config: SimpleModalConfig): ModalRef<void> {
return this.open(
SimpleModalComponent,
this.buildSimpleModalConfig(config)
);
return this.open(SimpleModalComponent, this.buildSimpleModalConfig(config));
}
openQr(config: QrModalConfig): Observable<void> {
return this.openQrRef(config).afterClosed$.pipe(map(() => undefined));
}
openQrRef(config: QrModalConfig): ModalRef<void> {
const { title, id, date, ticket, size = 'qr', ...modalConfig } = config;
return this.open(QrModalComponent, {
...modalConfig,
title,
size,
data: { id, date, ticket },
});
}
private close<TResult>(ref: ModalRef<TResult>, result?: TResult): void {
@@ -194,7 +203,7 @@ export class ModalService {
private dismiss<TResult>(
ref: ModalRef<TResult>,
reason: ModalDismissReason = 'programmatic'
reason: ModalDismissReason = 'programmatic',
): void {
if (this.activeModalState()?.ref !== ref) {
return;
@@ -204,18 +213,14 @@ export class ModalService {
this.activeModalState.set(null);
}
private normalizeConfig<TData>(
config: ModalConfig<TData>
): NormalizedModalConfig<TData> {
private normalizeConfig<TData>(config: ModalConfig<TData>): NormalizedModalConfig<TData> {
return {
...DEFAULT_MODAL_CONFIG,
...config
...config,
};
}
private buildConfirmModalConfig(
config: ConfirmModalConfig
): ModalConfig<ConfirmModalData> {
private buildConfirmModalConfig(config: ConfirmModalConfig): ModalConfig<ConfirmModalData> {
const {
content,
confirmLabel = DEFAULT_CONFIRM_MODAL_LABELS.confirmLabel,
@@ -228,26 +233,20 @@ export class ModalService {
data: {
content,
confirmLabel,
cancelLabel
}
cancelLabel,
},
};
}
private buildSimpleModalConfig(
config: SimpleModalConfig
): ModalConfig<SimpleModalData> {
const {
content,
buttonLabel = 'Entendido',
...modalConfig
} = config;
private buildSimpleModalConfig(config: SimpleModalConfig): ModalConfig<SimpleModalData> {
const { content, buttonLabel = 'Entendido', ...modalConfig } = config;
return {
...modalConfig,
data: {
content,
buttonLabel
}
buttonLabel,
},
};
}
}

View File

@@ -9,6 +9,44 @@ export interface BankAccount {
cvu: string;
}
export interface HeroConfig {
background_image?: string | null;
title_html?: string | null;
description_html?: string | null;
button_text?: string | null;
button_href?: string | null;
}
export interface EventConfig {
title?: string | null;
location?: string | null;
dates?: string[] | null;
}
export interface Menu {
id: number;
code: string;
label: string;
parent_menu_code: string | null;
content_type: 'static' | 'dynamic';
route: string;
static_content?: unknown;
submenues: Menu[];
}
export interface SocialMedia {
code: string;
icon: string;
name: string;
url: string;
}
export interface Category {
id: number;
nombre: string;
subcategories: Category[];
}
export interface Tenant {
id: number;
codigo: string;
@@ -24,7 +62,15 @@ export interface Tenant {
footer_logo: string;
selected_bank_account_id?: number | null;
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

@@ -3,9 +3,7 @@
<div class="card-body">
<span class="eyebrow">Componentes reutilizables</span>
<h1>Button + Input</h1>
<p class="lead mb-4">
Pagina dedicada a probar variantes de los componentes compartidos.
</p>
<p class="lead mb-4">Pagina dedicada a probar variantes de los componentes compartidos.</p>
<div class="button-showcase">
<div class="button-group">
@@ -26,9 +24,7 @@
<app-button variant="secondary" [disabled]="true">Secondary</app-button>
<app-button variant="danger" [disabled]="true">Danger</app-button>
<app-button variant="danger-secondary" [disabled]="true">
Danger secondary
</app-button>
<app-button variant="danger-secondary" [disabled]="true"> Danger secondary </app-button>
<app-button variant="cancel" [disabled]="true">Cancel</app-button>
</div>
</div>
@@ -60,13 +56,8 @@
<div class="button-group">
<h2>Toasts Persistentes (No se van)</h2>
<div class="button-grid">
<app-button (click)="triggerPersistentToast('info')">
Info Persistente
</app-button>
<app-button
variant="secondary"
(click)="triggerPersistentToast('success')"
>
<app-button (click)="triggerPersistentToast('info')"> Info Persistente </app-button>
<app-button variant="secondary" (click)="triggerPersistentToast('success')">
Success Persistente
</app-button>
<app-button variant="danger" (click)="triggerPersistentToast('danger')">
@@ -91,9 +82,7 @@
<app-button variant="danger-secondary" (click)="openWideModal()">
Abrir modal ancho
</app-button>
<app-button (click)="openSimpleModal()">
Abrir simple modal
</app-button>
<app-button (click)="openSimpleModal()"> Abrir simple modal </app-button>
</div>
<div class="modal-showcase__result" data-testid="modal-last-result">
{{ lastModalResult }}
@@ -106,9 +95,9 @@
<div class="icon-button-showcase">
<h2>Icon Buttons</h2>
<p class="text-muted mb-4">
Botones de icono pequenos con color base <code>#666666</code>,
deshabilitados con <code>#A0A0A0</code> y estados hover/active con color
de marca (a excepcion de trash que usa danger).
Botones de icono pequenos con color base <code>#666666</code>, deshabilitados con
<code>#A0A0A0</code> y estados hover/active con color de marca (a excepcion de trash que
usa danger).
</p>
<div class="icon-button-showcase__grid">
@@ -155,9 +144,7 @@
<div class="icon-button-showcase mt-4">
<h2>Cart Icons</h2>
<p class="text-muted mb-4">
Icono de carrito con badge opcional indicando cantidad.
</p>
<p class="text-muted mb-4">Icono de carrito con badge opcional indicando cantidad.</p>
<div class="icon-button-showcase__grid">
<div class="icon-button-showcase__row">
@@ -200,12 +187,7 @@
<div class="input-field">
<label for="number-input">Numero</label>
<app-input
id="number-input"
type="number"
placeholder="0"
[(value)]="numberValue"
/>
<app-input id="number-input" type="number" placeholder="0" [(value)]="numberValue" />
</div>
<div class="input-field">
@@ -220,12 +202,7 @@
<div class="input-field">
<label for="user-input">Usuario</label>
<app-input
id="user-input"
type="user"
placeholder="Nombre de usuario"
[(value)]="user"
/>
<app-input id="user-input" type="user" placeholder="Nombre de usuario" [(value)]="user" />
</div>
<div class="input-field">
@@ -250,22 +227,12 @@
<div class="input-field">
<label for="amount-input">Moneda</label>
<app-input
id="amount-input"
type="currency"
placeholder="0"
[(value)]="amount"
/>
<app-input id="amount-input" type="currency" placeholder="0" [(value)]="amount" />
</div>
<div class="input-field">
<label for="percent-input">Porcentaje</label>
<app-input
id="percent-input"
type="percent"
placeholder="0"
[(value)]="percent"
/>
<app-input id="percent-input" type="percent" placeholder="0" [(value)]="percent" />
</div>
<div class="input-field">
@@ -313,9 +280,7 @@
/>
</div>
<app-button variant="secondary" (click)="clearFile()">
Limpiar archivo
</app-button>
<app-button variant="secondary" (click)="clearFile()"> Limpiar archivo </app-button>
</div>
</div>
@@ -324,8 +289,7 @@
<div class="paginator-showcase">
<h2>Paginator</h2>
<p class="text-muted mb-0">
Componente shared para navegar por paginas con primera, anterior,
siguiente y ultima.
Componente shared para navegar por paginas con primera, anterior, siguiente y ultima.
</p>
<app-paginator
[page]="currentPage"
@@ -339,11 +303,11 @@
<div class="stepper-showcase">
<h2>Form Stepper</h2>
<p class="text-muted mb-4">
Stepper vertical reutilizable. Cada paso maneja sus propios botones de navegacion. No se puede avanzar si el paso no es valido.
Stepper vertical reutilizable. Cada paso maneja sus propios botones de navegacion. No se
puede avanzar si el paso no es valido.
</p>
<app-stepper #demoStepper>
<!-- Paso 1: Siempre valido -->
<app-step label="Informacion Personal" [isValid]="true">
<div class="row g-3">
@@ -353,7 +317,12 @@
</div>
<div class="col-md-6 input-field">
<label for="stepper-email">Email</label>
<app-input id="stepper-email" type="email" placeholder="mail@empresa.com" [(value)]="email" />
<app-input
id="stepper-email"
type="email"
placeholder="mail@empresa.com"
[(value)]="email"
/>
</div>
</div>
<div class="d-flex justify-content-end mt-4">
@@ -368,7 +337,12 @@
<div class="row g-3">
<div class="col-md-6 input-field">
<label for="stepper-password">Contraseña</label>
<app-input id="stepper-password" type="password-toggle" placeholder="********" [(value)]="password" />
<app-input
id="stepper-password"
type="password-toggle"
placeholder="********"
[(value)]="password"
/>
</div>
<div class="col-md-6 input-field">
<label for="stepper-amount">Presupuesto mensual</label>
@@ -377,9 +351,7 @@
</div>
<div class="stepper-validity-control mt-3 d-flex align-items-center gap-3">
<span class="text-muted small">
Simular validez del paso:
</span>
<span class="text-muted small"> Simular validez del paso: </span>
<app-button
[variant]="testStep2Valid ? 'secondary' : 'primary'"
(click)="testStep2Valid = !testStep2Valid"
@@ -418,12 +390,49 @@
</app-button>
</div>
</app-step>
</app-stepper>
</div>
<hr class="section-divider" />
<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.
</p>
<app-accordion>
<app-accordion-item title="¿Cómo realizo una compra?" [open]="true">
Elegí el producto, seleccioná sus variantes y agregalo al carrito. Desde allí podés
revisar el pedido y continuar al checkout.
</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.
</app-accordion-item>
<app-accordion-item title="Item deshabilitado" [disabled]="true">
Este contenido no puede abrirse.
</app-accordion-item>
</app-accordion>
<h3 class="h5 mt-5 mb-3">Variante múltiple y flush</h3>
<app-accordion [allowMultiple]="true" [flush]="true">
<app-accordion-item title="Envíos">
La tienda informa los métodos y costos de envío disponibles para cada pedido.
</app-accordion-item>
<app-accordion-item title="Cambios y devoluciones">
Las condiciones de cambio y devolución son definidas por cada comercio.
</app-accordion-item>
</app-accordion>
</div>
<hr class="section-divider" />
<div class="cart-showcase">
<div class="cart-showcase__header">
<div>
@@ -454,7 +463,6 @@
</div>
}
</div>
</div>
</section>
@@ -604,20 +612,73 @@
</div>
</section>
<section class="component-demo card shadow-sm mt-4" data-testid="main-carousel">
<div class="card-body">
<span class="eyebrow">Reusable Component</span>
<h2 class="mb-3">Main Carousel</h2>
<p class="text-muted mb-4">
Carrusel panorámico compuesto únicamente por imágenes, con autoslide e indicadores.
</p>
<app-main-carousel
[images]="mainCarouselImages"
[imageAlts]="mainCarouselImageAlts"
[autoSlideInterval]="4000"
ariaLabel="Promociones de la tienda"
/>
</div>
</section>
<section class="component-demo card shadow-sm mt-4" data-testid="products-carousel">
<div class="card-body">
<span class="eyebrow">Reusable Component</span>
<h2 class="mb-3">Carousel de productos</h2>
<p class="text-muted mb-4">
El carousel controla únicamente el desplazamiento. Cada ítem se define externamente con un
template y en este ejemplo renderiza productos con imágenes mockeadas.
</p>
<ng-template #productCarouselItem let-product let-index="index">
<div class="carousel-product" [attr.data-carousel-index]="index">
<span class="carousel-product__mock-id">ITEM {{ product.id }}</span>
<app-product-column-with-image
[imageUrl]="product.imageUrl"
[title]="product.title"
[originalPrice]="product.originalPrice"
[discount]="product.discount"
[transferPrice]="product.transferPrice"
(buy)="onProductBuy(product.title)"
/>
</div>
</ng-template>
<app-carousel
[items]="carouselProducts"
[itemTemplate]="productCarouselItem"
[trackBy]="trackCarouselProduct"
[gap]="16"
[circular]="true"
ariaLabel="Productos con imágenes de prueba"
/>
</div>
</section>
<section class="component-demo card shadow-sm mt-4">
<div class="card-body">
<span class="eyebrow">Reusable Component</span>
<h2 class="mb-4">Tarjetas de Producto (ProductCard)</h2>
<h2 class="mb-4">Tarjetas de Producto (Column With Image)</h2>
<p class="text-muted mb-4">
Las tarjetas se adaptan al tamano del contenedor padre. A continuacion se
presentan dentro de una grilla de Bootstrap.
Las tarjetas se adaptan al tamano del contenedor padre. A continuacion se presentan dentro
de una grilla de Bootstrap.
</p>
<div class="row">
@for (product of testProducts; track product.title) {
<div class="col-12 col-md-6 col-lg-4 mb-4 d-flex align-items-stretch">
<app-product-card
<app-product-column-with-image
[imageUrl]="product.imageUrl"
[title]="product.title"
[originalPrice]="product.originalPrice"
@@ -631,16 +692,75 @@
</div>
</section>
<app-store-section
title="PRODUCTOS"
class="component-demo store-section-demo mt-5"
>
<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>
<h2 class="mb-4">Tarjetas de Producto (Row Layout)</h2>
<p class="text-muted mb-4">Diseño horizontal que ocupa el 100% del ancho disponible.</p>
<div class="row">
<div class="col-12 mb-4 d-flex align-items-stretch">
<app-product-row-card
[title]="testRowProduct.title"
[description]="testRowProduct.description"
[price]="testRowProduct.price"
[variants]="testRowProduct.variants"
(buy)="onProductBuy(testRowProduct.title)"
(addToCart)="triggerToast('success')"
/>
</div>
</div>
</div>
</section>
<section class="component-demo card shadow-sm mt-4">
<div class="card-body">
<span class="eyebrow">Reusable Component</span>
<h2 class="mb-4">Tarjetas de Producto (Vertical With Cart Layout)</h2>
<p class="text-muted mb-4">
Diseño vertical con selector de cantidad y acciones de compra y carrito.
</p>
<div class="row justify-content-center justify-content-md-start">
<div class="col-12 col-sm-9 col-md-6 col-lg-5 col-xl-4 mb-4 d-flex align-items-stretch">
<app-product-vertical-with-cart-card
[title]="testVerticalWithCartProduct.title"
[description]="testVerticalWithCartProduct.description"
[price]="testVerticalWithCartProduct.price"
(buy)="onProductBuy(testVerticalWithCartProduct.title)"
(addToCart)="triggerToast('success')"
/>
</div>
</div>
</div>
</section>
<app-store-section title="PRODUCTOS" class="component-demo store-section-demo mt-5">
<div class="row">
@for (product of testProducts; track product.title) {
<div
class="col-12 col-md-6 col-lg-4 mb-4 d-flex align-items-stretch store-section-demo__item"
>
<app-product-card
<app-product-column-with-image
[imageUrl]="product.imageUrl"
[title]="product.title"
[originalPrice]="product.originalPrice"
@@ -653,4 +773,3 @@
</div>
</app-store-section>
</main>

View File

@@ -19,6 +19,32 @@
width: min(100%, 64rem);
}
.carousel-product {
position: relative;
width: clamp(15rem, 30vw, 18rem);
height: 100%;
&__mock-id {
position: absolute;
top: 0.75rem;
left: 0.75rem;
z-index: 2;
padding: 0.35rem 0.55rem;
color: #fff;
font-size: 0.7rem;
font-weight: 700;
line-height: 1;
letter-spacing: 0.08em;
background: rgb(20 20 20 / 82%);
border-radius: 999px;
}
app-product-column-with-image {
display: block;
height: 100%;
}
}
.eyebrow {
display: inline-block;
margin-bottom: 0.75rem;
@@ -130,7 +156,7 @@ h1 {
}
.store-section-demo__item {
app-product-card {
app-product-column-with-image {
width: 100%;
}
}
@@ -155,7 +181,6 @@ h1 {
gap: 0.75rem;
}
.file-field {
display: grid;
gap: 0.75rem;
@@ -168,6 +193,10 @@ h1 {
}
@media (max-width: 767.98px) {
.carousel-product {
width: min(18rem, calc(100vw - 5rem));
}
.input-grid {
grid-template-columns: minmax(0, 1fr);
}
@@ -206,11 +235,15 @@ h1 {
padding: 1rem;
overflow: hidden;
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.05);
transition: transform 0.2s ease, box-shadow 0.2s ease;
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
&:hover {
transform: translateY(-2px);
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.05), 0 4px 12px rgba(0, 0, 0, 0.08);
box-shadow:
inset 0 2px 4px rgba(0, 0, 0, 0.05),
0 4px 12px rgba(0, 0, 0, 0.08);
}
}
@@ -251,7 +284,9 @@ h1 {
border: 1px solid #dee2e6;
overflow: hidden;
background-color: #ffffff;
transition: transform 0.2s ease, box-shadow 0.2s ease;
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
&:hover {
transform: translateY(-3px);

View File

@@ -1,9 +1,6 @@
import { signal } from '@angular/core';
import { TestBed, getTestBed } from '@angular/core/testing';
import {
BrowserTestingModule,
platformBrowserTesting
} from '@angular/platform-browser/testing';
import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
import { of } from 'rxjs';
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
@@ -25,7 +22,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(currentTenant: Tenant | null) {
@@ -36,7 +34,7 @@ function createTenantServiceStub(currentTenant: Tenant | null) {
tenant: tenantState.asReadonly(),
status: statusState.asReadonly(),
getTenant: () => tenantState(),
bootstrap: vi.fn().mockResolvedValue(undefined)
bootstrap: vi.fn().mockResolvedValue(undefined),
};
}
@@ -44,7 +42,7 @@ function createToastServiceStub() {
return {
success: vi.fn(),
danger: vi.fn(),
info: vi.fn()
info: vi.fn(),
};
}
@@ -52,17 +50,14 @@ function createModalServiceStub() {
return {
open: vi.fn(),
openConfirm: vi.fn().mockReturnValue(of(true)),
openConfirmDelete: vi.fn().mockReturnValue(of(false))
openConfirmDelete: vi.fn().mockReturnValue(of(false)),
};
}
describe('ReutilizablesTestPageComponent', () => {
beforeAll(() => {
try {
getTestBed().initTestEnvironment(
BrowserTestingModule,
platformBrowserTesting()
);
getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
} catch {
// Test environment may already be initialized by another setup entrypoint.
}
@@ -79,17 +74,17 @@ describe('ReutilizablesTestPageComponent', () => {
providers: [
{
provide: TenantService,
useValue: createTenantServiceStub(tenant)
useValue: createTenantServiceStub(tenant),
},
{
provide: ToastService,
useValue: createToastServiceStub()
useValue: createToastServiceStub(),
},
{
provide: ModalService,
useValue: modalServiceStub
}
]
useValue: modalServiceStub,
},
],
}).compileComponents();
const fixture = TestBed.createComponent(ReutilizablesTestPageComponent);
@@ -101,12 +96,12 @@ describe('ReutilizablesTestPageComponent', () => {
expect(sectionTitle?.textContent).toContain('Configuracion Tenant');
const headerImg = element.querySelector(
'.tenant-logo-card img[alt="Header Logo"]'
'.tenant-logo-card img[alt="Header Logo"]',
) as HTMLImageElement;
expect(headerImg?.src).toBe(tenant.header_logo);
const footerImg = element.querySelector(
'.tenant-logo-card img[alt="Footer Logo"]'
'.tenant-logo-card img[alt="Footer Logo"]',
) as HTMLImageElement;
expect(footerImg?.src).toBe(tenant.footer_logo);
@@ -123,17 +118,17 @@ describe('ReutilizablesTestPageComponent', () => {
providers: [
{
provide: TenantService,
useValue: createTenantServiceStub(tenant)
useValue: createTenantServiceStub(tenant),
},
{
provide: ToastService,
useValue: createToastServiceStub()
useValue: createToastServiceStub(),
},
{
provide: ModalService,
useValue: modalServiceStub
}
]
useValue: modalServiceStub,
},
],
}).compileComponents();
const fixture = TestBed.createComponent(ReutilizablesTestPageComponent);
@@ -142,9 +137,117 @@ describe('ReutilizablesTestPageComponent', () => {
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('app-paginator')).not.toBeNull();
expect(element.querySelector('[data-testid="paginator-status"]')?.textContent?.trim()).toBe(
'1/8',
);
});
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],
providers: [
{
provide: TenantService,
useValue: createTenantServiceStub(tenant),
},
{
provide: ToastService,
useValue: createToastServiceStub(),
},
{
provide: ModalService,
useValue: createModalServiceStub(),
},
],
}).compileComponents();
const fixture = TestBed.createComponent(ReutilizablesTestPageComponent);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const carouselDemo = element.querySelector('[data-testid="products-carousel"]');
const carouselItems = carouselDemo?.querySelectorAll('[data-carousel-index]');
const productImages = carouselDemo?.querySelectorAll('app-product-column-with-image img');
expect(carouselDemo?.querySelector('app-carousel')).not.toBeNull();
expect(carouselItems).toHaveLength(6);
expect(productImages).toHaveLength(6);
expect(productImages?.[0].getAttribute('src')).toContain('/images/carousel-mochila-roja.webp');
expect(productImages?.[1].getAttribute('src')).toContain(
'/images/carousel-auriculares-amarillos.webp',
);
expect(
element.querySelector('[data-testid="paginator-status"]')?.textContent?.trim()
).toBe('1/8');
new Set(Array.from(productImages ?? [], (image) => image.getAttribute('src'))).size,
).toBe(6);
});
it('renders the main carousel demo with remote images', 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 element = fixture.nativeElement as HTMLElement;
const carouselDemo = element.querySelector('[data-testid="main-carousel"]');
const images = carouselDemo?.querySelectorAll('.main-carousel__image');
const indicators = carouselDemo?.querySelectorAll('.main-carousel__indicator');
expect(carouselDemo?.querySelector('app-main-carousel')).not.toBeNull();
expect(images).toHaveLength(3);
expect(indicators).toHaveLength(3);
expect(images?.[0].getAttribute('src')).toContain('images.unsplash.com');
fixture.destroy();
});
it('renders the modal showcase and opens the confirm demos from the buttons', async () => {
@@ -154,36 +257,34 @@ describe('ReutilizablesTestPageComponent', () => {
providers: [
{
provide: TenantService,
useValue: createTenantServiceStub(tenant)
useValue: createTenantServiceStub(tenant),
},
{
provide: ToastService,
useValue: createToastServiceStub()
useValue: createToastServiceStub(),
},
{
provide: ModalService,
useValue: modalServiceStub
}
]
useValue: modalServiceStub,
},
],
}).compileComponents();
const fixture = TestBed.createComponent(ReutilizablesTestPageComponent);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const buttons = Array.from(
element.querySelectorAll('.button-group app-button button')
);
const buttons = Array.from(element.querySelectorAll('.button-group app-button button'));
const confirmButton = buttons.find((button) =>
button.textContent?.includes('Abrir confirm modal')
button.textContent?.includes('Abrir confirm modal'),
) as HTMLButtonElement;
const deleteButton = buttons.find((button) =>
button.textContent?.includes('Abrir confirm delete')
button.textContent?.includes('Abrir confirm delete'),
) as HTMLButtonElement;
expect(
element.querySelector('[data-testid="modal-last-result"]')?.textContent
).toContain('Todavia no se abrio ningun modal.');
expect(element.querySelector('[data-testid="modal-last-result"]')?.textContent).toContain(
'Todavia no se abrio ningun modal.',
);
confirmButton.click();
fixture.detectChanges();
@@ -193,17 +294,17 @@ describe('ReutilizablesTestPageComponent', () => {
expect(modalServiceStub.openConfirm).toHaveBeenCalledWith({
title: 'Confirmar accion',
content: 'Caso base para verificar apertura, cierre y devolucion de resultado.',
confirmLabel: 'Confirmar'
confirmLabel: 'Confirmar',
});
expect(modalServiceStub.openConfirmDelete).toHaveBeenCalledWith({
title: 'Eliminar producto',
content:
'Esta accion eliminara el producto del catalogo. Podras volver a crearlo manualmente.',
confirmLabel: 'Eliminar'
confirmLabel: 'Eliminar',
});
expect(
element.querySelector('[data-testid="modal-last-result"]')?.textContent
).toContain('Resultado: false');
expect(element.querySelector('[data-testid="modal-last-result"]')?.textContent).toContain(
'Resultado: false',
);
});
it('updates the visible result when the confirm modal resolves to true', async () => {
@@ -213,17 +314,17 @@ describe('ReutilizablesTestPageComponent', () => {
providers: [
{
provide: TenantService,
useValue: createTenantServiceStub(tenant)
useValue: createTenantServiceStub(tenant),
},
{
provide: ToastService,
useValue: createToastServiceStub()
useValue: createToastServiceStub(),
},
{
provide: ModalService,
useValue: modalServiceStub
}
]
useValue: modalServiceStub,
},
],
}).compileComponents();
const fixture = TestBed.createComponent(ReutilizablesTestPageComponent);
@@ -231,16 +332,14 @@ describe('ReutilizablesTestPageComponent', () => {
const element = fixture.nativeElement as HTMLElement;
const confirmButton = Array.from(
element.querySelectorAll('.button-group app-button button')
).find((button) =>
button.textContent?.includes('Abrir confirm modal')
) as HTMLButtonElement;
element.querySelectorAll('.button-group app-button button'),
).find((button) => button.textContent?.includes('Abrir confirm modal')) as HTMLButtonElement;
confirmButton.click();
fixture.detectChanges();
expect(
element.querySelector('[data-testid="modal-last-result"]')?.textContent
).toContain('Resultado: true');
expect(element.querySelector('[data-testid="modal-last-result"]')?.textContent).toContain(
'Resultado: true',
);
});
});

View File

@@ -1,20 +1,36 @@
import { Component, inject } from '@angular/core';
import { ButtonComponent } from '../../../../shared/components/button/button.component';
import {
CartComponent,
CartItemMock
} from '../../../../shared/components/cart/cart.component';
import { CartComponent, CartItemMock } from '../../../../shared/components/cart/cart.component';
import { CartIconComponent } from '../../../../shared/components/cart-icon/cart-icon.component';
import { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.component';
import { InputComponent } from '../../../../shared/components/input/input.component';
import { PaginatorComponent } from '../../../../shared/components/paginator/paginator.component';
import { ProductCardComponent } from '../../../../shared/components/product-card/product-card.component';
import { ProductColumnWithImageComponent } from '../../../../shared/components/product-column-with-image/product-column-with-image.component';
import { ProductRowCardComponent } from '../../../../shared/components/product-row-card/product-row-card.component';
import { ProductVerticalWithCartCardComponent } from '../../../../shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component';
import { StoreSectionComponent } from '../../../../shared/components/store-section/store-section.component';
import { ModalService } from '../../../../core/services/modal.service';
import { TenantService } from '../../../../core/services/tenant.service';
import { ToastService } from '../../../../core/services/toast.service';
import { StepperComponent } from '../../../../shared/components/stepper/stepper.component';
import { StepComponent } from '../../../../shared/components/stepper/step.component';
import { CarouselComponent } from '../../../../shared/components/carousel/carousel.component';
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;
title: string;
originalPrice: number;
discount: number | null;
transferPrice: number;
imageUrl: string;
}
@Component({
selector: 'app-reutilizables-test-page',
@@ -23,15 +39,22 @@ import { StepComponent } from '../../../../shared/components/stepper/step.compon
CartComponent,
InputComponent,
PaginatorComponent,
ProductCardComponent,
ProductColumnWithImageComponent,
ProductRowCardComponent,
ProductVerticalWithCartCardComponent,
StoreSectionComponent,
IconButtonComponent,
CartIconComponent,
StepperComponent,
StepComponent
StepComponent,
CarouselComponent,
MainCarouselComponent,
AccordionComponent,
AccordionItemComponent,
LocationMapComponent,
],
templateUrl: './reutilizables-test-page.component.html',
styleUrl: './reutilizables-test-page.component.scss'
styleUrl: './reutilizables-test-page.component.scss',
})
export class ReutilizablesTestPageComponent {
private readonly tenantService = inject(TenantService);
@@ -61,6 +84,39 @@ 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',
'https://images.unsplash.com/photo-1483985988355-763728e1935b?auto=format&fit=crop&w=1600&q=80',
];
protected readonly mainCarouselImageAlts = [
'Interior de una tienda de indumentaria',
'Colección de moda en exteriores',
'Percheros con prendas de distintos colores',
];
protected readonly testProducts = [
{
@@ -68,24 +124,97 @@ export class ReutilizablesTestPageComponent {
originalPrice: 95000,
discount: 20,
transferPrice: 74800,
imageUrl: '/images/pantalon-scuba.png'
imageUrl: '/images/pantalon-scuba.png',
},
{
title: 'Zapatillas de running azul',
originalPrice: 120000,
discount: 15,
transferPrice: 98000,
imageUrl: '/images/zapatillas-running.png'
imageUrl: '/images/zapatillas-running.png',
},
{
title: 'Remera basica blanca (Sin Descuento)',
originalPrice: 32000,
discount: null,
transferPrice: 30000,
imageUrl: null
}
imageUrl: null,
},
];
protected readonly carouselProducts: CarouselProductMock[] = [
{
id: 1,
title: '01 · Mochila roja',
originalPrice: 85000,
discount: 20,
transferPrice: 68000,
imageUrl: '/images/carousel-mochila-roja.webp',
},
{
id: 2,
title: '02 · Auriculares amarillos',
originalPrice: 120000,
discount: 15,
transferPrice: 102000,
imageUrl: '/images/carousel-auriculares-amarillos.webp',
},
{
id: 3,
title: '03 · Reloj verde',
originalPrice: 96000,
discount: 10,
transferPrice: 86400,
imageUrl: '/images/carousel-reloj-verde.webp',
},
{
id: 4,
title: '04 · Cámara violeta',
originalPrice: 135000,
discount: null,
transferPrice: 128000,
imageUrl: '/images/carousel-camara-violeta.webp',
},
{
id: 5,
title: '05 · Zapatillas naranjas',
originalPrice: 110000,
discount: 25,
transferPrice: 82500,
imageUrl: '/images/carousel-zapatillas-naranjas.webp',
},
{
id: 6,
title: '06 · Campera azul',
originalPrice: 142000,
discount: 12,
transferPrice: 121000,
imageUrl: '/images/carousel-campera-azul.webp',
},
];
protected readonly trackCarouselProduct = (
_index: number,
product: CarouselProductMock,
): number => product.id;
protected readonly testRowProduct = {
title: 'ENTRADA GENERAL',
description:
'Acceso total al predio. No incluye acceso a estacionamiento. Niños menores de 5 años ingresan gratis.',
price: 10000,
variants: [
{ label: '10 de Octubre', value: '10-oct' },
{ label: '11 de Octubre', value: '11-oct' },
],
};
protected readonly testVerticalWithCartProduct = {
title: 'PANCHO',
description: 'Pancho con aderezo a elección.',
price: 11500,
};
protected readonly cartMockItems: CartItemMock[] = [
{
imageUrl: null,
@@ -96,8 +225,8 @@ export class ReutilizablesTestPageComponent {
quantity: 1,
attributes: [
{ label: 'Color', value: 'Beige' },
{ label: 'Talle', value: 'S' }
]
{ label: 'Talle', value: 'S' },
],
},
{
imageUrl: null,
@@ -108,8 +237,8 @@ export class ReutilizablesTestPageComponent {
quantity: 1,
attributes: [
{ label: 'Color', value: 'Beige' },
{ label: 'Talle', value: 'S' }
]
{ label: 'Talle', value: 'S' },
],
},
{
imageUrl: null,
@@ -120,8 +249,8 @@ export class ReutilizablesTestPageComponent {
quantity: 1,
attributes: [
{ label: 'Color', value: 'Negro' },
{ label: 'Talle', value: 'XL' }
]
{ label: 'Talle', value: 'XL' },
],
},
{
imageUrl: null,
@@ -132,9 +261,9 @@ export class ReutilizablesTestPageComponent {
quantity: 2,
attributes: [
{ label: 'Color', value: 'Gris' },
{ label: 'Talle', value: 'M' }
]
}
{ label: 'Talle', value: 'M' },
],
},
];
protected onProductBuy(productTitle: string): void {
@@ -162,45 +291,32 @@ export class ReutilizablesTestPageComponent {
protected triggerToast(type: 'success' | 'danger' | 'info'): void {
if (type === 'success') {
this.toastService.success(
'Operacion exitosa. El producto se ha guardado correctamente.'
);
this.toastService.success('Operacion exitosa. El producto se ha guardado correctamente.');
} else if (type === 'danger') {
this.toastService.danger(
'Alerta de error. Hubo un problema al procesar la solicitud.'
);
this.toastService.danger('Alerta de error. Hubo un problema al procesar la solicitud.');
} else if (type === 'info') {
this.toastService.info(
'Informacion del sistema: Hay una nueva actualizacion disponible.'
);
this.toastService.info('Informacion del sistema: Hay una nueva actualizacion disponible.');
}
}
protected triggerPersistentToast(type: 'success' | 'danger' | 'info'): void {
if (type === 'success') {
this.toastService.success(
'Exito persistente. Esta alerta no se cerrara sola.',
0
);
this.toastService.success('Exito persistente. Esta alerta no se cerrara sola.', 0);
} else if (type === 'danger') {
this.toastService.danger(
'Error persistente. Por favor, atienda este problema y cierrelo manualmente.',
0
0,
);
} else if (type === 'info') {
this.toastService.info(
'Informacion persistente: Mantendremos este aviso en pantalla.',
0
);
this.toastService.info('Informacion persistente: Mantendremos este aviso en pantalla.', 0);
}
}
protected openBasicModal(): void {
this.openConfirmModal({
title: 'Confirmar accion',
content:
'Caso base para verificar apertura, cierre y devolucion de resultado.',
confirmLabel: 'Confirmar'
content: 'Caso base para verificar apertura, cierre y devolucion de resultado.',
confirmLabel: 'Confirmar',
});
}
@@ -209,18 +325,17 @@ export class ReutilizablesTestPageComponent {
title: 'Eliminar producto',
content:
'Esta accion eliminara el producto del catalogo. Podras volver a crearlo manualmente.',
confirmLabel: 'Eliminar'
confirmLabel: 'Eliminar',
});
}
protected openLockedModal(): void {
this.openConfirmModal({
title: 'Modal bloqueado',
content:
'Este modal no se cierra tocando el backdrop ni con la tecla Escape.',
content: 'Este modal no se cierra tocando el backdrop ni con la tecla Escape.',
confirmLabel: 'Entendido',
closeOnBackdrop: false,
closeOnEscape: false
closeOnEscape: false,
});
}
@@ -229,16 +344,18 @@ export class ReutilizablesTestPageComponent {
title: 'Modal ancho',
content: 'Demuestra una variante visual mas amplia para contenido mas pesado.',
confirmLabel: 'Seguir',
size: 'xl'
size: 'xl',
});
}
protected openSimpleModal(): void {
this.modalService.openSimple({
this.modalService
.openSimple({
title: 'Mensaje del sistema',
content: 'Este es un mensaje simple del sistema que no requiere confirmación.',
buttonLabel: 'Entendido'
}).subscribe(() => {
buttonLabel: 'Entendido',
})
.subscribe(() => {
this.lastModalResult = 'Simple modal cerrado';
});
}
@@ -249,13 +366,9 @@ export class ReutilizablesTestPageComponent {
});
}
private openConfirmDelete(
config: Parameters<ModalService['openConfirmDelete']>[0]
): void {
private openConfirmDelete(config: Parameters<ModalService['openConfirmDelete']>[0]): void {
this.modalService.openConfirmDelete(config).subscribe((confirmed) => {
this.lastModalResult = `Resultado: ${confirmed}`;
});
}
}

View File

@@ -0,0 +1,71 @@
import { TestBed } from '@angular/core/testing';
import { beforeEach, describe, expect, it } from 'vitest';
import { ProductAttribute } from '../../../../core/services/catalog/catalog.interface';
import { ProductAttributeSelectorComponent } from './product-attribute-selector.component';
describe('ProductAttributeSelectorComponent', () => {
const sizeAttribute: ProductAttribute = {
id: 1,
codigo: 'size',
nombre: 'Size',
is_required: true,
metadata_schema: null,
type: 'select',
options: [
{ id: 10, value: 'S', label: 'S', sort_order: 0, metadata: null },
{ id: 20, value: 'M', label: 'M', sort_order: 1, metadata: null },
],
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ProductAttributeSelectorComponent],
}).compileComponents();
});
it('keeps an unlimited option available when maximum quantity is null', () => {
const fixture = TestBed.createComponent(ProductAttributeSelectorComponent);
fixture.componentRef.setInput('attributes', [sizeAttribute]);
fixture.componentRef.setInput('inventoryPolicy', 'unlimited');
fixture.componentRef.setInput('variants', [
{
id: 1,
stock_tecnico: null,
values: { size: 'S' },
},
]);
fixture.detectChanges();
const buttons = Array.from(
fixture.nativeElement.querySelectorAll('.attribute-selector__text-option'),
) as HTMLButtonElement[];
expect(buttons[0].disabled).toBe(false);
expect(buttons[1].disabled).toBe(true);
});
it('disables tracked options without available stock', () => {
const fixture = TestBed.createComponent(ProductAttributeSelectorComponent);
fixture.componentRef.setInput('attributes', [sizeAttribute]);
fixture.componentRef.setInput('inventoryPolicy', 'tracked');
fixture.componentRef.setInput('variants', [
{
id: 1,
stock_tecnico: 0,
values: { size: 'S' },
},
{
id: 2,
stock_tecnico: 2,
values: { size: 'M' },
},
]);
fixture.detectChanges();
const buttons = Array.from(
fixture.nativeElement.querySelectorAll('.attribute-selector__text-option'),
) as HTMLButtonElement[];
expect(buttons[0].disabled).toBe(true);
expect(buttons[1].disabled).toBe(false);
});
});

View File

@@ -6,14 +6,14 @@ import {
input,
output,
signal,
untracked
untracked,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import {
CatalogItemVariant,
InventoryPolicy,
ProductAttribute,
ProductAttributeOption,
ProductVariant,
ProductVariantMap
} from '../../../../core/services/catalog/catalog.interface';
@Component({
@@ -22,20 +22,21 @@ import {
imports: [CommonModule],
templateUrl: './product-attribute-selector.component.html',
styleUrl: './product-attribute-selector.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ProductAttributeSelectorComponent {
public attributes = input<ProductAttribute[]>([]);
public variantsMap = input<ProductVariantMap[]>([]);
public defaultVariant = input<ProductVariant | null>(null);
public variants = input<CatalogItemVariant[]>([]);
public selectedVariant = input<CatalogItemVariant | null>(null);
public inventoryPolicy = input.required<InventoryPolicy>();
public variantChange = output<ProductVariantMap | null>();
public variantChange = output<CatalogItemVariant | null>();
protected readonly selectedAttributeOptions = signal<Record<number, number>>({});
protected readonly availableOptions = computed(() => {
const selections = this.selectedAttributeOptions();
const variants = this.variantsMap();
const variants = this.variants();
const attributes = this.attributes();
const availability: Record<number, Record<number, boolean>> = {};
@@ -45,10 +46,10 @@ export class ProductAttributeSelectorComponent {
for (const option of attribute.options) {
const optionNormalized = this.normalizeText(option.value || option.label);
const isAvailable = variants.some(variant => {
if (variant.cantidad_maxima <= 0) return false;
const isAvailable = variants.some((variant) => {
if (!this.isVariantAvailable(variant)) return false;
const variantAttrValue = this.getDefaultVariantAttributeValue(attribute, variant.attributes);
const variantAttrValue = this.getDefaultVariantAttributeValue(attribute, variant.values);
if (!variantAttrValue || this.normalizeText(variantAttrValue) !== optionNormalized) {
return false;
}
@@ -57,10 +58,12 @@ export class ProductAttributeSelectorComponent {
if (otherAttr.id === attribute.id) continue;
const selectedOptionId = selections[otherAttr.id];
if (selectedOptionId !== undefined) {
const selectedOption = otherAttr.options.find(o => o.id === selectedOptionId);
const selectedOption = otherAttr.options.find((o) => o.id === selectedOptionId);
if (selectedOption) {
const selectedNormalized = this.normalizeText(selectedOption.value || selectedOption.label);
const vAttrValue = this.getDefaultVariantAttributeValue(otherAttr, variant.attributes);
const selectedNormalized = this.normalizeText(
selectedOption.value || selectedOption.label,
);
const vAttrValue = this.getDefaultVariantAttributeValue(otherAttr, variant.values);
if (!vAttrValue || this.normalizeText(vAttrValue) !== selectedNormalized) {
return false;
}
@@ -78,13 +81,13 @@ export class ProductAttributeSelectorComponent {
});
public reset(): void {
this.initializeSelections(this.attributes(), this.defaultVariant());
this.initializeSelections(this.attributes(), this.selectedVariant());
}
constructor() {
effect(() => {
const attributes = this.attributes();
const defaultVariant = this.defaultVariant();
const defaultVariant = this.selectedVariant();
untracked(() => {
this.initializeSelections(attributes, defaultVariant);
@@ -93,26 +96,29 @@ export class ProductAttributeSelectorComponent {
effect(() => {
const selections = this.selectedAttributeOptions();
const variantsMap = this.variantsMap();
const variants = this.variants();
const attributes = this.attributes();
untracked(() => {
this.emitMatchingVariant(selections, variantsMap, attributes);
this.emitMatchingVariant(selections, variants, attributes);
});
});
}
protected hasSelectedOption(attribute: ProductAttribute, option: ProductAttributeOption): boolean {
protected hasSelectedOption(
attribute: ProductAttribute,
option: ProductAttributeOption,
): boolean {
return this.selectedAttributeOptions()[attribute.id] === option.id;
}
protected selectAttributeOption(
attribute: ProductAttribute,
option: ProductAttributeOption
option: ProductAttributeOption,
): void {
this.selectedAttributeOptions.update((current) => ({
...current,
[attribute.id]: option.id
[attribute.id]: option.id,
}));
}
@@ -124,7 +130,10 @@ export class ProductAttributeSelectorComponent {
return this.findFirstHexValue(option.metadata) ?? '#D9D9D9';
}
private initializeSelections(attributes: ProductAttribute[], defaultVariant: ProductVariant | null): void {
private initializeSelections(
attributes: ProductAttribute[],
defaultVariant: CatalogItemVariant | null,
): void {
const selections: Record<number, number> = {};
for (const attribute of attributes) {
@@ -139,20 +148,20 @@ export class ProductAttributeSelectorComponent {
private findDefaultOptionId(
attribute: ProductAttribute,
variant: ProductVariant | null
variant: CatalogItemVariant | null,
): number | null {
if (!variant) {
return null;
}
const defaultValue = this.getDefaultVariantAttributeValue(attribute, variant.definitions);
const defaultValue = this.getDefaultVariantAttributeValue(attribute, variant.values);
if (!defaultValue) {
return null;
}
const normalizedValue = this.normalizeText(defaultValue);
const matchByValue = attribute.options.find(
(option) => this.normalizeText(option.value) === normalizedValue
(option) => this.normalizeText(option.value) === normalizedValue,
);
if (matchByValue) {
@@ -160,7 +169,7 @@ export class ProductAttributeSelectorComponent {
}
const matchByLabel = attribute.options.find(
(option) => this.normalizeText(option.label) === normalizedValue
(option) => this.normalizeText(option.label) === normalizedValue,
);
return matchByLabel?.id ?? null;
@@ -168,7 +177,7 @@ export class ProductAttributeSelectorComponent {
private getDefaultVariantAttributeValue(
attribute: ProductAttribute,
variantAttributes: Record<string, string>
variantAttributes: Record<string, string>,
): string | null {
const normalizedCodigo = this.normalizeText(attribute.codigo);
const normalizedNombre = this.normalizeText(attribute.nombre);
@@ -192,6 +201,10 @@ export class ProductAttributeSelectorComponent {
.toLowerCase();
}
private isVariantAvailable(variant: CatalogItemVariant): boolean {
return this.inventoryPolicy() === 'unlimited' || (variant.stock_tecnico ?? 0) > 0;
}
private findFirstHexValue(value: unknown): string | null {
if (typeof value === 'string') {
const match = value.match(/#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})\b/);
@@ -222,10 +235,10 @@ export class ProductAttributeSelectorComponent {
private emitMatchingVariant(
selections: Record<number, number>,
variantsMap: ProductVariantMap[],
attributes: ProductAttribute[]
variants: CatalogItemVariant[],
attributes: ProductAttribute[],
): void {
if (attributes.length === 0 || variantsMap.length === 0) {
if (attributes.length === 0 || variants.length === 0) {
this.variantChange.emit(null);
return;
}
@@ -238,7 +251,7 @@ export class ProductAttributeSelectorComponent {
allSelected = false;
break;
}
const option = attr.options.find(o => o.id === selectedOptionId);
const option = attr.options.find((o) => o.id === selectedOptionId);
if (option) {
selectedValuesById[attr.id] = this.normalizeText(option.value || option.label);
}
@@ -249,12 +262,12 @@ export class ProductAttributeSelectorComponent {
return;
}
const matchingVariant = variantsMap.find(vMap => {
return attributes.every(attr => {
const matchingVariant = variants.find((variant) => {
return attributes.every((attr) => {
const selectedValue = selectedValuesById[attr.id];
const vMapValue = this.getDefaultVariantAttributeValue(attr, vMap.attributes);
if (!vMapValue) return false;
return this.normalizeText(vMapValue) === selectedValue;
const variantValue = this.getDefaultVariantAttributeValue(attr, variant.values);
if (!variantValue) return false;
return this.normalizeText(variantValue) === selectedValue;
});
});

View File

@@ -3,11 +3,19 @@
<div class="product-carousel__main position-relative bg-light overflow-hidden">
<!-- Main Image -->
@if (images().length > 0) {
<img [src]="images()[activeIndex()]" alt="Product active image"
class="product-carousel__main-image w-100 h-100 object-fit-cover" />
<img
[ngSrc]="images()[activeIndex()]"
alt="Product active image"
fill
priority
sizes="(min-width: 992px) 42vw, 100vw"
class="product-carousel__main-image w-100 h-100 object-fit-cover"
/>
} @else {
<!-- Placeholder -->
<div class="product-carousel__placeholder w-100 h-100 d-flex align-items-center justify-content-center text-muted">
<div
class="product-carousel__placeholder w-100 h-100 d-flex align-items-center justify-content-center text-muted"
>
<i class="fa-solid fa-image fa-3x"></i>
</div>
}
@@ -15,22 +23,29 @@
<!-- Discount Badge -->
@if (discount() && discount()! > 0) {
<span
class="product-carousel__discount-badge position-absolute top-0 end-0 bg-primary text-white px-3 py-2 fw-semibold">
class="product-carousel__discount-badge position-absolute top-0 end-0 bg-primary text-white px-3 py-2 fw-semibold"
>
-{{ discount() }}%
</span>
}
<!-- Navigation Arrows -->
@if (images().length > 1) {
<button type="button"
<button
type="button"
class="product-carousel__nav-btn product-carousel__nav-btn--prev position-absolute top-50 translate-middle-y border-0 p-0 d-flex align-items-center justify-content-center"
(click)="prevImage()" aria-label="Previous image">
(click)="prevImage()"
aria-label="Previous image"
>
<i class="fa-solid fa-chevron-left"></i>
</button>
<button type="button"
<button
type="button"
class="product-carousel__nav-btn product-carousel__nav-btn--next position-absolute top-50 translate-middle-y border-0 p-0 d-flex align-items-center justify-content-center"
(click)="nextImage()" aria-label="Next image">
(click)="nextImage()"
aria-label="Next image"
>
<i class="fa-solid fa-chevron-right"></i>
</button>
}
@@ -40,9 +55,13 @@
@if (images().length > 1) {
<div class="product-carousel__thumbnails">
@for (image of images(); track image; let idx = $index) {
<button type="button" class="product-carousel__thumbnail border-0 p-0 overflow-hidden bg-light"
[class.product-carousel__thumbnail--active]="idx === activeIndex()" (click)="selectImage(idx)"
[attr.aria-label]="'Select image ' + (idx + 1)">
<button
type="button"
class="product-carousel__thumbnail border-0 p-0 overflow-hidden bg-light"
[class.product-carousel__thumbnail--active]="idx === activeIndex()"
(click)="selectImage(idx)"
[attr.aria-label]="'Select image ' + (idx + 1)"
>
<img [src]="image" alt="Product thumbnail" class="w-100 h-100 object-fit-cover" />
</button>
}

View File

@@ -1,13 +1,13 @@
import { ChangeDetectionStrategy, Component, input, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { CommonModule, NgOptimizedImage } from '@angular/common';
@Component({
selector: 'app-product-carousel',
standalone: true,
imports: [CommonModule],
imports: [CommonModule, NgOptimizedImage],
templateUrl: './product-carousel.component.html',
styleUrl: './product-carousel.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ProductCarouselComponent {
readonly images = input<string[]>([]);

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,66 @@
<form [formGroup]="profileForm" class="profile-form" (ngSubmit)="onSubmit()">
<div class="form-group">
<label>Nombre y Apellido:</label>
<app-input
type="text"
[value]="profileForm.controls['fullName'].value"
(valueChange)="profileForm.controls['fullName'].setValue($event)"
></app-input>
@if (getControlError('fullName'); as errorMessage) {
<small class="text-danger">{{ errorMessage }}</small>
}
</div>
<div class="form-group">
<label>Email:</label>
<app-input
type="email"
[value]="profileForm.controls['email'].value"
(valueChange)="profileForm.controls['email'].setValue($event)"
></app-input>
@if (getControlError('email'); as errorMessage) {
<small class="text-danger">{{ errorMessage }}</small>
}
</div>
<div class="form-group">
<label>DNI:</label>
<app-input
type="text"
[value]="profileForm.controls['dni'].value"
(valueChange)="profileForm.controls['dni'].setValue($event)"
></app-input>
@if (getControlError('dni'); as errorMessage) {
<small class="text-danger">{{ errorMessage }}</small>
}
</div>
<div class="form-group">
<label>Teléfono:</label>
<app-input
type="text"
[value]="profileForm.controls['phone'].value"
(valueChange)="profileForm.controls['phone'].setValue($event)"
></app-input>
@if (getControlError('phone'); as errorMessage) {
<small class="text-danger">{{ errorMessage }}</small>
}
</div>
<div class="form-group">
<label>Contraseña (dejar en blanco para no modificar):</label>
<app-input
type="password"
[value]="profileForm.controls['password'].value"
(valueChange)="profileForm.controls['password'].setValue($event)"
></app-input>
@if (getControlError('password'); as errorMessage) {
<small class="text-danger">{{ errorMessage }}</small>
}
</div>
<div class="form-actions mt-3">
<app-button type="button" variant="cancel" hostClass="action-btn" buttonClass="w-100" (click)="onCancel()">Cancelar</app-button>
<app-button type="submit" variant="primary" hostClass="action-btn" buttonClass="w-100" [disabled]="isSubmitting()">Guardar</app-button>
</div>
</form>

View File

@@ -0,0 +1,15 @@
.profile-form {
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
}
.form-actions {
display: flex;
gap: 12px;
margin-top: 16px;
}
.action-btn {
flex: 1;
display: block;
}

View File

@@ -0,0 +1,105 @@
import { Component, inject, effect, signal, OnInit } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { InputComponent } from '../../../../../../shared/components/input/input.component';
import { ButtonComponent } from '../../../../../../shared/components/button/button.component';
import { AuthService } from '../../../../../../core/services/auth/auth.service';
import { ToastService } from '../../../../../../core/services/toast.service';
@Component({
selector: 'app-profile-form',
standalone: true,
imports: [ReactiveFormsModule, InputComponent, ButtonComponent],
templateUrl: './profile-form.html',
styleUrl: './profile-form.scss',
})
export class ProfileForm implements OnInit {
profileForm: FormGroup;
private authService = inject(AuthService);
private toastService = inject(ToastService);
isSubmitting = signal(false);
constructor(private fb: FormBuilder) {
this.profileForm = this.fb.group({
fullName: ['', [Validators.required, Validators.maxLength(255)]],
email: ['', [Validators.required, Validators.email, Validators.maxLength(255)]],
dni: ['', [Validators.pattern(/^[0-9]{7,8}$/)]],
phone: ['', [Validators.pattern(/^\+?[0-9\s\-]{10,20}$/)]],
password: ['', [Validators.pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*[\W_]).{8,}$/)]]
});
effect(() => {
const user = this.authService.user();
if (user) {
this.profileForm.patchValue({
fullName: user.nombre_apellido,
email: user.email,
dni: user.dni || '',
phone: user.telefono || ''
});
}
});
}
ngOnInit() {
// Refresh user data directly from the API to ensure consistency
this.authService.loadCurrentUser().subscribe();
}
getControlError(controlName: string): string | null {
const control = this.profileForm.get(controlName);
if (!control || !control.errors || (!control.touched && !this.isSubmitting())) return null;
if (control.errors['required']) return 'Este campo es obligatorio.';
if (control.errors['email']) return 'Ingresa un email válido.';
if (control.errors['maxlength']) return 'Excede el largo máximo.';
if (controlName === 'dni' && control.errors['pattern']) return 'DNI inválido (7-8 números).';
if (controlName === 'phone' && control.errors['pattern']) return 'Teléfono inválido.';
if (controlName === 'password' && control.errors['pattern']) return 'Debe contener mayúscula, minúscula, carácter especial y mínimo 8 caracteres.';
return 'Valor inválido.';
}
onSubmit() {
if (this.profileForm.invalid) {
this.profileForm.markAllAsTouched();
return;
}
this.isSubmitting.set(true);
const formValue = this.profileForm.value;
const payload: any = {
nombre_apellido: formValue.fullName,
email: formValue.email,
};
if (formValue.dni) payload.dni = formValue.dni;
if (formValue.phone) payload.telefono = formValue.phone;
if (formValue.password) payload.password = formValue.password;
this.authService.updateProfile(payload).subscribe({
next: () => {
this.isSubmitting.set(false);
this.toastService.success('Perfil actualizado correctamente.');
},
error: (error: any) => {
this.isSubmitting.set(false);
const errorMessage = error.error?.message || 'Error al actualizar el perfil.';
this.toastService.danger(errorMessage);
}
});
}
onCancel() {
const user = this.authService.user();
if (user) {
this.profileForm.reset({
fullName: user.nombre_apellido,
email: user.email,
dni: user.dni || '',
phone: user.telefono || '',
password: ''
});
} else {
this.profileForm.reset();
}
}
}

View File

@@ -0,0 +1,74 @@
<article class="w-100 py-3 rounded-0 cart-item" [class.cart-item--without-image]="!item.imageUrl">
@if (item.imageUrl) {
<div class="position-relative overflow-hidden cart-item-media">
@if (item.discountPercentage) {
<span class="position-absolute top-0 start-0 z-1 rounded-0 cart-item-discount-badge"
>-{{ item.discountPercentage }}%</span
>
}
<img class="w-100 h-100 object-fit-cover d-block" [src]="item.imageUrl" [alt]="item.title" />
</div>
}
<div class="d-flex flex-column justify-content-between w-100 h-100 py-1 cart-item-content">
<div class="d-grid cart-item-top-row align-items-start">
<h3 class="m-0 text-uppercase fw-bold cart-item-product">{{ item.title }}</h3>
<div class="cart-item-prices">
@if (item.originalPrice) {
<span class="text-decoration-line-through cart-item-original-price"
>$ {{ item.originalPrice }}</span
>
}
<span class="fw-bold cart-item-discounted-price">$ {{ item.price }}</span>
</div>
</div>
@if (item.imageUrl) {
<div
class="d-grid cart-item-attributes"
[class.cart-item-attributes--quantity-only]="item.attributes.length === 0"
>
<div class="cart-item-attribute">
<span class="cart-item-attribute-label">Cantidad: </span>
<span class="fw-bold"
>{{ item.quantity }} unidad{{ item.quantity === 1 ? '' : 'es' }}</span
>
</div>
@for (attribute of item.attributes; track attribute.label + attribute.value) {
<div class="cart-item-attribute">
<span class="cart-item-attribute-label">{{ attribute.label }}: </span>
<span class="fw-bold">{{ attribute.value }}</span>
</div>
}
</div>
@if (item.transferPrice) {
<div class="cart-item-transfer-price">
<span class="cart-item-price-label">Precio por transferencia</span>
<span class="fw-bold">${{ item.transferPrice }}</span>
</div>
}
} @else {
<p class="m-0 cart-item-description">{{ item.description }}</p>
<div class="d-grid cart-item-bottom-details">
@for (attribute of item.attributes; track attribute.label + attribute.value) {
<div class="cart-item-attribute">
<span class="cart-item-attribute-label">{{ attribute.label }}: </span>
<span class="fw-bold">{{ attribute.value }}</span>
</div>
}
<div class="cart-item-attribute">
<span class="cart-item-attribute-label">Cantidad: </span>
<span class="fw-bold"
>{{ item.quantity }} unidad{{ item.quantity === 1 ? '' : 'es' }}</span
>
</div>
</div>
}
</div>
</article>

View File

@@ -0,0 +1,129 @@
:host {
display: block;
border-bottom: 1px solid #dddddd;
}
:host(:last-child) {
border-bottom: none;
}
.cart-item {
display: grid;
grid-template-columns: 120px minmax(0, 1fr);
gap: 0.75rem;
background-color: transparent;
position: relative;
&--without-image {
grid-template-columns: minmax(0, 1fr);
}
}
.cart-item-media {
width: 120px;
aspect-ratio: 4 / 3;
border-radius: 5px 0 0 5px;
background-color: #ffffff;
border: 1px solid #eaeaea;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.cart-item-discount-badge {
width: 44px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
border-bottom-right-radius: 4px;
background-color: var(--bs-primary, #5b75ff);
color: #ffffff;
font-size: 10px;
font-weight: 500;
line-height: 1;
}
.cart-item-content {
gap: 0.5rem;
}
.cart-item-top-row {
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.75rem;
}
.cart-item-product {
color: #666666;
font-size: 10px;
line-height: 1.25;
font-weight: 325;
}
.cart-item-prices {
display: grid;
justify-items: end;
line-height: 1.1;
}
.cart-item-original-price {
color: #a0a0a0;
font-size: 9px;
font-weight: 325;
}
.cart-item-discounted-price {
color: var(--bs-primary, #5b75ff);
font-size: 15px;
font-weight: bold;
}
.cart-item-price-label {
color: #a0a0a0;
font-size: 9px;
font-weight: 325;
}
.cart-item-attributes {
gap: 0.125rem;
&--quantity-only {
flex: 1;
align-content: center;
}
}
.cart-item-attribute {
color: #666666;
font-size: 9px;
line-height: 1.2;
}
.cart-item-attribute-label {
font-weight: 325;
}
.cart-item-transfer-price {
color: #666666;
font-size: 9px;
line-height: 1.2;
display: grid;
justify-items: end;
}
.cart-item-description {
color: #666666;
font-size: 9px;
font-weight: 325;
line-height: 1.35;
white-space: pre-line;
}
.cart-item-bottom-details {
gap: 0.125rem;
justify-items: end;
}

View File

@@ -0,0 +1,68 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { beforeEach, describe, expect, it } from 'vitest';
import { PurchaseItem, PurchaseItemViewModel } from './purchase-item';
describe('PurchaseItem', () => {
let fixture: ComponentFixture<PurchaseItem>;
const item: PurchaseItemViewModel = {
id: 1,
title: 'Entrada general',
description: 'Acceso al evento durante toda la jornada.',
imageUrl: null,
quantity: 2,
attributes: [{ label: 'Turno', value: 'Tarde' }],
price: '20000.00',
transferPrice: '18000.00',
};
beforeEach(async () => {
await TestBed.configureTestingModule({ imports: [PurchaseItem] }).compileComponents();
fixture = TestBed.createComponent(PurchaseItem);
});
it('uses the image-free layout and moves description and item details', () => {
fixture.componentInstance.item = item;
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(
element.querySelector('.cart-item')?.classList.contains('cart-item--without-image'),
).toBe(true);
expect(element.querySelector('.cart-item-media')).toBeNull();
expect(element.querySelector('.cart-item-description')?.textContent?.trim()).toBe(
item.description,
);
expect(element.querySelector('.cart-item-bottom-details')?.textContent).toContain(
'Turno: Tarde',
);
expect(element.querySelector('.cart-item-bottom-details')?.textContent).toContain(
'Cantidad: 2 unidades',
);
expect(element.querySelector('.cart-item-transfer-price')).toBeNull();
});
it('keeps the existing layout when the product has an image', () => {
fixture.componentInstance.item = {
...item,
imageUrl: 'https://example.com/product.png',
};
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(
element.querySelector('.cart-item')?.classList.contains('cart-item--without-image'),
).toBe(false);
expect(element.querySelector('.cart-item-media img')).not.toBeNull();
expect(element.querySelector('.cart-item-description')).toBeNull();
expect(element.querySelector('.cart-item-attributes')?.textContent).toContain(
'Cantidad: 2 unidades',
);
expect(element.querySelector('.cart-item-transfer-price')?.textContent).toContain(
'Precio por transferencia',
);
});
});

View File

@@ -0,0 +1,28 @@
import { Component, Input } from '@angular/core';
export type PurchaseItemViewModel = {
id: number;
title: string;
description: string | null;
imageUrl: string | null;
quantity: number;
attributes: Array<{
label: string;
value: string | null;
}>;
price: string;
originalPrice?: string | null;
transferPrice?: string | null;
discountPercentage?: string | number | null;
};
@Component({
selector: 'app-purchase-item',
standalone: true,
imports: [],
templateUrl: './purchase-item.html',
styleUrl: './purchase-item.scss',
})
export class PurchaseItem {
@Input() item!: PurchaseItemViewModel;
}

View File

@@ -0,0 +1,11 @@
<div class="purchase-item" [routerLink]="[purchase.id]" style="cursor: pointer;">
<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-action">
<svg width="8" height="14" viewBox="0 0 8 14" fill="none" xmlns="http://www.w3.org/2000/svg" class="arrow-icon">
<path d="M1 1L7 7L1 13" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
</div>

View File

@@ -0,0 +1,37 @@
.purchase-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
padding-left:0;
transition: box-shadow 0.2s, border-radius 0.2s;
cursor: pointer;
}
.purchase-item:hover {
box-shadow: 0px 0px 30px 0px rgba(0, 0, 0, 0.07);
border-radius: 8px;
}
.purchase-item:hover .arrow-icon {
color: #5b75ff; /* Blue on hover */
}
.purchase-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.purchase-id {
font-weight: bold;
color: #666666;
font-size: 13px;
}
.purchase-date {
font-weight: 325;
color: #666666;
font-size: 10px;
}
.arrow-icon {
width: 8px;
height: 14px;
color: #888;
transition: color 0.2s;
}

View File

@@ -0,0 +1,14 @@
import { Component, Input } from '@angular/core';
import { RouterLink } from '@angular/router';
@Component({
selector: 'app-purchase-list-item',
standalone: true,
imports: [RouterLink],
templateUrl: './purchase-list-item.html',
styleUrl: './purchase-list-item.scss',
})
export class PurchaseListItem {
@Input() purchase!: { id: number; date: string };
}

View File

@@ -0,0 +1,29 @@
<div class="purchase-list-container">
<ng-container *ngIf="isLoading(); else contentTpl">
<!-- Skeleton -->
<div class="skeleton-item" *ngFor="let i of [1, 2, 3, 4]">
<div class="skeleton-info">
<div class="skeleton-id"></div>
<div class="skeleton-date"></div>
</div>
<div class="skeleton-action"></div>
</div>
</ng-container>
<ng-template #contentTpl>
<ng-container *ngIf="purchases().length > 0; else emptyTpl">
<ng-container *ngFor="let purchase of purchases(); let last = last">
<app-purchase-list-item
[purchase]="purchase">
</app-purchase-list-item>
<div class="purchase-divider" *ngIf="!last"></div>
</ng-container>
</ng-container>
</ng-template>
<ng-template #emptyTpl>
<div class="empty-state">
Aún no hay compras realizadas
</div>
</ng-template>
</div>

View File

@@ -0,0 +1,73 @@
.purchase-list-container {
display: flex;
flex-direction: column;
width: 100%;
}
.purchase-divider {
height: 1px;
background-color: #DDDDDD;
width: 100%;
}
.empty-state {
padding: 2rem 0;
text-align: center;
color: #666;
font-size: 1rem;
}
/* Skeleton */
.skeleton-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 0;
border-bottom: 1px solid #DDDDDD;
}
.skeleton-item:last-child {
border-bottom: none;
}
.skeleton-info {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.skeleton-id {
width: 100px;
height: 1rem;
background-color: #EEEEEE;
border-radius: 4px;
animation: pulse 1.5s infinite ease-in-out;
}
.skeleton-date {
width: 150px;
height: 0.8rem;
background-color: #EEEEEE;
border-radius: 4px;
animation: pulse 1.5s infinite ease-in-out;
}
.skeleton-action {
width: 24px;
height: 24px;
background-color: #EEEEEE;
border-radius: 50%;
animation: pulse 1.5s infinite ease-in-out;
}
@keyframes pulse {
0% {
background-color: #EEEEEE;
}
50% {
background-color: #E0E0E0;
}
100% {
background-color: #EEEEEE;
}
}

View File

@@ -0,0 +1,64 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { PurchaseListItem } from '../purchase-list-item/purchase-list-item';
import { CheckoutService, PurchaseSummaryResponse } from '../../../../../../core/services/checkout.service';
import { ToastService } from '../../../../../../core/services/toast.service';
import { TenantService } from '../../../../../../core/services/tenant.service';
type PurchaseListViewModel = {
id: number;
date: string;
};
@Component({
selector: 'app-purchase-list',
standalone: true,
imports: [CommonModule, PurchaseListItem],
templateUrl: './purchase-list.html',
styleUrl: './purchase-list.scss',
})
export class PurchaseList implements OnInit {
private readonly checkoutService = inject(CheckoutService);
private readonly toastService = inject(ToastService);
private readonly tenantService = inject(TenantService);
purchases = signal<PurchaseListViewModel[]>([]);
isLoading = signal<boolean>(true);
async ngOnInit(): Promise<void> {
try {
const tenantCode = this.tenantService.tenant()?.codigo || '';
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),
}));
this.purchases.set(mappedPurchases);
} catch (error) {
this.toastService.danger('Hubo un error al cargar las compras');
} finally {
this.isLoading.set(false);
}
}
private formatDate(value: string | null): string {
if (!value) {
return '-';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '-';
}
return new Intl.DateTimeFormat('es-AR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
}).format(date);
}
}

View File

@@ -0,0 +1,4 @@
<div class="page-container">
<h2 class="page-title">DATOS PERSONALES</h2>
<app-profile-form></app-profile-form>
</div>

View File

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

View File

@@ -0,0 +1,11 @@
import { Component } from '@angular/core';
import { ProfileForm } from '../../components/profile-form/profile-form';
@Component({
selector: 'app-profile-page',
standalone: true,
imports: [ProfileForm],
templateUrl: './profile-page.html',
styleUrl: './profile-page.scss',
})
export class ProfilePage {}

View File

@@ -0,0 +1,33 @@
<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>
@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="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 ">
<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>
<div class="d-flex flex-column">
@for (item of purchase.items; track item.id) {
<app-purchase-item [item]="item"></app-purchase-item>
} @empty {
<p class="purchase-empty">No hay productos para mostrar en esta compra.</p>
}
</div>
}
</div>

View File

@@ -0,0 +1,51 @@
.page-container {
display: flex;
flex-direction: column;
width: 100%;
max-width: 600px;
}
.page-title {
font-size: 15px;
font-weight: bold;
color: #A0A0A0;
}
.purchase-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.purchase-id {
font-weight: bold;
color: #666666;
font-size: 13px;
}
.purchase-date {
font-weight: 325;
color: #666666;
font-size: 10px;
}
.purchase-total {
display: flex;
align-items: center;
gap: 0.5rem;
}
.purchase-total-label {
font-size: 17px;
color: #666666;
font-weight: 325;
}
.purchase-total-value {
color: var(--bs-primary, #5b75ff);
font-weight: bold;
font-size: 17px;
}
.purchase-loading,
.purchase-empty {
color: #666666;
font-size: 12px;
margin: 0;
}

View File

@@ -0,0 +1,101 @@
import { CommonModule } from '@angular/common';
import { Component, OnInit, inject, signal } from '@angular/core';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import {
CheckoutService,
PurchaseDetailResponse,
} from '../../../../../../core/services/checkout.service';
import { TenantService } from '../../../../../../core/services/tenant.service';
import { ToastService } from '../../../../../../core/services/toast.service';
import { PurchaseItem, PurchaseItemViewModel } from '../../components/purchase-item/purchase-item';
type PurchaseDetailViewModel = {
id: number;
date: string;
total: string;
items: PurchaseItemViewModel[];
};
@Component({
selector: 'app-purchase-detail-page',
standalone: true,
imports: [CommonModule, RouterLink, PurchaseItem],
templateUrl: './purchase-detail-page.html',
styleUrl: './purchase-detail-page.scss',
})
export class PurchaseDetailPage implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly checkoutService = inject(CheckoutService);
private readonly tenantService = inject(TenantService);
private readonly toastService = inject(ToastService);
readonly isLoading = signal(true);
readonly purchase = signal<PurchaseDetailViewModel | null>(null);
async ngOnInit(): Promise<void> {
const purchaseId = this.route.snapshot.paramMap.get('id');
const tenant = this.tenantService.tenant();
if (!purchaseId || !tenant) {
void this.router.navigate(['/mi-cuenta/compras']);
return;
}
try {
const response = await this.checkoutService
.withCustomLoading()
.getPurchase(tenant.codigo, purchaseId);
this.purchase.set({
id: response.id,
date: this.formatDate(response.created_at),
total: response.total,
items: this.mapItems(response),
});
} catch (error) {
console.error('Failed to fetch purchase detail:', error);
this.toastService.danger('Hubo un error al cargar el detalle de la compra');
void this.router.navigate(['/mi-cuenta/compras']);
} finally {
this.isLoading.set(false);
}
}
private formatDate(value: string | null): string {
if (!value) {
return '-';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '-';
}
return new Intl.DateTimeFormat('es-AR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
}).format(date);
}
private mapItems(purchase: PurchaseDetailResponse): PurchaseItemViewModel[] {
return purchase.items.map((item) => ({
id: item.id,
title: item.item_details.nombre || 'Producto sin nombre',
description: item.item_details.descripcion,
imageUrl: item.item_details.imagen,
quantity: item.quantity,
attributes: item.item_details.attributes.map((attribute) => ({
label: attribute.name,
value: attribute.value === null ? null : String(attribute.value),
})),
price: item.line_total,
originalPrice: null,
transferPrice: null,
discountPercentage: null,
}));
}
}

View File

@@ -0,0 +1,4 @@
<div class="page-container">
<h2 class="page-title">MIS COMPRAS</h2>
<app-purchase-list></app-purchase-list>
</div>

View File

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

View File

@@ -0,0 +1,11 @@
import { Component } from '@angular/core';
import { PurchaseList } from '../../components/purchase-list/purchase-list';
@Component({
selector: 'app-purchases-page',
standalone: true,
imports: [PurchaseList],
templateUrl: './purchases-page.html',
styleUrl: './purchases-page.scss',
})
export class PurchasesPage {}

View File

@@ -0,0 +1,55 @@
<article class="ticket" [class.ticket--disabled]="disabled()" [attr.aria-disabled]="disabled()">
@if (!disabled()) {
<label class="ticket__selection">
<input
type="checkbox"
[checked]="selected()"
(change)="selected.set($any($event.target).checked)"
/>
<span class="visually-hidden">Seleccionar ticket {{ title() }}</span>
</label>
}
<div class="ticket__details">
<h3 class="ticket__title">{{ title() }}</h3>
<p class="ticket__id">ID: {{ ticketId() }}</p>
</div>
@if (date() || expired()) {
<div class="ticket__status">
@if (expired()) {
<span class="ticket__expired">VENCIDO</span>
}
@if (date(); as ticketDate) {
<time class="ticket__date">{{ ticketDate }}</time>
}
</div>
}
<div class="ticket__actions">
<app-button
variant="primary"
buttonClass="ticket__qr-button"
[disabled]="disabled()"
(click)="viewQr.emit()"
>
<i class="fa-solid fa-qrcode" aria-hidden="true"></i>
<span>Ver QR</span>
</app-button>
@if (!disabled()) {
<app-icon-button
variant="download"
ariaLabel="Descargar ticket"
[disabled]="isGeneratingPdf()"
(clicked)="download.emit()"
/>
<app-icon-button
variant="share"
ariaLabel="Compartir ticket"
[disabled]="isGeneratingPdf()"
(clicked)="share.emit()"
/>
}
</div>
</article>

View File

@@ -0,0 +1,132 @@
:host {
display: block;
width: 100%;
}
.ticket {
display: grid;
grid-template-columns: auto minmax(0, 1fr) 78px auto;
align-items: center;
width: 100%;
min-height: 76px;
gap: 16px;
padding: 16px 0;
color: #666666;
}
.ticket__selection {
display: flex;
margin: 0;
cursor: pointer;
}
.ticket__selection input {
width: 21px;
height: 21px;
margin: 0;
accent-color: var(--tenant-primary, #009933);
cursor: pointer;
}
.ticket__details {
min-width: 0;
}
.ticket__title,
.ticket__id {
overflow: hidden;
margin: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
.ticket__title {
font-size: 13px;
font-weight: 700;
line-height: 1.25;
text-transform: uppercase;
}
.ticket__id {
margin-top: 3px;
font-size: 12px;
line-height: 1.4;
}
.ticket__date {
display: block;
font-size: 10px;
line-height: 1.4;
white-space: nowrap;
}
.ticket__status {
text-align: center;
}
.ticket__expired {
display: block;
font-size: 10px;
font-weight: 700;
line-height: 1.4;
text-transform: uppercase;
}
.ticket__actions {
display: flex;
align-items: center;
gap: 8px;
}
:host ::ng-deep button.ticket__qr-button {
width: 115px;
min-width: 0;
max-width: 115px;
min-height: 36px;
padding-inline: 14px;
}
.ticket--disabled {
grid-template-columns: minmax(0, 1fr) 78px auto;
color: #8a8a8a;
}
:host ::ng-deep .ticket--disabled button.ticket__qr-button:disabled {
--bs-btn-disabled-bg: #8a8a8a;
--bs-btn-disabled-border-color: #8a8a8a;
--bs-btn-disabled-color: #ffffff;
--bs-btn-disabled-opacity: 1;
}
.ticket__selection input:focus-visible {
outline: 2px solid var(--tenant-primary, #009933);
outline-offset: 2px;
}
@media (max-width: 767.98px) {
.ticket {
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 10px;
}
.ticket__status {
grid-column: 2;
}
.ticket__actions {
grid-column: 3;
grid-row: 1 / span 2;
}
.ticket--disabled {
grid-template-columns: minmax(0, 1fr) auto;
}
.ticket--disabled .ticket__status {
grid-column: 1;
}
.ticket--disabled .ticket__actions {
grid-column: 2;
}
}

View File

@@ -0,0 +1,64 @@
import { TestBed } from '@angular/core/testing';
import { describe, expect, it, vi } from 'vitest';
import { TicketComponent } from './ticket.component';
describe('TicketComponent', () => {
it('renders its data and emits its actions', async () => {
await TestBed.configureTestingModule({ imports: [TicketComponent] }).compileComponents();
const fixture = TestBed.createComponent(TicketComponent);
fixture.componentRef.setInput('title', 'ENTRADA GENERAL');
fixture.componentRef.setInput('ticketId', '0000000000');
fixture.componentRef.setInput('date', '09 de octubre');
const viewQr = vi.fn();
const download = vi.fn();
const share = vi.fn();
fixture.componentInstance.viewQr.subscribe(viewQr);
fixture.componentInstance.download.subscribe(download);
fixture.componentInstance.share.subscribe(share);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(element.textContent).toContain('ENTRADA GENERAL');
expect(element.textContent).toContain('ID: 0000000000');
element.querySelector<HTMLButtonElement>('app-button button')?.click();
element.querySelector<HTMLButtonElement>('[aria-label="Descargar ticket"]')?.click();
element.querySelector<HTMLButtonElement>('[aria-label="Compartir ticket"]')?.click();
expect(viewQr).toHaveBeenCalledOnce();
expect(download).toHaveBeenCalledOnce();
expect(share).toHaveBeenCalledOnce();
});
it('renders the expired disabled state without secondary actions', async () => {
await TestBed.configureTestingModule({ imports: [TicketComponent] }).compileComponents();
const fixture = TestBed.createComponent(TicketComponent);
fixture.componentRef.setInput('ticketId', '0000000000');
fixture.componentRef.setInput('date', '09 de octubre');
fixture.componentRef.setInput('disabled', true);
fixture.componentRef.setInput('expired', true);
const viewQr = vi.fn();
fixture.componentInstance.viewQr.subscribe(viewQr);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const ticket = element.querySelector<HTMLElement>('.ticket');
const qrButton = element.querySelector<HTMLButtonElement>('app-button button');
expect(ticket?.classList.contains('ticket--disabled')).toBe(true);
expect(ticket?.getAttribute('aria-disabled')).toBe('true');
expect(element.textContent).toContain('VENCIDO');
expect(qrButton?.disabled).toBe(true);
expect(element.querySelector('[aria-label="Descargar ticket"]')).toBeNull();
expect(element.querySelector('[aria-label="Compartir ticket"]')).toBeNull();
expect(element.querySelector('input[type="checkbox"]')).toBeNull();
qrButton?.click();
expect(viewQr).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,25 @@
import { ChangeDetectionStrategy, Component, input, model, output } from '@angular/core';
import { ButtonComponent } from '../../../../../../../../shared/components/button/button.component';
import { IconButtonComponent } from '../../../../../../../../shared/components/icon-button/icon-button.component';
@Component({
selector: 'app-ticket',
imports: [ButtonComponent, IconButtonComponent],
templateUrl: './ticket.component.html',
styleUrl: './ticket.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TicketComponent {
readonly title = input('ENTRADA GENERAL');
readonly ticketId = input.required<string | number>();
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>();
readonly download = output<void>();
readonly share = output<void>();
}

View File

@@ -0,0 +1,46 @@
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 {
id: number;
tenant_code: string;
ticket: string;
name: string;
description: string | null;
source_catalog_item_id: number | null;
source_variant_id: number | null;
starts_at: string | null;
expires_at: string | null;
used_at: string | null;
is_valid: boolean;
is_expired: boolean;
is_used: boolean;
}
@Injectable()
export class TicketService extends BaseApiService {
private readonly tenantService = inject(TenantService);
getTickets(): Promise<TicketResponse[]> {
return firstValueFrom(
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

@@ -0,0 +1,109 @@
<section class="tickets-page">
<h1 class="tickets-page__title">MIS TICKETS</h1>
<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>
}
@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">
<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) {
<app-ticket
[title]="ticket.name"
[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)"
/>
}
@if (inactiveTickets().length) {
<section class="tickets-list__inactive" aria-labelledby="inactive-tickets-title">
<header class="tickets-list__header tickets-list__header--inactive">
<h2 id="inactive-tickets-title">TICKETS INACTIVOS</h2>
</header>
@for (ticket of inactiveTickets(); track ticket.id) {
<app-ticket
[title]="ticket.name"
[ticketId]="ticket.id"
[date]="formatDate(ticket)"
[disabled]="true"
[expired]="ticket.is_expired"
/>
}
</section>
}
} @else {
<p class="tickets-list__empty">Aún no tenés tickets disponibles.</p>
}
</div>
</section>

View File

@@ -0,0 +1,231 @@
.tickets-page {
width: 100%;
max-width: 720px;
}
.tickets-page__title {
margin: 0 0 20px;
color: #a0a0a0;
font-size: 15px;
font-weight: 700;
}
.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 {
display: flex;
min-height: 58px;
align-items: center;
justify-content: space-between;
padding-bottom: 12px;
border-bottom: 1px solid #dddddd;
}
.tickets-list__header--inactive {
justify-content: flex-start;
}
.tickets-list__header--inactive h2 {
margin: 0;
color: #a0a0a0;
font-size: 14px;
font-weight: 700;
}
.tickets-list__inactive {
margin-top: 44px;
}
.tickets-list__select-all {
display: flex;
align-items: center;
gap: 16px;
margin: 0;
color: #a0a0a0;
cursor: pointer;
font-size: 14px;
font-weight: 700;
}
.tickets-list__select-all input {
width: 21px;
height: 21px;
margin: 0;
accent-color: var(--tenant-primary, #009933);
}
.tickets-list__bulk-actions {
display: flex;
gap: 18px;
}
.tickets-list__bulk-action {
display: flex;
flex-direction: column;
align-items: center;
color: #a0a0a0;
font-size: 11px;
}
.tickets-list app-ticket {
border-bottom: 1px solid #dddddd;
}
.tickets-list__empty {
padding: 32px 0;
color: #666666;
text-align: center;
}
.tickets-list__skeleton-header {
display: flex;
min-height: 58px;
align-items: center;
justify-content: space-between;
padding-bottom: 12px;
border-bottom: 1px solid #dddddd;
}
.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;
}
}
@keyframes tickets-spinner {
to {
transform: rotate(360deg);
}
}
@media (max-width: 767.98px) {
.tickets-page__title {
margin-top: 24px;
}
.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

@@ -0,0 +1,277 @@
import { By } from '@angular/platform-browser';
import { TestBed } from '@angular/core/testing';
import { describe, expect, it, vi } from 'vitest';
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',
ticket: 'ticket-1',
name: 'Entrada general',
description: null,
source_catalog_item_id: null,
source_variant_id: null,
starts_at: '2026-10-09T00:00:00.000Z',
expires_at: null,
used_at: null,
is_valid: true,
is_expired: false,
is_used: false,
...overrides,
});
describe('TicketsPage', () => {
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 }),
ticket({ id: 2, is_valid: false, is_expired: true }),
ticket({ id: 3, is_valid: false, is_used: true, used_at: '2026-10-10T00:00:00.000Z' }),
];
await TestBed.configureTestingModule({
imports: [TicketsPage],
providers: [{ provide: ToastService, useValue: { danger: vi.fn() } }],
})
.overrideComponent(TicketsPage, {
set: {
providers: [
{
provide: TicketService,
useValue: withCustomLoading({ getTickets: () => Promise.resolve(tickets) }),
},
],
},
})
.compileComponents();
const fixture = TestBed.createComponent(TicketsPage);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const ticketComponents = fixture.debugElement.queryAll(By.directive(TicketComponent));
const [activeTicket, expiredTicket, usedTicket] = ticketComponents.map(
(element) => element.componentInstance as TicketComponent,
);
expect(ticketComponents).toHaveLength(3);
expect(activeTicket.disabled()).toBe(false);
expect(expiredTicket.disabled()).toBe(true);
expect(expiredTicket.expired()).toBe(true);
expect(usedTicket.disabled()).toBe(true);
expect(usedTicket.expired()).toBe(false);
expect(fixture.nativeElement.textContent).toContain('TICKETS INACTIVOS');
const page = fixture.nativeElement as HTMLElement;
const inactiveElements = Array.from(
page.querySelectorAll<HTMLElement>('.tickets-list__inactive app-ticket'),
);
expect(inactiveElements).toHaveLength(2);
inactiveElements.forEach((element) => {
expect(element.querySelector('.ticket--disabled')).not.toBeNull();
expect(element.querySelector<HTMLButtonElement>('app-button button')?.disabled).toBe(true);
expect(element.querySelector('input[type="checkbox"]')).toBeNull();
expect(element.querySelector('[aria-label="Descargar ticket"]')).toBeNull();
expect(element.querySelector('[aria-label="Compartir ticket"]')).toBeNull();
});
});
it('opens the QR modal with the selected ticket data', async () => {
const modalService = { openQr: vi.fn() };
await TestBed.configureTestingModule({
imports: [TicketsPage],
providers: [
{ provide: ToastService, useValue: { danger: vi.fn() } },
{ provide: ModalService, useValue: modalService },
],
})
.overrideComponent(TicketsPage, {
set: {
providers: [
{
provide: TicketService,
useValue: withCustomLoading({
getTickets: () => Promise.resolve([ticket({})]),
}),
},
],
},
})
.compileComponents();
const fixture = TestBed.createComponent(TicketsPage);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const page = fixture.nativeElement as HTMLElement;
page.querySelector<HTMLButtonElement>('app-ticket app-button button')?.click();
expect(modalService.openQr).toHaveBeenCalledWith({
title: 'Entrada general',
id: 1,
date: '09-octubre',
ticket: 'ticket-1',
});
});
});

View File

@@ -0,0 +1,179 @@
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 { TicketComponent } from './components/ticket/ticket.component';
import { TicketResponse, TicketService } from './ticket.service';
@Component({
selector: 'app-tickets-page',
imports: [IconButtonComponent, TicketComponent],
providers: [TicketService],
templateUrl: './tickets-page.html',
styleUrl: './tickets-page.scss',
})
export class TicketsPage implements OnInit {
private readonly ticketService = inject(TicketService);
private readonly toastService = inject(ToastService);
private readonly modalService = inject(ModalService);
protected readonly tickets = signal<TicketResponse[]>([]);
protected readonly activeTickets = computed(() =>
this.tickets().filter((ticket) => !this.isInactive(ticket)),
);
protected readonly inactiveTickets = computed(() =>
this.tickets().filter((ticket) => this.isInactive(ticket)),
);
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,
);
async ngOnInit(): Promise<void> {
try {
this.tickets.set(await this.ticketService.withCustomLoading().getTickets());
} catch {
this.toastService.danger('Hubo un error al cargar los tickets');
} finally {
this.isLoading.set(false);
}
}
protected toggleAll(selected: boolean): void {
this.selectedIds.set(
selected ? new Set(this.activeTickets().map((ticket) => ticket.id)) : new Set(),
);
}
protected toggleTicket(ticketId: number, selected: boolean): void {
this.selectedIds.update((current) => {
const next = new Set(current);
selected ? next.add(ticketId) : next.delete(ticketId);
return next;
});
}
protected isSelected(ticketId: number): boolean {
return this.selectedIds().has(ticketId);
}
protected isInactive(ticket: TicketResponse): boolean {
return ticket.is_expired || ticket.is_used;
}
protected formatDate(ticket: TicketResponse): string | null {
const value = ticket.starts_at ?? ticket.expires_at;
if (!value) return null;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return null;
return new Intl.DateTimeFormat('es-AR', {
day: '2-digit',
month: 'long',
timeZone: 'UTC',
}).format(date);
}
protected viewQr(ticket: TicketResponse): void {
this.modalService.openQr({
title: ticket.name,
id: ticket.id,
date: this.formatDate(ticket) ?? '',
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);
}
}

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