128 Commits

Author SHA1 Message Date
3f8b5bf206 feat(product-ticket-selector): enhance variant selection handling and add toast notifications for errors 2026-08-14 15:44:30 -03:00
d92b5a7623 feat(tenant-service): implement bootstrap request timeout and enhance request context resolution 2026-08-14 15:17:22 -03:00
e4670491c7 feat(ticket-selector): implement confirmation modal for row deletion and refactor selection handling 2026-08-14 14:06:43 -03:00
1b94b2ca13 feat(ticket-selector): enhance validation feedback and improve cart synchronization 2026-08-14 13:40:26 -03:00
46dcb84985 test(ticket-selector): cover compact option responses 2026-08-14 10:15:01 -03:00
f7983b109f refactor(ticket-selector): consume compact remote options 2026-08-14 10:14:46 -03:00
8ff9ed216c fix(ticket-selector): reset invalid variant selections 2026-08-14 10:01:56 -03:00
f80d146012 refactor(ticket-selector): stop sending excluded variants 2026-08-14 09:54:44 -03:00
1cb2cf3d54 feat(checkout): use reserved cart for ticket purchases 2026-08-14 09:43:58 -03:00
97250d10b7 feat(ticket-selector): reserve completed selections in cart 2026-08-14 09:43:53 -03:00
15979874ce feat(catalog): fetch options for partial variant selections 2026-08-14 09:43:47 -03:00
0defe75adb feat(variant-selector): implement manual selection when autoSelectFirst is disabled 2026-08-14 09:26:43 -03:00
34afb5142d Merge branch 'tenant/desfile_pura_tendencia' of https://gitea.quo.ar/tbianchini/shopit-front into tenant/desfile_pura_tendencia 2026-08-14 09:05:08 -03:00
8eba15155e feat(stock): add handling for unavailable variants in product selection 2026-08-14 09:05:05 -03:00
85c31579e2 feat(checkout): update checkout process to handle multiple direct items 2026-08-14 09:05:05 -03:00
159ec36cb2 refactor(checkout): simplify direct item handling in checkout process 2026-08-14 09:05:05 -03:00
32b1eb5639 refactor(ticket-selector): restructure actions layout for improved responsiveness 2026-08-14 09:04:56 -03:00
0f9d3e79d4 refactor(variants): standardize variant structure and improve variant handling across components 2026-08-14 09:04:56 -03:00
b97753f825 feat(ticket-selector): implement ticket selector component with layout and functionality 2026-08-14 09:04:36 -03:00
f1b724ed63 feat(stock): add handling for unavailable variants in product selection 2026-08-14 09:01:34 -03:00
571cae0f98 feat(checkout): update checkout process to handle multiple direct items 2026-08-14 09:01:34 -03:00
30b8527c9a refactor(checkout): simplify direct item handling in checkout process 2026-08-14 09:01:34 -03:00
0e5f6dceb0 feat(store-layout): add dynamic background images to header and footer components 2026-08-14 09:01:34 -03:00
cd0a6e9b5e refactor(ticket-selector): restructure actions layout for improved responsiveness 2026-08-14 09:01:34 -03:00
1bbb3d0914 refactor(variants): standardize variant structure and improve variant handling across components 2026-08-14 09:01:34 -03:00
e4157a4005 feat(ticket-selector): implement ticket selector component with layout and functionality 2026-08-14 09:01:34 -03:00
c9f8a1c4a6 feat(tenant): add site_title and favicon properties to Tenant interface; update app to set document title and favicon dynamically 2026-08-14 09:01:17 -03:00
308af52ad5 refactor(ticket.service): remove validity time interfaces from ticket service 2026-08-13 15:13:48 -03:00
ae6728998d Merge branch 'tenant/desfile_pura_tendencia' of https://gitea.quo.ar/tbianchini/shopit-front into tenant/desfile_pura_tendencia 2026-08-12 16:14:26 -03:00
f1b829264f feat(stock): add handling for unavailable variants in product selection 2026-08-12 16:13:39 -03:00
fccb7676bf feat(checkout): update checkout process to handle multiple direct items 2026-08-12 16:13:39 -03:00
3adc04b68d refactor(checkout): simplify direct item handling in checkout process 2026-08-12 16:13:39 -03:00
12e2870d40 feat(store-layout): add dynamic background images to header and footer components 2026-08-12 16:13:39 -03:00
0b53253461 refactor(ticket-selector): restructure actions layout for improved responsiveness 2026-08-12 16:13:39 -03:00
6cc0fcd9c0 refactor(variants): standardize variant structure and improve variant handling across components 2026-08-12 16:13:39 -03:00
dc6668df72 feat(ticket-selector): implement ticket selector component with layout and functionality 2026-08-12 16:13:39 -03:00
f428a88ba8 feat(product-attribute-selector): add show_in_selector property to filter attributes in selector 2026-08-12 16:12:47 -03:00
e21c5e9e9f feat(stock): add handling for unavailable variants in product selection 2026-08-12 14:46:27 -03:00
f7a8b42892 feat(checkout): update checkout process to handle multiple direct items 2026-08-12 14:46:15 -03:00
9f8e42c30b refactor(checkout): simplify direct item handling in checkout process 2026-08-12 14:09:11 -03:00
d45199a0ef feat(store-layout): add dynamic background images to header and footer components 2026-08-12 14:08:56 -03:00
c1da31711f refactor(ticket-selector): restructure actions layout for improved responsiveness 2026-08-12 14:08:37 -03:00
cf61611d76 refactor(variants): standardize variant structure and improve variant handling across components 2026-08-12 10:47:09 -03:00
2c4d22f0b9 feat(ticket-selector): implement ticket selector component with layout and functionality 2026-08-12 10:30:21 -03:00
caa1fddcc1 feat(event-schedules): add border radius and clip-path to table rows for improved styling 2026-08-12 09:30:17 -03:00
5f0c99c395 feat(hero-banner): add border radius and clip-path to event schedule rows for improved styling 2026-08-12 09:27:50 -03:00
832fad19bc fix(auth): update cookie key to use 'shopit.front.auth.token' for consistency 2026-08-12 09:19:16 -03:00
28ed5d71b2 feat(header): update category button padding and header background color to use CSS variable 2026-08-12 09:11:49 -03:00
23a5ac32cc feat: update border styles to use CSS variables for consistency across components 2026-08-12 09:00:13 -03:00
1b19d36e26 feat: add file replacements for local environment and update default configuration for serve 2026-08-12 08:41:10 -03:00
86533b9ab4 feat(modal): enhance modal behavior and styling
- Adjust modal padding to account for scrollbar width.
- Update modal background opacity for better visibility.

style(product-list): refine layout and spacing
- Remove gap in product list rows for a cleaner look.

feat(product-list): improve variant handling
- Update variant mapping to include description and price.

refactor(product-row-card): restructure HTML for better layout
- Simplify structure by combining price and quantity controls.
- Enhance button placement for better UX.

style(product-row-card): improve card styling
- Adjust flex properties for better alignment and spacing.
- Update button styles for consistency.

feat(product-vertical-with-cart-card): enhance variant selection
- Implement variant selector for better user interaction.
- Update price and description display based on selected variant.

test(product-vertical-with-cart-card): add tests for variant selection
- Ensure correct variant description and price are displayed.

fix(environment): update API URL for testing environment
- Change backend URL to point to the QA server.

style(global): update global styles for better readability
- Adjust table styles for improved visibility and aesthetics.
2026-08-11 16:59:01 -03:00
369cfb0a40 feat: update header background color to transparent and modify API URL for QA environment 2026-08-11 16:50:47 -03:00
7b0c9831f1 feat: add validity_time_id to ProductAttributeOption and enhance TicketResponse with validity_times and validity_groups 2026-08-11 13:26:05 -03:00
ede2173a5a feat: update styles for small quantity selector to improve appearance and usability 2026-08-11 10:38:42 -03:00
5d760bfd80 feat: improve modal styling and enhance padding management in modal host component 2026-08-11 10:14:59 -03:00
e3599a48e4 feat: update layout and styles for product components and variant selectors 2026-08-11 10:14:39 -03:00
5d629e27b1 feat: add configurable display options for categories and search bar in store header 2026-08-11 09:55:57 -03:00
563e4634d1 feat: update styles across components for improved layout and responsiveness 2026-08-11 09:55:49 -03:00
c75787a59c feat: enhance hero banner component with event schedule toggle and styling improvements 2026-08-11 08:50:32 -03:00
f6f3a997f7 feat: refactor variant selection to use app-variant-selector for improved maintainability 2026-08-11 08:28:42 -03:00
019c9df23b feat: update modal and cart components for improved user feedback and layout adjustments 2026-08-11 08:24:17 -03:00
9ea85b7924 feat: enhance variant handling in selector and layout components for improved display and selection 2026-08-10 16:34:02 -03:00
0769bb8683 Revert "feat: enhance variant attribute handling across components for improved selection and display"
This reverts commit 56f602d876.
2026-08-10 16:17:09 -03:00
c645feba80 feat: simplify event date labels in product components for clearer display 2026-08-10 14:17:09 -03:00
56f602d876 feat: enhance variant attribute handling across components for improved selection and display 2026-08-10 12:25:58 -03:00
d6bab24e1d feat: implement variant selection in cart and product components for enhanced user experience 2026-08-10 12:08:41 -03:00
dd828d9a4e feat: add event_date_text to Tenant interface and update StoreHomePageComponent to display formatted event dates 2026-08-10 10:42:15 -03:00
7c2a098696 feat: enhance product variant handling and improve layout for better user interaction 2026-08-10 09:58:51 -03:00
1977df6765 feat: enhance product attributes and event date handling for improved user experience and data management 2026-08-07 14:16:02 -03:00
c6ac974459 feat: adjust footer and cart dropdown styles for improved layout and responsiveness 2026-08-06 12:13:51 -03:00
8d88e1bb13 Merge branch 'feature/responsive' into develop 2026-08-06 09:23:37 -03:00
50f986dd2f feat: refactor menu content section for improved structure and responsiveness across account and help pages 2026-08-06 09:22:24 -03:00
393aeec7a5 feat: add menu content section component for improved layout and responsiveness across help and FAQ pages 2026-08-06 09:15:34 -03:00
a012f94198 feat: implement menu page layout component and remove old sidebar components for improved structure and responsiveness 2026-08-06 09:06:19 -03:00
6b9087262a feat: improve responsiveness of checkout payment panel and help sidebar components 2026-08-06 08:53:23 -03:00
4b98df4058 feat: enhance checkout page layout for better responsiveness and adjust cart actions for mobile 2026-08-06 08:49:38 -03:00
2b3e054ccf feat: enhance footer layout for improved responsiveness and structure 2026-08-05 17:07:04 -03:00
1b238cb1bc feat: center align summary content and adjust variant selectors layout 2026-08-05 16:52:10 -03:00
c9f9e6cc3e feat: enhance product vertical card with variant selection and improve store header responsiveness 2026-08-05 16:48:34 -03:00
5da341dde2 feat: improve hero banner responsiveness and enhance event card layout 2026-08-05 16:16:46 -03:00
21786d7b34 feat: enhance store header layout for better responsiveness and add mobile menu functionality 2026-08-05 12:18:50 -03:00
8aea54959f fix: enforce padding and background styles for form control in recover password page 2026-08-05 11:09:30 -03:00
12ca45bdd8 feat: add storeName input to footer component and update copyright display 2026-08-05 10:49:33 -03:00
fa6fc53f8d fix: correct language attribute from 'e' to 'es' in HTML tag 2026-08-05 09:06:51 -03:00
78970573a8 feat: add type property to Product and CatalogItemDetail interfaces; include event_id in PurchaseSummaryResponse and PurchaseDetailResponse 2026-08-05 08:49:52 -03:00
f289e9e2e4 feat: add expires_at to PurchaseDetailResponse and update checkout logic for pending payments 2026-08-03 17:04:07 -03:00
2487546a75 feat: add additionalInfoConfig to tenant interface and implement rendering in store home page 2026-08-03 15:08:33 -03:00
cc884e3cc0 feat: add description_html to tenant heroConfig and update tests for hero banner rendering 2026-08-03 15:08:13 -03:00
ffb586c348 feat: enhance product and tenant interfaces to support event dates, update product detail page to display event date selection 2026-08-03 14:24:42 -03:00
ec18aaf0c5 feat: update WebsiteType and EventConfig interfaces with new properties, enhance tenant creation in tests 2026-08-03 09:09:58 -03:00
941cb5c73c feat: add dates_text to EventConfig and update formattedDates method in HeroBannerComponent 2026-07-30 16:32:37 -03:00
934bd07105 feat: enhance tenant interface with website type and extras, update store home page to utilize new data structure 2026-07-29 15:46:02 -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
227 changed files with 12575 additions and 1642 deletions

2
.vscode/launch.json vendored
View File

@@ -7,7 +7,7 @@
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: start",
"url": "http://localhost:4200/"
"url": "http://localhost:4300/"
},
{
"name": "ng test",

View File

@@ -30,15 +30,15 @@
"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"
"localhost:4300",
"127.0.0.1",
"127.0.0.1:4300"
]
},
"ssr": {
@@ -69,13 +69,30 @@
"local": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
"sourceMap": true,
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.local.ts"
}
]
},
"homo": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.homo.ts"
}
]
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"options": {
"port": 4300
},
"configurations": {
"production": {
"buildTarget": "shopit-front:build:production"
@@ -85,9 +102,12 @@
},
"local": {
"buildTarget": "shopit-front:build:local"
},
"homo": {
"buildTarget": "shopit-front:build:homo"
}
},
"defaultConfiguration": "development"
"defaultConfiguration": "local"
},
"test": {
"builder": "@angular/build:unit-test"
@@ -95,4 +115,4 @@
}
}
}
}
}

25
package-lock.json generated
View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 333 KiB

View File

@@ -0,0 +1,28 @@
import { HttpRequest } from '@angular/common/http';
import { isStoreCatalogRequest } from './app.config';
describe('isStoreCatalogRequest', () => {
it.each([
'http://localhost:8000/api/tenants/demo/catalog',
'http://localhost:8000/api/tenants/demo/catalog?currency=ARS',
'http://localhost:8000/api/tenants/demo/catalog/featured-groups/7/items?page=2',
'http://localhost:8000/api/tenants/demo/catalog-items?q=remera&page=1',
'http://localhost:8000/api/tenants/demo/catalog-items/42?variant_id=3',
])('includes GET %s in the hydration transfer cache', (url) => {
expect(isStoreCatalogRequest(new HttpRequest('GET', url))).toBe(true);
});
it.each([
['POST', 'http://localhost:8000/api/tenants/demo/catalog-items'],
['GET', 'http://localhost:8000/api/tenants/demo/productos'],
['GET', 'http://localhost:8000/api/tenants/demo/categories/7'],
['GET', 'http://localhost:8000/api/tenants/demo/catalog-items/42/variant-options'],
['GET', 'http://localhost:8000/api/tenants/demo/catalogue'],
])('excludes %s %s from the hydration transfer cache', (method, url) => {
const request =
method === 'GET' ? new HttpRequest('GET', url) : new HttpRequest('POST', url, null);
expect(isStoreCatalogRequest(request)).toBe(false);
});
});

View File

@@ -10,12 +10,15 @@ import { provideClientHydration, withHttpTransferCacheOptions } from '@angular/p
import { routes } from './app.routes';
import { authBootstrap } from './core/services/auth/auth-bootstrap';
import { authInterceptor } from './core/services/auth/auth.interceptor';
import { globalLoadingInterceptor } from './core/services/global-loading/global-loading.interceptor';
import { tenantBootstrap } from './core/services/tenant-bootstrap';
function isStoreCatalogRequest(request: HttpRequest<unknown>): boolean {
export function isStoreCatalogRequest(request: HttpRequest<unknown>): boolean {
return (
request.method === 'GET' &&
/\/api\/tenants\/[^/]+\/productos(?:\/\d+)?(?:\?|$)/.test(request.urlWithParams)
/\/api\/tenants\/[^/?#]+\/(?:catalog(?:\/featured-groups\/\d+\/items)?|catalog-items(?:\/\d+)?)\/?(?:[?#]|$)/.test(
request.urlWithParams,
)
);
}
@@ -29,7 +32,10 @@ export const appConfig: ApplicationConfig = {
filter: isStoreCatalogRequest,
}),
),
provideHttpClient(withFetch(), withInterceptors([authInterceptor])),
provideHttpClient(
withFetch(),
withInterceptors([authInterceptor, globalLoadingInterceptor])
),
provideAppInitializer(authBootstrap),
provideAppInitializer(tenantBootstrap),
],

View File

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

View File

@@ -23,7 +23,8 @@ const tenant: Tenant = {
header_bg_color: '#313131',
footer_bg_color: '#313131',
header_logo: 'https://example.com/header.png',
footer_logo: 'https://example.com/footer.png'
footer_logo: 'https://example.com/footer.png',
categories: []
};
function createTenantServiceStub(
@@ -165,6 +166,55 @@ describe('app routes', () => {
]);
});
it('loads the recover password page inside the simple layout', async () => {
const { fixture, router } = await renderAppAt('/recuperar-contrasena');
const compiled = fixture.nativeElement as HTMLElement;
const recoverPasswordPage = compiled.querySelector('app-recover-password-page');
expect(router.url).toBe('/recuperar-contrasena');
expect(compiled.querySelector('.simple-layout')).not.toBeNull();
expect(recoverPasswordPage).not.toBeNull();
expect(recoverPasswordPage?.textContent).toContain('Recuperar contraseña');
expect(recoverPasswordPage?.querySelector('input')?.getAttribute('placeholder')).toBe('Email');
expect(recoverPasswordPage?.textContent).toContain('Recuperar acceso');
expect(recoverPasswordPage?.textContent).toContain('Volver');
});
it('loads the recovery code page and shows the entered email', async () => {
const { fixture, router } = await renderAppAt(
'/recuperar-contrasena/codigo?email=ada%40example.com'
);
const compiled = fixture.nativeElement as HTMLElement;
const codePage = compiled.querySelector('app-recover-password-code-page');
expect(router.url).toBe('/recuperar-contrasena/codigo?email=ada%40example.com');
expect(compiled.querySelector('.simple-layout')).not.toBeNull();
expect(codePage).not.toBeNull();
expect(codePage?.textContent).toContain('ada@example.com');
expect(codePage?.querySelectorAll('app-input')).toHaveLength(4);
expect(codePage?.textContent).toContain('Validar');
});
it('loads the reset password page with the shared password inputs', async () => {
const { fixture, router } = await renderAppAt(
'/recuperar-contrasena/restablecer?email=ada%40example.com&code=1234'
);
const compiled = fixture.nativeElement as HTMLElement;
const resetPage = compiled.querySelector('app-reset-password-page');
const placeholders = Array.from(resetPage?.querySelectorAll('input') ?? []).map((input) =>
input.getAttribute('placeholder')
);
expect(router.url).toBe(
'/recuperar-contrasena/restablecer?email=ada%40example.com&code=1234'
);
expect(compiled.querySelector('.simple-layout')).not.toBeNull();
expect(resetPage).not.toBeNull();
expect(resetPage?.textContent).toContain('Restablecer contraseña');
expect(placeholders).toEqual(['Nueva Contraseña', 'Repetir Nueva Contraseña']);
expect(resetPage?.textContent).toContain('Guardar');
});
it('redirects authenticated users away from /login', async () => {
const { router } = await renderAppAt('/login', createTenantServiceStub(), createAuthServiceStub(true));
@@ -183,10 +233,23 @@ describe('app routes', () => {
expect(compiled.textContent).toContain('No encontramos una tienda para este dominio');
});
it('renders the tenant not found screen at / without redirecting to itself', async () => {
const { fixture, router } = await renderAppAt(
'/',
createTenantServiceStub('not-found', null),
createAuthServiceStub()
);
const compiled = fixture.nativeElement as HTMLElement;
expect(router.url).toBe('/');
expect(compiled.querySelector('.tenant-status')).not.toBeNull();
expect(compiled.textContent).toContain('No encontramos una tienda para este dominio');
});
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

@@ -17,7 +17,7 @@
.tenant-status__card {
width: min(100%, 34rem);
padding: 2rem;
border: 1px solid rgba(32, 32, 32, 0.1);
border: 1px solid var(--border-color);
border-radius: 1.5rem;
background-color: rgba(255, 255, 255, 0.92);
box-shadow: 0 1.25rem 3rem rgba(0, 0, 0, 0.1);
@@ -55,7 +55,7 @@
.tenant-status__spinner {
width: 3rem;
height: 3rem;
border: 0.25rem solid rgba(32, 32, 32, 0.08);
border: 0.25rem solid var(--border-color);
border-top-color: var(--tenant-primary);
border-radius: 999px;
animation: tenant-status-spin 0.8s linear infinite;

View File

@@ -17,6 +17,8 @@ const tenant: Tenant = {
codigo: 'test',
nombre: 'Test Tenant',
dominio: 'localhost',
site_title: 'Test Store',
favicon: 'https://example.com/favicon.png',
primary_color: '#6376F3',
secondary_color: '#A0A0A0',
danger_color: '#FF8888',
@@ -24,7 +26,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) {
@@ -89,6 +92,9 @@ describe('App', () => {
expect(fixture.nativeElement.style.getPropertyValue('--tenant-danger-rgb')).toBe(
'255, 136, 136'
);
expect(document.title).toBe(tenant.site_title);
expect(document.head.querySelector<HTMLLinkElement>('link[rel~="icon"]')?.getAttribute('href'))
.toBe(tenant.favicon);
});
it('renders the tenant not found screen when the tenant is missing', async () => {
@@ -107,5 +113,8 @@ describe('App', () => {
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('No encontramos una tienda para este dominio');
expect(document.title).toBe('ShopitFront');
expect(document.head.querySelector<HTMLLinkElement>('link[rel~="icon"]')?.getAttribute('href'))
.toBe('favicon.ico');
});
});

View File

@@ -1,8 +1,11 @@
import { Component, computed, inject } from '@angular/core';
import { DOCUMENT } from '@angular/common';
import { Component, computed, effect, inject } from '@angular/core';
import { Title } from '@angular/platform-browser';
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 +26,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: {
@@ -51,6 +59,28 @@ function hexToRgb(hex: string): string {
})
export class App {
private readonly tenantService = inject(TenantService);
private readonly document = inject(DOCUMENT);
private readonly title = inject(Title);
constructor() {
effect(() => {
const tenant = this.tenantService.tenant();
const siteTitle = tenant?.site_title?.trim() || 'ShopitFront';
const faviconHref = tenant?.favicon || 'favicon.ico';
this.title.setTitle(siteTitle);
let favicon = this.document.head.querySelector<HTMLLinkElement>('link[rel~="icon"]');
if (!favicon) {
favicon = this.document.createElement('link');
favicon.setAttribute('rel', 'icon');
this.document.head.appendChild(favicon);
}
favicon.setAttribute('href', faviconHref);
});
}
protected readonly status = this.tenantService.status;
@@ -88,4 +118,4 @@ export class App {
this.tenantService.tenant()?.footer_bg_color ??
DEFAULT_TENANT_BRANDING.footerBgColor
);
}
}

View File

@@ -0,0 +1,76 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter, Router, UrlTree } from '@angular/router';
import { Tenant } from '../services/tenant.interface';
import { TenantService } from '../services/tenant.service';
import { hasMenuGuard } from './menu.guard';
const tenant: Tenant = {
id: 1,
codigo: 'test',
nombre: 'Test Tenant',
dominio: 'localhost',
primary_color: '#6376F3',
secondary_color: '#A0A0A0',
danger_color: '#FF8888',
success_color: '#198754',
header_bg_color: '#313131',
footer_bg_color: '#313131',
header_logo: 'https://example.com/header.png',
footer_logo: 'https://example.com/footer.png',
categories: [],
menues: [
{
id: 1,
code: 'index',
label: 'Inicio',
parent_menu_code: null,
content_type: 'dynamic',
route: '/',
submenues: [],
},
],
};
function runGuard(menuCode: string, url: string, currentTenant: Tenant | null) {
TestBed.configureTestingModule({
providers: [
provideRouter([]),
{
provide: TenantService,
useValue: { tenant: () => currentTenant },
},
],
});
return TestBed.runInInjectionContext(() =>
hasMenuGuard(menuCode)(null as never, { url } as never),
);
}
describe('hasMenuGuard', () => {
beforeEach(() => {
TestBed.resetTestingModule();
});
it('allows a route exposed by the tenant menu', () => {
expect(runGuard('index', '/', tenant)).toBe(true);
});
it('cancels navigation when the tenant is missing', () => {
expect(runGuard('index', '/', null)).toBe(false);
});
it('redirects a missing menu route to the store root', () => {
const result = runGuard('checkout', '/checkout', tenant);
expect(result instanceof UrlTree).toBe(true);
expect(TestBed.inject(Router).serializeUrl(result as UrlTree)).toBe('/');
});
it('allows the store root when its menu is missing to avoid a self-redirect', () => {
const tenantWithoutMenus = { ...tenant, menues: [] };
expect(runGuard('index', '/', tenantWithoutMenus)).toBe(true);
});
});

View File

@@ -4,19 +4,19 @@ import { findMenu } from '../services/menu.utils';
import { TenantService } from '../services/tenant.service';
export const hasMenuGuard = (menuCode: string): CanActivateFn => {
return () => {
return (_route, state) => {
const tenantService = inject(TenantService);
const router = inject(Router);
const tenant = tenantService.tenant();
if (!tenant) {
return router.createUrlTree(['/']);
return false;
}
const hasMenu = findMenu(tenant.menues ?? [], menuCode) !== undefined;
if (hasMenu) {
if (hasMenu || state.url === '/') {
return true;
}

View File

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

View File

@@ -1,23 +1,30 @@
.store-layout__footer {
background-color: var(--tenant-footer-bg);
background-repeat: no-repeat;
background-position: center;
background-size: cover;
}
.store-layout__mobile-divider {
display: none;
}
.store-layout__brand-slot {
width: min(100%, 12rem);
width: min(100%, 230px);
min-width: 10rem;
}
.store-layout__brand-logo-shell {
position: relative;
width: min(100%, 12rem);
height: 2.75rem;
width: min(100%, 230px);
height: 65px;
}
.store-layout__brand-logo {
display: block;
width: 100%;
height: 100%;
max-height: 2.75rem;
max-height: 65px;
object-fit: contain;
object-position: left center;
}
@@ -42,7 +49,7 @@
place-items: center;
width: 2.75rem;
height: 2.75rem;
border: 1px dashed rgba(255, 255, 255, 0.35);
border: 1px dashed var(--border-color);
border-radius: 999px;
font-size: 1.05rem;
font-weight: 700;
@@ -63,7 +70,7 @@
.store-layout__footer-link {
width: fit-content;
color: rgba(255, 255, 255, 0.72);
color: #ffffff;
text-decoration: none;
&:hover,
@@ -92,11 +99,97 @@
}
@media (max-width: 767.98px) {
.store-layout__footer-content {
padding: 1.45rem 2.5rem 1.8rem !important;
font-size: 13px;
}
.store-layout__footer-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0;
margin: 0;
}
.store-layout__footer-grid > * {
margin-top: 0;
padding-right: 0;
padding-left: 0;
}
.store-layout__links-column {
order: 1;
width: auto;
padding-bottom: 1.65rem;
line-height: 1.25;
&:nth-of-type(even) {
padding-left: 0.65rem;
}
> div {
gap: 0.5rem !important;
}
}
.store-layout__mobile-divider {
display: block;
order: 2;
grid-column: 1 / -1;
width: 100%;
border-top: 1px solid var(--border-color);
}
.store-layout__brand-column,
.store-layout__social-column {
order: 3;
width: auto;
padding-top: 2rem;
}
.store-layout__brand-slot {
width: min(100%, 150px);
min-width: 0;
}
.store-layout__brand-logo {
max-height: 55px;
}
.store-layout__brand-logo-shell {
width: min(100%, 150px);
height: 55px;
}
.store-layout__brand-text {
font-size: 1.45rem;
font-size: 13px;
}
.store-layout__social-column {
align-items: center;
}
.store-layout__social-list {
justify-content: flex-end;
gap: 0 !important;
}
.store-layout__contact {
order: 4;
grid-column: 1 / -1;
width: auto;
gap: 1.25rem !important;
padding-top: 0.9rem;
text-align: center;
}
.store-layout__contact-details {
justify-items: center;
gap: 0.4rem !important;
}
.store-layout__copyright {
font-size: 11px;
line-height: 1.3;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,16 +1,21 @@
.store-layout__header {
color: #ffffff;
background-color: var(--tenant-header-bg);
box-shadow: inset 0 -1px 0 rgba(255, 255, 255, 0.08);
background-repeat: no-repeat;
background-position: center;
background-size: cover;
}
.store-layout__header-main {
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
border-bottom: 1px solid var(--border-color);
}
.store-layout__brand-slot {
min-width: 150px;
max-width:40px;
}
.store-layout__brand-column {
max-width: 12rem;
}
.store-layout__brand-logo-shell {
@@ -41,14 +46,40 @@
animation: store-header-logo-skeleton 1.2s ease-in-out infinite;
}
.store-layout__search {
width: min(100%, 260px);
.store-layout__search-wrapper {
width: 100%;
min-width: 0;
}
@media (min-width: 768px) {
.store-layout__search-wrapper {
width: min(100%, 260px);
}
.store-layout__actions-column {
padding-left: 0;
}
::ng-deep .store-layout__tickets-btn-host {
flex: 0 0 clamp(8rem, 15vw, 13.5rem);
width: clamp(8rem, 15vw, 13.5rem);
}
}
.store-layout__search {
width: 100%;
}
.store-layout__search-error {
margin-top: 0.25rem;
font-size: 0.75rem;
line-height: 1.2;
color: var(--tenant-danger, #dc3545);
}
.store-layout__search-input {
color: #666666;
border-color: #cccccc;
border-color: var(--border-color);
background-color: rgba(255, 255, 255, 0.96);
}
@@ -57,21 +88,21 @@
}
.store-layout__search-input:focus {
border-color: #cccccc;
border-color: var(--border-color);
box-shadow: none;
outline: none;
}
.store-layout__search-button {
color: #a0a0a0;
border-color: #cccccc;
border-color: var(--border-color);
background-color: rgba(255, 255, 255, 0.96);
}
.store-layout__search-button:hover,
.store-layout__search-button:focus-visible {
color: #8a8a8a;
border-color: #cccccc;
border-color: var(--border-color);
background-color: #ffffff;
}
@@ -100,6 +131,19 @@
color: #8a8a8a;
}
.store-layout__category-menu {
position: relative;
display: inline-flex;
flex-direction: column;
}
.store-layout__category-menu app-category-dropdown {
position: absolute;
z-index: 1050;
top: 100%;
left: 0;
}
@keyframes store-header-logo-skeleton {
from {
background-position: 200% 0;
@@ -113,5 +157,70 @@
@media (max-width: 767.98px) {
.store-layout__brand-slot {
min-width: 0;
max-width: 150px;
}
.store-layout__actions-column {
flex: 1 1 auto;
width: auto;
min-width: 0;
}
::ng-deep .store-layout__tickets-btn-host {
flex: 1 1 0;
width: auto;
min-width: 0;
max-width: 50vw;
}
.store-layout__mobile-separator {
border-color: var(--border-color);
opacity: 1;
margin: 0 -1rem;
}
}
@media (max-width: 399.98px) {
.store-layout__brand-column {
flex: 1 1 0;
width: auto;
min-width: 0;
max-width: none;
padding-right: 0.25rem;
}
.store-layout__brand-slot {
width: 100%;
max-width: 6.5rem;
}
.store-layout__actions-column {
flex: 1 1 auto;
width: auto;
min-width: 0;
margin-left: 0 !important;
padding-left: 0.25rem;
gap: 0.25rem !important;
}
::ng-deep .store-layout__tickets-btn-host .btn {
padding-inline: 0.75rem;
font-size: 0.875rem;
}
}
.store-layout__mobile-dropdown-menu {
top: 100%;
min-width: 220px;
z-index: 1050;
}
.store-layout__mobile-dropdown-item {
cursor: pointer;
color: #8a8a8a;
&:hover,
&:focus-visible {
color: #666666;
}
}

View File

@@ -1,44 +1,77 @@
import { Component, ElementRef, HostListener, inject, input, output, signal } from '@angular/core';
import { RouterLink } from '@angular/router';
import { Router, RouterLink } from '@angular/router';
import { AuthUser } from '../../../services/auth/auth.interfaces';
import { CartIconComponent } from '../../../../shared/components/cart-icon/cart-icon.component';
import { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.component';
import { UserDropdownComponent } from './user-dropdown/user-dropdown.component';
import { Menu } from '../../../services/tenant.interface';
import { ButtonComponent } from '../../../../shared/components/button/button.component';
import { ReactiveFormsModule, FormControl } from '@angular/forms';
import { Category } from '../../../services/tenant.interface';
import { CategoryDropdownComponent } from './category-dropdown/category-dropdown.component';
@Component({
selector: 'app-store-header',
imports: [CartIconComponent, IconButtonComponent, RouterLink, UserDropdownComponent],
imports: [
ButtonComponent,
CartIconComponent,
IconButtonComponent,
RouterLink,
ReactiveFormsModule,
UserDropdownComponent,
CategoryDropdownComponent,
],
templateUrl: './store-header.component.html',
styleUrl: './store-header.component.scss'
styleUrl: './store-header.component.scss',
})
export class StoreHeaderComponent {
private readonly elementRef = inject(ElementRef<HTMLElement>);
private readonly router = inject(Router);
readonly logoUrl = input<string | null>(null);
readonly backgroundImageUrl = input<string | null>(null);
readonly cartQuantity = input<number>(0);
readonly user = input<AuthUser | null>(null);
readonly isAuthenticated = input<boolean>(false);
readonly accountNavigationMenus = input<readonly Menu[]>([]);
readonly ticketsMenu = input<Menu | null>(null);
readonly categories = input<readonly Category[]>([]);
readonly displayCategories = input(true);
readonly displaySeachBar = input(true);
readonly displayCart = input(true);
readonly cartClick = output<void>();
readonly ticketsClick = output<void>();
readonly loginClick = output<void>();
readonly logoutClick = output<void>();
readonly searchSubmit = output<string>();
readonly categorySelect = output<Category>();
protected readonly isUserDropdownOpen = signal(false);
protected readonly isCategoryDropdownOpen = signal(false);
protected readonly isMobileMenuOpen = signal(false);
protected readonly minSearchLength = 3;
protected readonly showSearchError = signal(false);
protected readonly searchControl = new FormControl('', { nonNullable: true });
@HostListener('document:click', ['$event'])
protected onDocumentClick(event: MouseEvent): void {
if (!this.isUserDropdownOpen()) {
if (!this.isUserDropdownOpen() && !this.isCategoryDropdownOpen() && !this.isMobileMenuOpen()) {
return;
}
const target = event.target;
if (target instanceof Node && !this.elementRef.nativeElement.contains(target)) {
this.isUserDropdownOpen.set(false);
this.isCategoryDropdownOpen.set(false);
this.isMobileMenuOpen.set(false);
}
}
@HostListener('document:keydown.escape')
protected onEscapeKey(): void {
this.isUserDropdownOpen.set(false);
this.isCategoryDropdownOpen.set(false);
this.isMobileMenuOpen.set(false);
}
protected toggleUserDropdown(): void {
@@ -59,4 +92,50 @@ export class StoreHeaderComponent {
this.isUserDropdownOpen.set(false);
this.logoutClick.emit();
}
protected submitSearch(event?: Event): void {
event?.preventDefault();
const term = this.searchControl.value.trim();
if (term.length < this.minSearchLength) {
this.showSearchError.set(true);
return;
}
this.showSearchError.set(false);
this.searchSubmit.emit(term);
}
protected clearSearchError(): void {
this.showSearchError.set(false);
}
protected toggleCategoryDropdown(): void {
this.isCategoryDropdownOpen.update((isOpen) => !isOpen);
}
protected onCategorySelect(category: Category): void {
this.isCategoryDropdownOpen.set(false);
this.isMobileMenuOpen.set(false);
this.categorySelect.emit(category);
}
protected toggleMobileMenu(): void {
this.isMobileMenuOpen.update((isOpen) => !isOpen);
}
protected onMobileAccountClick(): void {
this.isMobileMenuOpen.set(false);
if (!this.isAuthenticated()) {
this.loginClick.emit();
} else {
const menus = this.accountNavigationMenus();
if (menus.length > 0) {
this.router.navigate([menus[0].route]);
} else {
this.router.navigate(['/cuenta']);
}
}
}
}

View File

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

View File

@@ -10,7 +10,7 @@
.user-dropdown {
display: grid;
background: #ffffff;
border: 1px solid rgba(32, 32, 32, 0.1);
border: 1px solid var(--border-color);
border-radius: 8px;
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.16);
padding: 0.75rem;

View File

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

View File

@@ -1,15 +1,26 @@
<div class="store-layout d-flex flex-column flex-grow-1">
<app-store-header
ngSkipHydration
[cartQuantity]="cartQuantity()"
[logoUrl]="tenant()?.header_logo ?? null"
[backgroundImageUrl]="tenant()?.header_bg_image ?? null"
[user]="user()"
[isAuthenticated]="isAuthenticated()"
[accountNavigationMenus]="accountNavigationMenus()"
[ticketsMenu]="ticketsMenu() ?? null"
[categories]="tenant()?.categories ?? []"
[displayCategories]="tenant()?.display_categories ?? true"
[displaySeachBar]="tenant()?.display_seach_bar ?? true"
[displayCart]="displayCart()"
(cartClick)="isCartOpen.set(!isCartOpen())"
(ticketsClick)="onTicketsClick()"
(loginClick)="onLoginClick()"
(logoutClick)="onLogoutClick()"
(searchSubmit)="onSearch($event)"
(categorySelect)="onCategorySelect($event)"
/>
@if (isCartOpen()) {
@if (displayCart() && isCartOpen()) {
<div class="store-layout__cart-overlay" (click)="isCartOpen.set(false)"></div>
<div class="store-layout__cart-dropdown card border-0 shadow-lg">
<app-cart
@@ -21,8 +32,17 @@
[backgroundColor]="'#ffffff'"
(closed)="isCartOpen.set(false)"
>
<app-button variant="secondary" class="flex-grow-1" (click)="isCartOpen.set(false)">Seguir comprando</app-button>
<app-button variant="primary" class="flex-grow-1" (click)="onCheckoutClick()">Comprar</app-button>
<app-button variant="secondary" class="flex-grow-1" (click)="isCartOpen.set(false)"
>Seguir comprando</app-button
>
<app-button
variant="primary"
class="flex-grow-1"
[disabled]="isCreatingPurchase()"
(click)="onCheckoutClick()"
>
Comprar
</app-button>
</app-cart>
</div>
}
@@ -40,6 +60,8 @@
[footerSections]="footerSections()"
[socialMedia]="tenant()?.social_media ?? []"
[logoUrl]="tenant()?.footer_logo ?? null"
[backgroundImageUrl]="tenant()?.footer_bg_image ?? null"
[storeName]="tenant()?.nombre ?? null"
(logoutClick)="onLogoutClick()"
/>
</div>

View File

@@ -21,14 +21,15 @@
.store-layout__cart-dropdown {
position: absolute;
top: 76px;
top: 100px;
right: calc((100% - 1320px) / 2 + 1.5rem);
width: 100%;
max-width: 400px;
max-height: calc(100vh - 120px);
z-index: 1050;
background-color: #ffffff;
border-radius: 8px;
border: 1px solid rgba(0, 0, 0, 0.08);
border: 1px solid var(--border-color);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.08);
overflow: hidden;
animation: store-layout-slide-down 0.2s ease-out;
@@ -40,6 +41,7 @@
right: 1rem;
left: 1rem;
width: auto;
max-width: none;
}
}

View File

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

View File

@@ -6,9 +6,12 @@ import { StoreFooterComponent, StoreFooterSection } from './store-footer/store-f
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 { CartItem, CartItemVariantValue } from '../../services/cart/cart.interface';
import { AuthService } from '../../services/auth/auth.service';
import { findMenu, getMenuLabel } from '../../services/menu.utils';
import { findMenu } from '../../services/menu.utils';
import { CheckoutService } from '../../services/checkout.service';
import { ToastService } from '../../services/toast.service';
import { Category } from '../../services/tenant.interface';
@Component({
selector: 'app-store-layout',
@@ -26,9 +29,13 @@ 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 displayCart = computed(() => this.tenant()?.display_cart ?? true);
protected readonly cartSubtotal = computed(() => {
const cart = this.cartService.cart();
@@ -63,6 +70,18 @@ export class StoreLayoutComponent implements OnInit {
});
}
const variants = item.product?.variants ?? [];
const selectedVariant = item.product?.variants?.find(
(variant) => variant.id === item.variant_id,
);
if (selectedVariant) {
attributes = Object.entries(selectedVariant.values).map(([label, value]) => ({
label: this.formatAttributeLabel(label),
value: this.formatAttributeValue(value),
}));
}
return {
cartItemId: item.id,
imageUrl: item.product?.imagen ?? null,
@@ -72,13 +91,43 @@ export class StoreLayoutComponent implements OnInit {
discountPercentage: null,
attributes,
quantity: item.cantidad,
variantId: item.variant_id,
variants,
};
}
private formatAttributeLabel(value: string): string {
const label = value.replace(/[_-]+/g, ' ');
return label.charAt(0).toUpperCase() + label.slice(1);
}
private formatAttributeValue(value: CartItemVariantValue): string {
const values = Array.isArray(value) ? value : [value];
return values.map((item) => (typeof item === 'string' ? item : item.label)).join(', ');
}
protected readonly currentYear = new Date().getFullYear();
protected readonly tenant = this.tenantService.tenant;
protected readonly user = this.authService.user;
protected readonly isAuthenticated = this.authService.isAuthenticated;
protected readonly ticketsMenu = computed(() =>
findMenu(this.tenant()?.menues ?? [], 'account.tickets'),
);
protected readonly accountNavigationMenus = computed(() => {
const accountMenu = findMenu(this.tenant()?.menues ?? [], 'account');
if (!accountMenu) {
return [];
}
const profile = accountMenu.submenues.find((menu) => menu.code === 'account.profile');
const purchases = accountMenu.submenues.find((menu) => menu.code === 'account.purchases');
return [
...(profile ? [{ ...accountMenu, route: profile.route }] : []),
...(purchases ? [purchases] : []),
];
});
protected readonly cartQuantity = computed(() => {
const cart = this.cartService.cart();
@@ -96,35 +145,109 @@ export class StoreLayoutComponent implements OnInit {
void this.router.navigate(['/login']);
}
protected onLogoutClick(): void {
protected onSearch(term: string): void {
void this.router.navigate(['/buscar'], {
queryParams: { q: term, page: 1 },
});
}
protected onCategorySelect(category: Category): void {
void this.router.navigate(['/categoria', category.id], {
queryParams: { page: 1 },
});
}
protected onTicketsClick(): void {
const route = this.ticketsMenu()?.route;
if (route) {
void this.router.navigate([route]);
}
}
protected async onLogoutClick(): Promise<void> {
const isLeavingCheckout = this.router.url.startsWith('/checkout');
// Checkout must be left while the authenticated session is still valid so
// its CanDeactivate guard can cancel the pending purchase.
if (isLeavingCheckout) {
const navigationSucceeded = await this.router.navigate(['/']);
if (!navigationSucceeded) {
return;
}
}
this.authService.logout().subscribe({
next: () => void this.router.navigate(['/login']),
next: () => {
this.cartService.clearCart();
this.isCartOpen.set(false);
if (!isLeavingCheckout) {
void this.router.navigate(['/']);
}
},
error: (err) => console.error('Error logging out', err),
});
}
protected onCheckoutClick(): void {
this.isCartOpen.set(false);
void this.router.navigate(['/checkout']);
protected async onCheckoutClick(): Promise<void> {
if (this.isCreatingPurchase()) {
return;
}
const cart = this.cartService.cart();
const tenant = this.tenantService.tenant();
const user = this.authService.user();
if (!user) {
this.isCartOpen.set(false);
void this.router.navigate(['/login'], { queryParams: { returnUrl: '/' } });
return;
}
if (!cart?.id || !tenant) {
this.toastService.danger('No hay un carrito activo para iniciar la compra.');
return;
}
this.isCreatingPurchase.set(true);
try {
const purchase = await this.checkoutService.startCheckout(tenant.codigo, {
cart_id: cart.id,
});
this.cartService.clearCart();
this.isCartOpen.set(false);
await this.router.navigate(['/checkout'], {
queryParams: { purchase: purchase.id },
});
} catch (error) {
console.error('Failed to create cart purchase:', error);
this.toastService.danger('No se pudo iniciar la compra.');
} finally {
this.isCreatingPurchase.set(false);
}
}
protected readonly footerSections = computed<StoreFooterSection[]>(() => {
const helpSubmenues =
findMenu(this.tenant()?.menues ?? [], 'help')?.submenues ?? [];
const helpSubmenues = findMenu(this.tenant()?.menues ?? [], 'help')?.submenues ?? [];
return [
{
heading: 'Cuenta',
links: [
{ label: 'Mi cuenta', routerLink: '/mi-cuenta/datos-personales' },
{ label: 'Mis compras', routerLink: '/mi-cuenta/compras' },
...this.accountNavigationMenus().map((menu) => ({
label: menu.label,
routerLink: menu.route,
})),
{ label: 'Cerrar sesion', action: 'logout' },
],
},
{
heading: 'Ayuda',
links: helpSubmenues.map((menu) => ({
label: getMenuLabel(menu),
label: menu.label,
routerLink: menu.route,
})),
},

View File

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

View File

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

View File

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

View File

@@ -2,8 +2,10 @@ import { PLATFORM_ID, TransferState } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TimeoutError } from 'rxjs';
import { environment } from '../../../../environments/environment';
import { BOOTSTRAP_REQUEST_TIMEOUT_MS } from '../bootstrap-request-timeout';
import { AuthService } from './auth.service';
import { CookieService } from '../cookie/cookie.service';
import { TenantService } from '../tenant.service';
@@ -19,8 +21,12 @@ describe('AuthService', () => {
function createCookieServiceStub() {
return {
get: (name: string) => cookieStore[name] || null,
set: (name: string, value: string) => { cookieStore[name] = value; },
delete: (name: string) => { delete cookieStore[name]; }
set: (name: string, value: string) => {
cookieStore[name] = value;
},
delete: (name: string) => {
delete cookieStore[name];
}
};
}
@@ -31,6 +37,10 @@ describe('AuthService', () => {
provideHttpClientTesting(),
AuthService,
{ provide: CookieService, useValue: createCookieServiceStub() },
{
provide: TenantService,
useValue: { getTenant: () => ({ codigo: 'tenant-test' }) }
},
TransferState
]
});
@@ -44,6 +54,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',
@@ -58,13 +74,13 @@ describe('AuthService', () => {
expect(service.token()).toBe('plain-text-token');
expect(service.user()?.email).toBe('ada@example.com');
expect(service.isAuthenticated()).toBe(true);
expect(cookieStore['shopit.auth.token']).toBe('plain-text-token');
expect(cookieStore['shopit.front.auth.token']).toBe('plain-text-token');
httpController.verify();
});
it('hydrates token from cookies and loads the current user during bootstrap', async () => {
cookieStore['shopit.auth.token'] = 'persisted-token';
cookieStore['shopit.front.auth.token'] = 'persisted-token';
TestBed.configureTestingModule({
providers: [
@@ -96,8 +112,49 @@ describe('AuthService', () => {
httpController.verify();
});
it('sends tenant and credentials when completing Google login', () => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
AuthService,
{ provide: CookieService, useValue: createCookieServiceStub() },
{
provide: TenantService,
useValue: { getTenant: () => ({ codigo: 'tenant-test' }) }
},
TransferState
]
});
const service = TestBed.inject(AuthService);
const httpController = TestBed.inject(HttpTestingController);
service.completeGoogleLogin('oauth-code').subscribe();
const request = httpController.expectOne(`${environment.url}auth/google/exchange`);
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual({
oauth_code: 'oauth-code',
tenant_codigo: 'tenant-test'
});
expect(request.request.withCredentials).toBe(true);
request.flush({
message: 'Sesion iniciada correctamente.',
token: 'google-token',
token_type: 'Bearer',
user: {
id: 1,
nombre_apellido: 'Ada Lovelace',
email: 'ada@example.com'
}
});
httpController.verify();
});
it('clears an expired session when /me returns 401', async () => {
cookieStore['shopit.auth.token'] = 'expired-token';
cookieStore['shopit.front.auth.token'] = 'expired-token';
TestBed.configureTestingModule({
providers: [
@@ -121,11 +178,48 @@ describe('AuthService', () => {
expect(service.user()).toBeNull();
expect(service.token()).toBeNull();
expect(service.isAuthenticated()).toBe(false);
expect(cookieStore['shopit.auth.token']).toBeUndefined();
expect(cookieStore['shopit.front.auth.token']).toBeUndefined();
httpController.verify();
});
it('propagates a /me timeout without clearing the local session', async () => {
cookieStore['shopit.front.auth.token'] = 'valid-token';
vi.useFakeTimers();
try {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
AuthService,
{ provide: CookieService, useValue: createCookieServiceStub() },
{ provide: BOOTSTRAP_REQUEST_TIMEOUT_MS, useValue: 25 },
TransferState,
],
});
const service = TestBed.inject(AuthService);
const httpController = TestBed.inject(HttpTestingController);
const bootstrapPromise = service.bootstrap();
const rejection = expect(bootstrapPromise).rejects.toBeInstanceOf(TimeoutError);
const request = httpController.expectOne(`${environment.url}me`);
await vi.advanceTimersByTimeAsync(25);
await rejection;
expect(request.cancelled).toBe(true);
expect(service.user()).toBeNull();
expect(service.token()).toBe('valid-token');
expect(cookieStore['shopit.front.auth.token']).toBe('valid-token');
httpController.verify();
} finally {
vi.useRealTimers();
}
});
it('registers without creating a session', () => {
TestBed.configureTestingModule({
providers: [
@@ -174,6 +268,121 @@ describe('AuthService', () => {
httpController.verify();
});
it('requests a password reset and exposes the final HTTP status', () => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
AuthService,
{ provide: CookieService, useValue: createCookieServiceStub() },
{
provide: TenantService,
useValue: { getTenant: () => ({ codigo: 'tenant-test' }) }
},
TransferState
]
});
const service = TestBed.inject(AuthService);
const httpController = TestBed.inject(HttpTestingController);
service.requestPasswordReset('ada@example.com').subscribe((response) => {
expect(response.status).toBe(202);
expect(response.body?.status).toBe('pending');
});
const request = httpController.expectOne(`${environment.url}password/reset-attempts`);
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual({
email: 'ada@example.com',
tenant_codigo: 'tenant-test'
});
request.flush(
{
message: 'Si el email está registrado, recibirás un código.',
status: 'pending'
},
{ status: 202, statusText: 'Accepted' }
);
httpController.verify();
});
it('validates a password reset code and exposes its final status', () => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
AuthService,
{ provide: CookieService, useValue: createCookieServiceStub() },
TransferState
]
});
const service = TestBed.inject(AuthService);
const httpController = TestBed.inject(HttpTestingController);
service.validatePasswordResetCode('ada@example.com', '0123').subscribe((response) => {
expect(response.status).toBe(200);
expect(response.body?.status).toBe('validated');
});
const request = httpController.expectOne(`${environment.url}password/reset-attempts/validate`);
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual({
email: 'ada@example.com',
codigo: '0123'
});
request.flush(
{
message: 'Código validado correctamente.',
status: 'validated'
},
{ status: 200, statusText: 'OK' }
);
httpController.verify();
});
it('resets the password and exposes the consumed attempt status', () => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
AuthService,
{ provide: CookieService, useValue: createCookieServiceStub() },
TransferState
]
});
const service = TestBed.inject(AuthService);
const httpController = TestBed.inject(HttpTestingController);
const payload = {
email: 'ada@example.com',
codigo: '0123',
password: 'NewSecret!456',
password_confirmation: 'NewSecret!456'
};
service.resetPassword(payload).subscribe((response) => {
expect(response.status).toBe(200);
expect(response.body?.status).toBe('used');
});
const request = httpController.expectOne(`${environment.url}password/reset`);
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual(payload);
request.flush(
{
message: 'Contraseña modificada correctamente.',
status: 'used'
},
{ status: 200, statusText: 'OK' }
);
httpController.verify();
});
it('propagates logout errors without clearing the local session', () => {
TestBed.configureTestingModule({
providers: [
@@ -181,6 +390,10 @@ describe('AuthService', () => {
provideHttpClientTesting(),
AuthService,
{ provide: CookieService, useValue: createCookieServiceStub() },
{
provide: TenantService,
useValue: { getTenant: () => ({ codigo: 'tenant-test' }) }
},
TransferState
]
});
@@ -210,7 +423,7 @@ describe('AuthService', () => {
expect(service.token()).toBe('plain-text-token');
expect(service.user()?.email).toBe('ada@example.com');
expect(cookieStore['shopit.auth.token']).toBe('plain-text-token');
expect(cookieStore['shopit.front.auth.token']).toBe('plain-text-token');
httpController.verify();
});

View File

@@ -1,34 +1,40 @@
import { DOCUMENT } from '@angular/common';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { HttpErrorResponse, HttpResponse } from '@angular/common/http';
import { isPlatformBrowser, isPlatformServer } from '@angular/common';
import { computed, inject, Injectable, PLATFORM_ID, signal, TransferState, makeStateKey } from '@angular/core';
import { firstValueFrom, map, Observable, of, tap } from 'rxjs';
import { firstValueFrom, map, Observable, of, tap, timeout } from 'rxjs';
import { environment } from '../../../../environments/environment';
import { BaseApiService } from '../base-api.service';
import {
AuthUser,
LoginPayload,
LoginResponse,
RegisterPayload,
RegisterResponse,
UpdateProfilePayload
ResetPasswordPayload,
ResetPasswordAttemptResponse,
ResetPasswordResponse,
UpdateProfilePayload,
ValidateResetPasswordAttemptResponse
} from './auth.interfaces';
import { CookieService } from '../cookie/cookie.service';
import { TenantService } from '../tenant.service';
import { BOOTSTRAP_REQUEST_TIMEOUT_MS } from '../bootstrap-request-timeout';
const AUTH_TOKEN_COOKIE_KEY = 'shopit.auth.token';
const AUTH_TOKEN_COOKIE_KEY = 'shopit.front.auth.token';
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 bootstrapRequestTimeoutMs = inject(BOOTSTRAP_REQUEST_TIMEOUT_MS);
private readonly userState = signal<AuthUser | null>(null);
private readonly tokenState = signal<string | null>(null);
@@ -38,18 +44,70 @@ export class AuthService {
readonly isAuthenticated = computed(() => this.tokenState() !== null);
login(payload: LoginPayload): Observable<AuthUser> {
return this.http.post<LoginResponse>(`${environment.url}login`, payload).pipe(
tap((response) => this.applyAuthenticatedState(response.token, response.user)),
map((response) => response.user)
);
const tenant = this.tenantService.getTenant();
if (!tenant) {
throw new Error('No se pudo resolver el tenant activo.');
}
return this.http
.post<LoginResponse>(
`${environment.url}login`,
{ ...payload, tenant_codigo: tenant.codigo },
{
withCredentials: true
}
)
.pipe(
tap((response) => this.applyAuthenticatedState(response.token, response.user)),
map((response) => response.user)
);
}
register(payload: RegisterPayload): Observable<RegisterResponse> {
const tenantCode = this.tenantService.getTenant()?.codigo;
return this.http.post<RegisterResponse>(`${environment.url}register`, {
...payload,
...(tenantCode ? { tenant_codigo: tenantCode } : {})
return this.http.post<RegisterResponse>(
`${environment.url}register`,
{
...payload,
...(tenantCode ? { tenant_codigo: tenantCode } : {})
},
);
}
requestPasswordReset(
email: string
): Observable<HttpResponse<ResetPasswordAttemptResponse>> {
const tenantCode = this.tenantService.getTenant()?.codigo;
return this.http.post<ResetPasswordAttemptResponse>(
`${environment.url}password/reset-attempts`,
{
email,
...(tenantCode ? { tenant_codigo: tenantCode } : {})
},
{
observe: 'response'
}
);
}
validatePasswordResetCode(
email: string,
codigo: string
): Observable<HttpResponse<ValidateResetPasswordAttemptResponse>> {
return this.http.post<ValidateResetPasswordAttemptResponse>(
`${environment.url}password/reset-attempts/validate`,
{ email, codigo },
{
observe: 'response'
}
);
}
resetPassword(payload: ResetPasswordPayload): Observable<HttpResponse<ResetPasswordResponse>> {
return this.http.post<ResetPasswordResponse>(`${environment.url}password/reset`, payload, {
observe: 'response'
});
}
@@ -68,16 +126,29 @@ export class AuthService {
}
completeGoogleLogin(oauthCode: string): Observable<AuthUser> {
return this.http.post<LoginResponse>(`${environment.url}auth/google/exchange`, { oauth_code: oauthCode }).pipe(
tap((response) => this.applyAuthenticatedState(response.token, response.user)),
map((response) => response.user)
);
const tenant = this.tenantService.getTenant();
if (!tenant) {
throw new Error('No se pudo resolver el tenant activo.');
}
return this.http
.post<LoginResponse>(
`${environment.url}auth/google/exchange`,
{ oauth_code: oauthCode, tenant_codigo: tenant.codigo },
{
withCredentials: true
}
)
.pipe(
tap((response) => this.applyAuthenticatedState(response.token, response.user)),
map((response) => response.user)
);
}
updateProfile(payload: UpdateProfilePayload): Observable<AuthUser> {
return this.http.put<AuthUser>(`${environment.url}me`, payload).pipe(
tap((user) => this.userState.set(user))
);
return this.http
.put<AuthUser>(`${environment.url}me`, payload)
.pipe(tap((user) => this.userState.set(user)));
}
logout(): Observable<void> {
@@ -86,9 +157,9 @@ export class AuthService {
return of(void 0);
}
return this.http.post<void>(`${environment.url}logout`, {}).pipe(
tap(() => this.clearSession())
);
return this.http
.post<void>(`${environment.url}logout`, {})
.pipe(tap(() => this.clearSession()));
}
async bootstrap(): Promise<void> {
@@ -106,7 +177,9 @@ export class AuthService {
}
try {
const user = await firstValueFrom(this.loadCurrentUser());
const user = await firstValueFrom(
this.loadCurrentUser().pipe(timeout(this.bootstrapRequestTimeoutMs)),
);
if (isPlatformServer(this.platformId)) {
this.transferState.set(AUTH_USER_SSR_STATE_KEY, user);
@@ -127,9 +200,9 @@ export class AuthService {
}
loadCurrentUser(): Observable<AuthUser> {
return this.http.get<AuthUser>(`${environment.url}me`).pipe(
tap((user) => this.userState.set(user))
);
return this.http
.get<AuthUser>(`${environment.url}me`)
.pipe(tap((user) => this.userState.set(user)));
}
hydrateSession(): void {

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
import { InjectionToken } from '@angular/core';
export const BOOTSTRAP_REQUEST_TIMEOUT_MS = new InjectionToken<number>(
'BOOTSTRAP_REQUEST_TIMEOUT_MS',
{
providedIn: 'root',
factory: () => 10_000,
},
);

View File

@@ -1,6 +1,24 @@
export interface CartItemProduct {
nombre: string;
imagen: string | null;
variants?: CartItemVariant[];
}
export interface CartItemVariantOption {
value: string;
label: string;
}
export type CartItemVariantValue =
| string
| CartItemVariantOption
| Array<string | CartItemVariantOption>;
export interface CartItemVariant {
id: number;
precio: string;
stock_tecnico: number | null;
values: Record<string, CartItemVariantValue>;
}
export interface CartItem {

View File

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

View File

@@ -1,16 +1,15 @@
import { inject, Injectable, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { catchError, map, Observable, tap } from 'rxjs';
import { ApiResponse } from '../api-response.interface';
import { BaseApiService } from '../base-api.service';
import { TenantService } from '../tenant.service';
import { Cart } from './cart.interface';
@Injectable({
providedIn: 'root',
})
export class CartService {
private readonly http = inject(HttpClient);
export class CartService extends BaseApiService {
private readonly tenantService = inject(TenantService);
private readonly cartState = signal<Cart | null>(null);
@@ -51,9 +50,13 @@ export class CartService {
): Observable<ApiResponse<Cart>> {
this.isUpdatingState.set(true);
return this.http
.post<
ApiResponse<Cart>
>(`${this.tenantApiUrl}/cart/items`, { catalog_item_id: catalogItemId, variant_id: variantId, cantidad }, { withCredentials: true })
.post<ApiResponse<Cart>>(
`${this.tenantApiUrl}/cart/items`,
{ catalog_item_id: catalogItemId, variant_id: variantId, cantidad },
{
withCredentials: true,
},
)
.pipe(
tap((response) => {
this.cartState.set(response.data);
@@ -67,11 +70,26 @@ export class CartService {
}
updateItemQuantity(cartItemId: number, cantidad: number): Observable<ApiResponse<Cart>> {
return this.updateItem(cartItemId, { cantidad });
}
updateItemVariant(
cartItemId: number,
cantidad: number,
variantId: number,
): Observable<ApiResponse<Cart>> {
return this.updateItem(cartItemId, { cantidad, variant_id: variantId });
}
private updateItem(
cartItemId: number,
payload: { cantidad: number; variant_id?: number },
): Observable<ApiResponse<Cart>> {
this.isUpdatingState.set(true);
return this.http
.patch<
ApiResponse<Cart>
>(`${this.tenantApiUrl}/cart/items/${cartItemId}`, { cantidad }, { withCredentials: true })
.patch<ApiResponse<Cart>>(`${this.tenantApiUrl}/cart/items/${cartItemId}`, payload, {
withCredentials: true,
})
.pipe(
tap((response) => {
this.cartState.set(response.data);
@@ -87,9 +105,9 @@ export class CartService {
removeItem(cartItemId: number): Observable<ApiResponse<Cart>> {
this.isUpdatingState.set(true);
return this.http
.delete<
ApiResponse<Cart>
>(`${this.tenantApiUrl}/cart/items/${cartItemId}`, { withCredentials: true })
.delete<ApiResponse<Cart>>(`${this.tenantApiUrl}/cart/items/${cartItemId}`, {
withCredentials: true,
})
.pipe(
tap((response) => {
this.cartState.set(response.data);

View File

@@ -1,7 +1,10 @@
import { ApiPaginatedResponse } from '../api-paginated-response.interface';
export type CatalogItemType = 'product' | 'bundle';
export interface Product {
id: number;
type: CatalogItemType;
category_id: number;
brand_id: number | null;
slug: string;
@@ -15,28 +18,60 @@ export interface Product {
export interface ProductAttributeOption {
id: number;
validity_time_id?: number | null;
value: string;
label: string;
sort_order: number;
metadata: Record<string, unknown> | null;
}
export type ProductAttributeType =
| 'string'
| 'number'
| 'boolean'
| 'select'
| 'multiselect'
| 'color'
| 'image'
| 'event_date';
export interface ProductAttribute {
id: number;
codigo: string;
nombre: string;
is_required: boolean;
allow_multi_select?: boolean;
show_in_selector?: boolean;
metadata_schema: Record<string, unknown> | null;
type: string;
type: ProductAttributeType;
options: ProductAttributeOption[];
}
export type InventoryPolicy = 'tracked' | 'unlimited';
export interface CatalogVariantOption {
value: string;
label: string;
}
export type CatalogVariantValue =
| string
| CatalogVariantOption
| Array<string | CatalogVariantOption>;
export interface CatalogItemVariant {
id: number;
descripcion?: string | null;
precio?: string;
event_date_id?: number | null;
event_date_ids?: number[];
event_dates?: string[];
stock_tecnico: number | null;
values: Record<string, string>;
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 | string[]>;
}
export interface SelectedCatalogItemVariant extends CatalogItemVariant {
@@ -45,6 +80,8 @@ export interface SelectedCatalogItemVariant extends CatalogItemVariant {
export interface CatalogItemDetail {
id: number;
type: CatalogItemType;
purpose?: 'product' | 'entry';
category_id: number | null;
brand_id: number | null;
slug: string;
@@ -54,6 +91,7 @@ export interface CatalogItemDetail {
category: string | null;
brand: string | null;
inventory_policy: InventoryPolicy;
max_units_per_user?: number | null;
has_tickets: boolean;
minimum_use_date: string | null;
maximum_use_date: string | null;
@@ -64,17 +102,44 @@ export interface CatalogItemDetail {
images?: string[];
}
export type CatalogProductLayout = 'row' | 'column_with_image' | 'column_with_cart';
export type CatalogGroupLayout = 'paginated' | 'simple' | 'simple_vertical' | 'carousel';
export type CatalogProductLayout =
| 'row'
| 'column_with_image'
| 'column_with_cart'
| 'ticket_selector';
export type CatalogGroupLayout = 'paginated' | 'simple' | 'simple_vertical' | 'carousel' | 'single';
export interface CatalogFeaturedItemVariant {
id: number;
descripcion?: string | null;
precio?: string;
stock_tecnico: number | null;
values: Record<string, string>;
values: Record<string, CatalogVariantValue>;
}
export interface CatalogVariantOptionsResponse {
selectors: CatalogVariantSelector[];
selected_values: Record<string, string | string[]>;
resolved_variant: CatalogFeaturedItemVariant | null;
valid: boolean;
available_variant_count: number;
matching_variant_count: number;
price_range: {
minimum: string;
maximum: string;
};
}
export interface CatalogVariantSelector {
key: string;
label: string;
options: CatalogVariantValue[];
enabled: boolean;
}
export interface CatalogFeaturedItem {
id: number;
type: CatalogItemType;
nombre: string;
descripcion?: string | null;
precio: number | string;
@@ -84,7 +149,8 @@ export interface CatalogFeaturedItem {
}
export type CatalogFeaturedItems =
ApiPaginatedResponse<CatalogFeaturedItem[]> | CatalogFeaturedItem[];
| ApiPaginatedResponse<CatalogFeaturedItem[]>
| CatalogFeaturedItem[];
export interface CatalogFeaturedGroup {
id: number;
@@ -94,3 +160,15 @@ export interface CatalogFeaturedGroup {
group_order: number;
items: CatalogFeaturedItems;
}
export interface CatalogCategory {
id: number;
nombre: string;
categoria_id: number | null;
}
export interface CategoryItemsResponse extends ApiPaginatedResponse<CatalogFeaturedItem[]> {
category: CatalogCategory;
layout: CatalogProductLayout;
group_layout: CatalogGroupLayout;
}

View File

@@ -1,25 +1,32 @@
import { HttpParams } from '@angular/common/http';
import { inject, Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { map, Observable } from 'rxjs';
import { ApiPaginationQueryParams } from '../api-pagination-query-params.interface';
import { ApiPaginatedResponse } from '../api-paginated-response.interface';
import { ApiResponse } from '../api-response.interface';
import { BaseApiService } from '../base-api.service';
import { TenantService } from '../tenant.service';
import {
CatalogFeaturedGroup,
CatalogFeaturedItem,
CatalogFeaturedItems,
CatalogItemDetail,
CatalogVariantOptionsResponse,
CategoryItemsResponse,
Product,
} from './catalog.interface';
type HttpParamValue = string | number | boolean | readonly (string | number | boolean)[];
export interface CatalogSearchQueryParams extends ApiPaginationQueryParams {
q: string;
}
@Injectable({
providedIn: 'root',
})
export class CatalogService {
private readonly http = inject(HttpClient);
export class CatalogService extends BaseApiService {
private readonly tenantService = inject(TenantService);
private get tenantApiUrl(): string {
@@ -36,13 +43,35 @@ export class CatalogService {
return this.http.get<CatalogFeaturedGroup[]>(`${this.tenantApiUrl}/catalog`);
}
searchCatalog(
params: CatalogSearchQueryParams,
): Observable<ApiPaginatedResponse<CatalogFeaturedItem[]>> {
return this.http.get<ApiPaginatedResponse<CatalogFeaturedItem[]>>(
`${this.tenantApiUrl}/catalog-items`,
{
params: this.buildHttpParams(params),
},
);
}
getCategoryItems(
categoryId: number,
params?: ApiPaginationQueryParams,
): Observable<CategoryItemsResponse> {
return this.http.get<CategoryItemsResponse>(`${this.tenantApiUrl}/categories/${categoryId}`, {
params: this.buildHttpParams(params),
});
}
getFeaturedGroupItems(
featuredGroupId: number,
params?: ApiPaginationQueryParams,
): Observable<CatalogFeaturedItems> {
return this.http.get<CatalogFeaturedItems>(
`${this.tenantApiUrl}/catalog/featured-groups/${featuredGroupId}/items`,
{ params: this.buildHttpParams(params) },
{
params: this.buildHttpParams(params),
},
);
}
@@ -53,7 +82,23 @@ export class CatalogService {
}
return this.http
.get<ApiResponse<CatalogItemDetail>>(`${this.tenantApiUrl}/catalog-items/${id}`, { params })
.get<ApiResponse<CatalogItemDetail>>(`${this.tenantApiUrl}/catalog-items/${id}`, {
params,
})
.pipe(map((response) => response.data));
}
getVariantOptions(
catalogItemId: number,
payload: {
selected_values: Record<string, string | string[]>;
cart_item_id?: number | null;
},
): Observable<CatalogVariantOptionsResponse> {
return this.http
.post<
ApiResponse<CatalogVariantOptionsResponse>
>(`${this.tenantApiUrl}/catalog-items/${catalogItemId}/variant-options`, payload, { withCredentials: true })
.pipe(map((response) => response.data));
}

View File

@@ -0,0 +1,41 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { environment } from '../../../environments/environment';
import { CheckoutService } from './checkout.service';
describe('CheckoutService', () => {
let service: CheckoutService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [CheckoutService, provideHttpClient(), provideHttpClientTesting()],
});
service = TestBed.inject(CheckoutService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('starts one direct checkout with all selected variants', async () => {
const payload = {
direct_items: [
{ catalog_item_id: 10, variant_id: 101, cantidad: 1 },
{ catalog_item_id: 10, variant_id: 102, cantidad: 1 },
],
};
const purchasePromise = service.startCheckout('desfile', payload);
const request = httpMock.expectOne(`${environment.url}tenants/desfile/compras/start-checkout`);
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual(payload);
request.flush({ data: { id: 55 } });
await expect(purchasePromise).resolves.toMatchObject({ id: 55 });
});
});

View File

@@ -1,17 +1,56 @@
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 interface DirectCheckoutItem {
catalog_item_id: number;
variant_id: number | null;
cantidad: number;
}
export interface UnavailableCheckoutItem {
index: number;
catalog_item_id: number;
variant_id: number | null;
requested_quantity: number;
available_quantity: number;
message: string;
}
export interface InsufficientStockResponse {
code: 'purchase.insufficient_stock';
message: string;
errors: Record<string, string[]>;
unavailable_items: UnavailableCheckoutItem[];
}
export function isInsufficientStockResponse(value: unknown): value is InsufficientStockResponse {
if (typeof value !== 'object' || value === null) return false;
const response = value as Partial<InsufficientStockResponse>;
return (
response.code === 'purchase.insufficient_stock' && Array.isArray(response.unavailable_items)
);
}
export type StartCheckoutPayload =
| {
cart_id: number;
}
| {
direct_items: DirectCheckoutItem[];
};
export interface PurchaseStatusResponse {
status: string | null;
}
@@ -50,6 +89,7 @@ export interface PurchaseDetailResponse extends PurchaseStatusResponse {
user_id: number;
created_at: string | null;
payment_method: string | null;
expires_at: string | null;
dni: string | null;
transfer_payer_dni: string | null;
telefono: string | null;
@@ -57,22 +97,28 @@ export interface PurchaseDetailResponse extends PurchaseStatusResponse {
email: string | null;
items_source: 'purchase';
items: PurchaseDetailItemResponse[];
tickets_count?: number;
has_generated_tickets?: boolean;
subtotal: string;
total: string;
}
@Injectable({
providedIn: 'root'
providedIn: 'root',
})
export class CheckoutService {
private readonly http = inject(HttpClient);
async createPurchase(tenantCode: string, payload: CreatePurchasePayload): Promise<{ id: number }> {
export class CheckoutService extends BaseApiService {
async startCheckout(
tenantCode: string,
payload: StartCheckoutPayload,
): Promise<PurchaseDetailResponse> {
const response = await firstValueFrom(
this.http.post<{ data?: { id: number }; id?: number }>(`${environment.url}tenants/${tenantCode}/compras`, payload)
this.http.post<{ data?: PurchaseDetailResponse } | PurchaseDetailResponse>(
`${environment.url}tenants/${tenantCode}/compras/start-checkout`,
payload,
),
);
const purchase = this.extractResponseData<{ id: number }>(response);
const purchase = this.extractResponseData<PurchaseDetailResponse>(response);
if (!purchase?.id) {
throw new Error('Error al crear la compra.');
@@ -81,13 +127,21 @@ export class CheckoutService {
return purchase;
}
async generatePaymentIntent(tenantCode: string, purchaseId: number, method: 'qr' | 'transfer' | 'telepagos', payerDni?: string): Promise<any> {
async generatePaymentIntent(
tenantCode: string,
purchaseId: number,
method: 'qr' | 'transfer' | 'telepagos',
payerDni?: string,
): Promise<any> {
const payload: any = { method };
if (payerDni) {
payload.transfer_payer_dni = payerDni;
}
const response = await firstValueFrom(
this.http.post<any>(`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/payment-intent`, payload)
this.http.post<any>(
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/payment-intent`,
payload,
),
);
if (!response) {
throw new Error('Error al generar la intención de pago.');
@@ -95,9 +149,72 @@ export class CheckoutService {
return response;
}
async updateCustomerData(
tenantCode: string,
purchaseId: number,
payload: UpdatePurchaseCustomerPayload,
): Promise<PurchaseDetailResponse> {
const response = await firstValueFrom(
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`, {})
this.http.post<{ data?: PurchaseStatusResponse } | PurchaseStatusResponse>(
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/complete`,
{},
),
);
const purchase = this.extractResponseData<PurchaseStatusResponse>(response);
@@ -107,27 +224,68 @@ export class CheckoutService {
}
return {
status: purchase.status ?? null
status: purchase.status ?? null,
};
}
async getPurchases(tenantCode: string, status?: string): Promise<{ data: PurchaseSummaryResponse[] }> {
async submitPurchaseForReview(
tenantCode: string,
purchaseId: number,
): Promise<PurchaseStatusResponse> {
const response = await firstValueFrom(
this.http.post<{ data?: PurchaseStatusResponse } | PurchaseStatusResponse>(
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/review`,
{},
),
);
const purchase = this.extractResponseData<PurchaseStatusResponse>(response);
if (!purchase) {
throw new Error('Error al enviar la compra a revisi\u00f3n.');
}
return { status: purchase.status ?? null };
}
async cancelPurchase(tenantCode: string, purchaseId: number): Promise<PurchaseStatusResponse> {
const response = await firstValueFrom(
this.http.post<{ data?: PurchaseStatusResponse } | PurchaseStatusResponse>(
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/cancel`,
{},
),
);
const purchase = this.extractResponseData<PurchaseStatusResponse>(response);
if (!purchase) {
throw new Error('Error al cancelar la compra.');
}
return { status: purchase.status ?? null };
}
async getPurchases(
tenantCode: string,
status?: string,
): Promise<{ data: PurchaseSummaryResponse[] }> {
let url = `${environment.url}tenants/${tenantCode}/compras`;
if (status) {
url += `?status=${status}`;
}
const response = await firstValueFrom(
this.http.get<{ data: PurchaseSummaryResponse[] }>(url)
);
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> {
async getPurchase(
tenantCode: string,
purchaseId: string | number,
): Promise<PurchaseDetailResponse> {
const response = await firstValueFrom(
this.http.get<{ data?: PurchaseDetailResponse } | PurchaseDetailResponse>(`${environment.url}tenants/${tenantCode}/compras/${purchaseId}`)
this.http.get<{ data?: PurchaseDetailResponse } | PurchaseDetailResponse>(
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}`,
),
);
const purchase = this.extractResponseData<PurchaseDetailResponse>(response);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -163,6 +163,8 @@ describe('ModalService', () => {
const activeModal = service.activeModal();
const resultPromise = firstValueFrom(result$);
expect(activeModal?.config.size).toBe('sm');
activeModal?.ref.dismiss('escape');
await expect(resultPromise).resolves.toBe(false);

View File

@@ -166,7 +166,10 @@ export class ModalService {
}
openConfirmDeleteRef(config: ConfirmModalConfig): ModalRef<boolean> {
return this.open(ConfirmDeleteModalComponent, this.buildConfirmModalConfig(config));
return this.open(
ConfirmDeleteModalComponent,
this.buildConfirmModalConfig({ size: 'sm', ...config }),
);
}
openSimple(config: SimpleModalConfig): Observable<void> {

View File

@@ -9,23 +9,68 @@ export interface BankAccount {
cvu: string;
}
export interface WebsiteType {
codigo: string;
nombre: string;
primary_color: string | null;
secondary_color: string | null;
danger_color: string | null;
success_color: string | null;
site_logo: string | null;
}
export interface HeroConfig {
background_image?: string | null;
background_image_id?: string | null;
title_html?: string | null;
description_html?: string | null;
button_text?: string | null;
button_href?: string | null;
}
export interface AdditionalInfoConfig {
description?: string | null;
}
export interface EventDate {
id?: number;
date: string;
start_time: string;
end_time: string;
}
export interface EventConfig {
id?: number;
title?: string | null;
location?: string | null;
dates?: string[] | null;
dates_text?: string | null;
dates?: EventDate[] | null;
contact?: SocialMedia[];
}
export interface ActiveEventDate {
id: number;
date: string;
time_start: string;
time_end: string;
}
export interface TenantEvent {
title: string;
location: string;
dates: ActiveEventDate[];
}
export interface WebsiteExtras {
carousel?: string[];
heroConfig?: HeroConfig | null;
additionalInfoConfig?: AdditionalInfoConfig | null;
[extraName: string]: unknown;
}
export interface Menu {
id: number;
code: string;
label: string;
parent_menu_code: string | null;
content_type: 'static' | 'dynamic';
route: string;
@@ -40,11 +85,19 @@ export interface SocialMedia {
url: string;
}
export interface Category {
id: number;
nombre: string;
subcategories: Category[];
}
export interface Tenant {
id: number;
codigo: string;
nombre: string;
dominio: string;
site_title?: string | null;
favicon?: string | null;
primary_color: string;
secondary_color: string;
danger_color: string;
@@ -53,14 +106,24 @@ export interface Tenant {
footer_bg_color: string;
header_logo: string;
footer_logo: string;
header_bg_image?: string | null;
footer_bg_image?: string | null;
website_type_code?: string | null;
website_type?: WebsiteType | null;
event_date_text?: string | null;
extras?: WebsiteExtras;
event?: TenantEvent | null;
selected_bank_account_id?: number | null;
selected_bank_account?: BankAccount | null;
hero_config?: HeroConfig | null;
event_config?: EventConfig | null;
main_carousel_images?: string[];
search_product_layout?: 'row' | 'column_with_image' | 'column_with_cart' | 'ticket_selector';
search_group_layout?: 'paginated' | 'simple' | 'simple_vertical' | 'carousel' | 'single';
search_items_per_page?: number;
display_categories?: boolean;
display_seach_bar?: boolean;
display_cart?: boolean;
social_media?: SocialMedia[];
menues?: Menu[];
categories: Category[];
}
export type TenantBootstrapResponse = ApiResponse<Tenant>;

View File

@@ -2,8 +2,10 @@ import { PLATFORM_ID, REQUEST, RESPONSE_INIT, TransferState } from '@angular/cor
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TimeoutError } from 'rxjs';
import { environment } from '../../../environments/environment';
import { BOOTSTRAP_REQUEST_TIMEOUT_MS } from './bootstrap-request-timeout';
import { Tenant, TenantBootstrapResponse } from './tenant.interface';
import { TENANT_SSR_STATE_KEY } from './tenant-ssr-cache.store';
import { TenantService } from './tenant.service';
@@ -20,11 +22,24 @@ 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 = {
data: tenant
data: tenant,
};
describe('TenantService', () => {
@@ -38,14 +53,16 @@ describe('TenantService', () => {
it('requests the tenant bootstrap endpoint using the current hostname and stores the response', async () => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting(), TenantService]
providers: [provideHttpClient(), provideHttpClientTesting(), TenantService],
});
const service = TestBed.inject(TenantService);
const httpController = TestBed.inject(HttpTestingController);
const bootstrapPromise = service.bootstrap();
const request = httpController.expectOne(`${environment.url}tenants/bootstrap/localhost`);
const request = httpController.expectOne(
`${environment.url}tenants/bootstrap?dominio=localhost&path=/`,
);
expect(request.request.method).toBe('GET');
@@ -61,7 +78,7 @@ describe('TenantService', () => {
it('hydrates the tenant from TransferState without performing HTTP on the browser', async () => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting(), TenantService]
providers: [provideHttpClient(), provideHttpClientTesting(), TenantService],
});
const service = TestBed.inject(TenantService);
@@ -70,7 +87,7 @@ describe('TenantService', () => {
transferState.set(TENANT_SSR_STATE_KEY, {
status: 'ready',
tenant
tenant,
});
await expect(service.bootstrap()).resolves.toBeUndefined();
@@ -96,12 +113,12 @@ describe('TenantService', () => {
useValue: new Request('https://internal.example/render', {
headers: {
'x-forwarded-host': 'STORE.EXAMPLE.COM:443, proxy.internal',
host: 'ignored.example.com'
}
})
host: 'ignored.example.com',
},
}),
},
{ provide: RESPONSE_INIT, useValue: responseInit }
]
{ provide: RESPONSE_INIT, useValue: responseInit },
],
});
const service = TestBed.inject(TenantService);
@@ -109,7 +126,9 @@ describe('TenantService', () => {
const httpController = TestBed.inject(HttpTestingController);
const bootstrapPromise = service.bootstrap();
const request = httpController.expectOne(`${environment.url}tenants/bootstrap/store.example.com`);
const request = httpController.expectOne(
`${environment.url}tenants/bootstrap?dominio=store.example.com&path=/render`,
);
request.flush(tenantResponse);
@@ -118,7 +137,7 @@ describe('TenantService', () => {
expect(service.tenant()).toEqual(tenant);
expect(transferState.get(TENANT_SSR_STATE_KEY, null)).toEqual({
status: 'ready',
tenant
tenant,
});
expect(responseInit.status).toBeUndefined();
@@ -138,12 +157,12 @@ describe('TenantService', () => {
provide: REQUEST,
useValue: new Request('https://internal.example/render', {
headers: {
host: 'missing.example.com:8443'
}
})
host: 'missing.example.com:8443',
},
}),
},
{ provide: RESPONSE_INIT, useValue: responseInit }
]
{ provide: RESPONSE_INIT, useValue: responseInit },
],
});
const service = TestBed.inject(TenantService);
@@ -151,7 +170,9 @@ describe('TenantService', () => {
const httpController = TestBed.inject(HttpTestingController);
const bootstrapPromise = service.bootstrap();
const request = httpController.expectOne(`${environment.url}tenants/bootstrap/missing.example.com`);
const request = httpController.expectOne(
`${environment.url}tenants/bootstrap?dominio=missing.example.com&path=/render`,
);
request.flush({ message: 'Not Found' }, { status: 404, statusText: 'Not Found' });
@@ -160,7 +181,7 @@ describe('TenantService', () => {
expect(service.tenant()).toBeNull();
expect(service.getTenant()).toBeNull();
expect(transferState.get(TENANT_SSR_STATE_KEY, null)).toEqual({
status: 'not-found'
status: 'not-found',
});
expect(responseInit.status).toBe(404);
@@ -169,14 +190,16 @@ describe('TenantService', () => {
it('rejects when the bootstrap response does not contain tenant data', async () => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting(), TenantService]
providers: [provideHttpClient(), provideHttpClientTesting(), TenantService],
});
const service = TestBed.inject(TenantService);
const httpController = TestBed.inject(HttpTestingController);
const bootstrapPromise = service.bootstrap();
const request = httpController.expectOne(`${environment.url}tenants/bootstrap/localhost`);
const request = httpController.expectOne(
`${environment.url}tenants/bootstrap?dominio=localhost&path=/`,
);
request.flush({});
@@ -200,19 +223,21 @@ describe('TenantService', () => {
provide: REQUEST,
useValue: new Request('https://internal.example/render', {
headers: {
host: 'broken.example.com'
}
})
host: 'broken.example.com',
},
}),
},
{ provide: RESPONSE_INIT, useValue: responseInit }
]
{ provide: RESPONSE_INIT, useValue: responseInit },
],
});
const service = TestBed.inject(TenantService);
const httpController = TestBed.inject(HttpTestingController);
const bootstrapPromise = service.bootstrap();
const request = httpController.expectOne(`${environment.url}tenants/bootstrap/broken.example.com`);
const request = httpController.expectOne(
`${environment.url}tenants/bootstrap?dominio=broken.example.com&path=/render`,
);
request.flush({ message: 'Boom' }, { status: 500, statusText: 'Server Error' });
@@ -222,4 +247,39 @@ describe('TenantService', () => {
httpController.verify();
});
it('rejects and cancels a tenant bootstrap request that exceeds the configured timeout', async () => {
vi.useFakeTimers();
try {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
TenantService,
{ provide: BOOTSTRAP_REQUEST_TIMEOUT_MS, useValue: 25 },
],
});
const service = TestBed.inject(TenantService);
const httpController = TestBed.inject(HttpTestingController);
const bootstrapPromise = service.bootstrap();
const rejection = expect(bootstrapPromise).rejects.toBeInstanceOf(TimeoutError);
const request = httpController.expectOne(
`${environment.url}tenants/bootstrap?dominio=localhost&path=/`,
);
await vi.advanceTimersByTimeAsync(25);
await rejection;
expect(request.cancelled).toBe(true);
expect(service.status()).toBe('idle');
expect(service.tenant()).toBeNull();
httpController.verify();
} finally {
vi.useRealTimers();
}
});
});

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,
@@ -7,29 +7,31 @@ import {
REQUEST,
RESPONSE_INIT,
signal,
TransferState
TransferState,
} from '@angular/core';
import { IS_DISCOVERING_ROUTES } from '@angular/ssr';
import { firstValueFrom } from 'rxjs';
import { firstValueFrom, timeout } from 'rxjs';
import { environment } from '../../../environments/environment';
import { BaseApiService } from './base-api.service';
import { BOOTSTRAP_REQUEST_TIMEOUT_MS } from './bootstrap-request-timeout';
import { Tenant, TenantBootstrapResponse } from './tenant.interface';
import {
TENANT_SSR_STATE_KEY,
TenantBootstrapStatus,
TenantSsrState
TenantSsrState,
} from './tenant-ssr-cache.store';
@Injectable({
providedIn: 'root'
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 });
private readonly transferState = inject(TransferState);
private readonly isDiscoveringRoutes = inject(IS_DISCOVERING_ROUTES, { optional: true }) ?? false;
private readonly bootstrapRequestTimeoutMs = inject(BOOTSTRAP_REQUEST_TIMEOUT_MS);
private readonly tenantState = signal<Tenant | null>(null);
private readonly statusState = signal<TenantBootstrapStatus>('idle');
@@ -48,7 +50,6 @@ export class TenantService {
return `${environment.url}tenants/${tenant.codigo}`;
}
async bootstrap(): Promise<void> {
if (this.statusState() !== 'idle') {
return;
@@ -69,11 +70,18 @@ export class TenantService {
return;
}
const domain = this.resolveDomain();
const { domain, path } = this.resolveRequestContext();
try {
const response = await firstValueFrom(
this.http.get<TenantBootstrapResponse>(`${environment.url}tenants/bootstrap/${domain}`)
this.http
.get<TenantBootstrapResponse>(`${environment.url}tenants/bootstrap`, {
params: {
dominio: domain,
path,
},
})
.pipe(timeout(this.bootstrapRequestTimeoutMs)),
);
if (!response?.data) {
@@ -83,7 +91,7 @@ export class TenantService {
this.setReady(response.data);
this.persistSsrState({
status: 'ready',
tenant: response.data
tenant: response.data,
});
} catch (error) {
if (this.isNotFoundError(error)) {
@@ -101,7 +109,7 @@ export class TenantService {
}
}
private resolveDomain(): string {
private resolveRequestContext(): { domain: string; path: string } {
if (isPlatformBrowser(this.platformId)) {
const domain = window.location.hostname;
@@ -109,7 +117,10 @@ export class TenantService {
throw new Error('Tenant bootstrap could not resolve the current domain.');
}
return this.normalizeHost(domain);
return {
domain: this.normalizeHost(domain),
path: this.normalizePath(window.location.pathname),
};
}
const requestHost =
@@ -121,7 +132,14 @@ export class TenantService {
throw new Error('Tenant bootstrap could not resolve the current domain.');
}
return this.normalizeHost(requestHost);
const forwardedPath =
this.request?.headers.get('x-forwarded-uri') ?? this.request?.headers.get('x-original-uri');
const requestPath = forwardedPath ?? (this.request ? new URL(this.request.url).pathname : '/');
return {
domain: this.normalizeHost(requestHost),
path: this.normalizePath(requestPath),
};
}
private normalizeHost(host: string): string {
@@ -143,6 +161,16 @@ export class TenantService {
return normalizedHost.replace(/:\d+$/, '');
}
private normalizePath(path: string): string {
const pathname = path.split(/[?#]/, 1)[0]?.trim() ?? '';
if (!pathname || pathname === '/') {
return '/';
}
return `/${pathname.replace(/^\/+|\/+$/g, '')}`;
}
private applyState(state: TenantSsrState): void {
if (state.status === 'ready') {
this.setReady(state.tenant);

View File

@@ -398,8 +398,8 @@
<div class="accordion-showcase">
<h2>Accordion</h2>
<p class="text-muted mb-4">
Accordion reutilizable basado en las clases globales de Bootstrap. Por defecto mantiene
un solo item abierto.
Accordion reutilizable basado en las clases globales de Bootstrap. Por defecto mantiene un
solo item abierto.
</p>
<app-accordion>
@@ -409,8 +409,8 @@
</app-accordion-item>
<app-accordion-item title="¿Qué medios de pago están disponibles?">
Los medios de pago dependen de la configuración de cada tienda y se muestran durante
el checkout.
Los medios de pago dependen de la configuración de cada tienda y se muestran durante el
checkout.
</app-accordion-item>
<app-accordion-item title="Item deshabilitado" [disabled]="true">
@@ -692,6 +692,23 @@
</div>
</section>
<section class="component-demo card shadow-sm mt-4" data-testid="location-map-demo">
<div class="card-body">
<span class="eyebrow">Reusable Component</span>
<h2 class="mb-2">Mapa de ubicaciones</h2>
<p class="text-muted mb-4">
Prueba de Leaflet con múltiples puntos, encuadre automático y acceso a indicaciones.
</p>
<app-location-map
[locations]="mapLocations"
[markerColor]="tenant()?.primary_color ?? '#24a9e0'"
mapStyle="minimal-light"
ariaLabel="Mapa de puntos de atención en Rosario"
/>
</div>
</section>
<section class="component-demo card shadow-sm mt-4">
<div class="card-body">
<span class="eyebrow">Reusable Component</span>
@@ -737,6 +754,29 @@
</div>
</section>
<section
class="component-demo component-demo--ticket-selector card shadow-sm mt-4"
data-testid="ticket-selector-demo"
>
<div class="card-body">
<span class="eyebrow">Reusable Component</span>
<h2 class="mb-2">Selector de entradas (Ticket Selector Layout)</h2>
<p class="text-muted mb-4">
Demo de selección encadenada por tipo, sector, fila y asiento. Las combinaciones sin stock
no aparecen como disponibles.
</p>
<app-product-ticket-selector
[productId]="1"
[title]="testTicketSelectorProduct.title"
[description]="testTicketSelectorProduct.description"
[price]="testTicketSelectorProduct.price"
[imageUrl]="testTicketSelectorProduct.imageUrl"
(buy)="onTicketBuy($event)"
/>
</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) {

View File

@@ -15,6 +15,10 @@
border: 0;
}
.component-demo--ticket-selector {
width: min(100%, 76rem);
}
.store-section-demo {
width: min(100%, 64rem);
}
@@ -77,7 +81,7 @@ h1 {
.modal-showcase__result {
padding: 0.875rem 1rem;
border: 1px dashed rgba(32, 32, 32, 0.14);
border: 1px dashed var(--border-color);
border-radius: 0.875rem;
background: linear-gradient(180deg, rgba(248, 248, 248, 0.92), rgba(255, 255, 255, 0.98));
color: #495057;
@@ -231,7 +235,7 @@ h1 {
justify-content: center;
height: 120px;
border-radius: 8px;
border: 1px solid #dee2e6;
border: 1px solid var(--border-color);
padding: 1rem;
overflow: hidden;
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.05);
@@ -281,7 +285,7 @@ h1 {
display: flex;
flex-direction: column;
border-radius: 8px;
border: 1px solid #dee2e6;
border: 1px solid var(--border-color);
overflow: hidden;
background-color: #ffffff;
transition:
@@ -296,7 +300,7 @@ h1 {
&__preview {
height: 80px;
width: 100%;
border-bottom: 1px solid #dee2e6;
border-bottom: 1px solid var(--border-color);
transition: opacity 0.2s ease;
&:hover {
@@ -337,7 +341,7 @@ h1 {
background-color: #eaeaea;
padding: 1.5rem;
border-radius: 8px;
border: 1px solid #dee2e6;
border: 1px solid var(--border-color);
}
&__row {

View File

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

View File

@@ -7,6 +7,7 @@ import { InputComponent } from '../../../../shared/components/input/input.compon
import { PaginatorComponent } from '../../../../shared/components/paginator/paginator.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 { ProductTicketSelectorComponent } from '../../../../shared/components/product-ticket-selector/product-ticket-selector.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';
@@ -18,6 +19,10 @@ import { CarouselComponent } from '../../../../shared/components/carousel/carous
import { MainCarouselComponent } from '../../../../shared/components/main-carousel/main-carousel.component';
import { AccordionComponent } from '../../../../shared/components/accordion/accordion.component';
import { AccordionItemComponent } from '../../../../shared/components/accordion/accordion-item.component';
import {
LocationMapComponent,
MapLocation,
} from '../../../../shared/components/location-map/location-map.component';
interface CarouselProductMock {
id: number;
@@ -28,6 +33,11 @@ interface CarouselProductMock {
imageUrl: string;
}
const ticketOption = (value: string, label: string = value): { value: string; label: string } => ({
value,
label,
});
@Component({
selector: 'app-reutilizables-test-page',
imports: [
@@ -37,6 +47,7 @@ interface CarouselProductMock {
PaginatorComponent,
ProductColumnWithImageComponent,
ProductRowCardComponent,
ProductTicketSelectorComponent,
ProductVerticalWithCartCardComponent,
StoreSectionComponent,
IconButtonComponent,
@@ -47,6 +58,7 @@ interface CarouselProductMock {
MainCarouselComponent,
AccordionComponent,
AccordionItemComponent,
LocationMapComponent,
],
templateUrl: './reutilizables-test-page.component.html',
styleUrl: './reutilizables-test-page.component.scss',
@@ -79,6 +91,29 @@ export class ReutilizablesTestPageComponent {
protected lastModalResult = 'Todavia no se abrio ningun modal.';
protected readonly paginatorTotalPages = 8;
protected readonly cartBackgroundColor = '#ffffff';
protected readonly mapLocations: readonly MapLocation[] = [
{
id: 'gigante-de-arroyito',
label: 'Gigante de Arroyito',
address: 'Av. Génova 640, Rosario',
latitude: -32.913997,
longitude: -60.674567,
},
{
id: 'punto-centro',
label: 'Punto Centro',
address: 'Centro, Rosario',
latitude: -32.94682,
longitude: -60.63932,
},
{
id: 'punto-sur',
label: 'Punto Sur',
address: 'Zona sur, Rosario',
latitude: -32.97151,
longitude: -60.65064,
},
];
protected readonly mainCarouselImages = [
'https://images.unsplash.com/photo-1441986300917-64674bd600d8?auto=format&fit=crop&w=1600&q=80',
'https://images.unsplash.com/photo-1529139574466-a303027c1d8b?auto=format&fit=crop&w=1600&q=80',
@@ -176,8 +211,9 @@ export class ReutilizablesTestPageComponent {
'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' },
{ id: '10-oct-manana', values: { fecha: '10 de Octubre', turno: 'Mañana' } },
{ id: '10-oct-tarde', values: { fecha: '10 de Octubre', turno: 'Tarde' } },
{ id: '11-oct-tarde', values: { fecha: '11 de Octubre', turno: 'Tarde' } },
],
};
@@ -187,6 +223,125 @@ export class ReutilizablesTestPageComponent {
price: 11500,
};
protected readonly testTicketSelectorProduct = {
title: 'ENTRADAS DESFILE PURA TENDENCIA',
description: '',
price: 40000,
imageUrl: '/images/ticket-selector-entrada-pasarela.png',
variants: [
{
id: 1001,
precio: 250000,
stock_tecnico: 1,
values: {
tipo: ticketOption('vip_lunch', 'VIP + Lunch'),
sector: ticketOption('a', 'Sector A'),
fila: ticketOption('1', 'Fila 1'),
asiento: ticketOption('1', 'Asiento 1'),
},
},
{
id: 1002,
precio: 250000,
stock_tecnico: 1,
values: {
tipo: ticketOption('vip_lunch', 'VIP + Lunch'),
sector: ticketOption('a', 'Sector A'),
fila: ticketOption('1', 'Fila 1'),
asiento: ticketOption('2', 'Asiento 2'),
},
},
{
id: 1003,
precio: 250000,
stock_tecnico: 1,
values: {
tipo: ticketOption('vip_lunch', 'VIP + Lunch'),
sector: ticketOption('c', 'Sector C'),
fila: ticketOption('1', 'Fila 1'),
asiento: ticketOption('1', 'Asiento 1'),
},
},
{
id: 1004,
precio: 200000,
stock_tecnico: 1,
values: {
tipo: ticketOption('vip_lunch', 'VIP + Lunch'),
sector: ticketOption('a', 'Sector A'),
fila: ticketOption('2', 'Fila 2'),
asiento: ticketOption('1', 'Asiento 1'),
},
},
{
id: 1005,
precio: 200000,
stock_tecnico: 0,
values: {
tipo: ticketOption('vip_lunch', 'VIP + Lunch'),
sector: ticketOption('a', 'Sector A'),
fila: ticketOption('2', 'Fila 2'),
asiento: ticketOption('2', 'Asiento 2'),
},
},
{
id: 1006,
precio: 100000,
stock_tecnico: 1,
values: {
tipo: ticketOption('general', 'General'),
sector: ticketOption('b', 'Sector B'),
fila: ticketOption('3', 'Fila 3'),
asiento: ticketOption('1', 'Asiento 1'),
},
},
{
id: 1007,
precio: 100000,
stock_tecnico: 1,
values: {
tipo: ticketOption('general', 'General'),
sector: ticketOption('b', 'Sector B'),
fila: ticketOption('3', 'Fila 3'),
asiento: ticketOption('2', 'Asiento 2'),
},
},
{
id: 1008,
precio: 90000,
stock_tecnico: 1,
values: {
tipo: ticketOption('general', 'General'),
sector: ticketOption('d', 'Sector D'),
fila: ticketOption('3', 'Fila 3'),
asiento: ticketOption('1', 'Asiento 1'),
},
},
{
id: 1009,
precio: 65000,
stock_tecnico: 1,
values: {
tipo: ticketOption('general', 'General'),
sector: ticketOption('d', 'Sector D'),
fila: ticketOption('4', 'Fila 4'),
asiento: ticketOption('1', 'Asiento 1'),
},
},
{
id: 1010,
precio: 40000,
stock_tecnico: 1,
values: {
tipo: ticketOption('general', 'General'),
sector: ticketOption('d', 'Sector D'),
fila: ticketOption('5', 'Fila 5'),
asiento: ticketOption('1', 'Asiento 1'),
},
},
],
};
protected readonly cartMockItems: CartItemMock[] = [
{
imageUrl: null,
@@ -243,6 +398,12 @@ export class ReutilizablesTestPageComponent {
alert(`Comprando: ${productTitle}`);
}
protected onTicketBuy(variantIds: number[]): void {
this.toastService.success(
`Compra iniciada para ${variantIds.length} entrada/s: ${variantIds.join(', ')}.`,
);
}
protected onFileChange(file: File | null): void {
this.fileName = file?.name ?? '';
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
<div class="attribute-selector">
@for (attribute of attributes(); track attribute.id) {
@for (attribute of visibleAttributes(); track attribute.codigo) {
<div class="attribute-selector__row">
<span class="attribute-selector__label">{{ attribute.nombre }}:</span>
@@ -10,12 +10,14 @@
type="button"
class="attribute-selector__swatch"
[class.attribute-selector__swatch--selected]="hasSelectedOption(attribute, option)"
[class.attribute-selector__swatch--disabled]="!availableOptions()[attribute.id][option.id]"
[class.attribute-selector__swatch--disabled]="
!availableOptions()[attribute.codigo][option.id]
"
[style.background-color]="getOptionSwatchColor(option)"
[attr.aria-label]="option.label"
[attr.aria-pressed]="hasSelectedOption(attribute, option)"
[title]="option.label"
[disabled]="!availableOptions()[attribute.id][option.id]"
[disabled]="!availableOptions()[attribute.codigo][option.id]"
(click)="selectAttributeOption(attribute, option)"
>
<span class="visually-hidden">{{ option.label }}</span>
@@ -24,10 +26,14 @@
<button
type="button"
class="attribute-selector__text-option"
[class.attribute-selector__text-option--selected]="hasSelectedOption(attribute, option)"
[class.attribute-selector__text-option--disabled]="!availableOptions()[attribute.id][option.id]"
[class.attribute-selector__text-option--selected]="
hasSelectedOption(attribute, option)
"
[class.attribute-selector__text-option--disabled]="
!availableOptions()[attribute.codigo][option.id]
"
[attr.aria-pressed]="hasSelectedOption(attribute, option)"
[disabled]="!availableOptions()[attribute.id][option.id]"
[disabled]="!availableOptions()[attribute.codigo][option.id]"
(click)="selectAttributeOption(attribute, option)"
>
{{ option.label }}

View File

@@ -54,7 +54,7 @@
}
&__text-option {
border: 1px solid #dcdcdc;
border: 1px solid var(--border-color);
background: #ffffff;
color: #666666;
transition: border-color 0.2s ease, color 0.2s ease, background-color 0.2s ease;

View File

@@ -68,4 +68,85 @@ describe('ProductAttributeSelectorComponent', () => {
expect(buttons[0].disabled).toBe(true);
expect(buttons[1].disabled).toBe(false);
});
it('allows multiple event dates and emits the exact matching variant', () => {
const fixture = TestBed.createComponent(ProductAttributeSelectorComponent);
const emittedIds: Array<number | null> = [];
fixture.componentInstance.variantChange.subscribe((variant) =>
emittedIds.push(variant?.id ?? null),
);
fixture.componentRef.setInput('attributes', [
{
id: 2,
codigo: 'event_date',
nombre: 'Fecha',
is_required: true,
allow_multi_select: true,
metadata_schema: null,
type: 'event_date',
options: [
{ id: 101, value: '1', label: '09/10/2026', sort_order: 0, metadata: null },
{ id: 102, value: '2', label: '10/10/2026', sort_order: 1, metadata: null },
],
} satisfies ProductAttribute,
]);
fixture.componentRef.setInput('inventoryPolicy', 'unlimited');
fixture.componentRef.setInput('variants', [
{ id: 1, stock_tecnico: null, values: { event_date: '1' } },
{ id: 2, stock_tecnico: null, values: { event_date: '2' } },
{ id: 3, stock_tecnico: null, values: { event_date: ['1', '2'] } },
]);
fixture.detectChanges();
const buttons = Array.from(
fixture.nativeElement.querySelectorAll('.attribute-selector__text-option'),
) as HTMLButtonElement[];
buttons[0].click();
fixture.detectChanges();
buttons[1].click();
fixture.detectChanges();
expect(buttons[0].getAttribute('aria-pressed')).toBe('true');
expect(buttons[1].getAttribute('aria-pressed')).toBe('true');
expect(emittedIds).toContain(3);
});
it('hides configured attributes and matches variants using only visible selections', () => {
const fixture = TestBed.createComponent(ProductAttributeSelectorComponent);
const emittedIds: Array<number | null> = [];
fixture.componentInstance.variantChange.subscribe((variant) =>
emittedIds.push(variant?.id ?? null),
);
fixture.componentRef.setInput('attributes', [
sizeAttribute,
{
id: 2,
codigo: 'internal_type',
nombre: 'Internal type',
is_required: true,
show_in_selector: false,
metadata_schema: null,
type: 'select',
options: [
{ id: 30, value: 'adult', label: 'Adult', sort_order: 0, metadata: null },
{ id: 40, value: 'child', label: 'Child', sort_order: 1, metadata: null },
],
} satisfies ProductAttribute,
]);
fixture.componentRef.setInput('inventoryPolicy', 'unlimited');
fixture.componentRef.setInput('variants', [
{ id: 1, stock_tecnico: null, values: { size: 'S', internal_type: 'adult' } },
{ id: 2, stock_tecnico: null, values: { size: 'M', internal_type: 'child' } },
]);
fixture.detectChanges();
const labels = fixture.nativeElement.querySelectorAll('.attribute-selector__label');
const buttons = fixture.nativeElement.querySelectorAll('.attribute-selector__text-option');
expect(labels).toHaveLength(1);
expect(labels[0].textContent).toContain('Size');
buttons[1].click();
fixture.detectChanges();
expect(emittedIds).toContain(2);
});
});

View File

@@ -32,48 +32,69 @@ export class ProductAttributeSelectorComponent {
public variantChange = output<CatalogItemVariant | null>();
protected readonly selectedAttributeOptions = signal<Record<number, number>>({});
protected readonly visibleAttributes = computed(() =>
this.attributes().filter((attribute) => attribute.show_in_selector !== false),
);
protected readonly selectedAttributeOptions = signal<Record<string, number[]>>({});
protected readonly availableOptions = computed(() => {
const selections = this.selectedAttributeOptions();
const variants = this.variants();
const attributes = this.attributes();
const attributes = this.visibleAttributes();
const availability: Record<number, Record<number, boolean>> = {};
const availability: Record<string, Record<number, boolean>> = {};
for (const attribute of attributes) {
availability[attribute.id] = {};
availability[attribute.codigo] = {};
for (const option of attribute.options) {
const optionNormalized = this.normalizeText(option.value || option.label);
const selectedForAttribute = selections[attribute.codigo] ?? [];
if (
!attribute.allow_multi_select &&
selectedForAttribute.length >= 1 &&
!selectedForAttribute.includes(option.id)
) {
availability[attribute.codigo][option.id] = false;
continue;
}
const isAvailable = variants.some((variant) => {
if (!this.isVariantAvailable(variant)) return false;
const variantAttrValue = this.getDefaultVariantAttributeValue(attribute, variant.values);
if (!variantAttrValue || this.normalizeText(variantAttrValue) !== optionNormalized) {
const variantAttrValues = this.getVariantAttributeValues(attribute, variant.values);
const desiredOptionIds = selectedForAttribute.includes(option.id)
? selectedForAttribute
: [...selectedForAttribute, option.id];
const desiredValues = desiredOptionIds
.map((id) => attribute.options.find((candidate) => candidate.id === id))
.filter((candidate): candidate is ProductAttributeOption => candidate !== undefined)
.map((candidate) => this.normalizeText(candidate.value || candidate.label));
if (
!variantAttrValues.includes(optionNormalized) ||
!desiredValues.every((value) => variantAttrValues.includes(value))
) {
return false;
}
for (const otherAttr of attributes) {
if (otherAttr.id === attribute.id) continue;
const selectedOptionId = selections[otherAttr.id];
if (selectedOptionId !== undefined) {
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.values);
if (!vAttrValue || this.normalizeText(vAttrValue) !== selectedNormalized) {
return false;
}
}
if (otherAttr.codigo === attribute.codigo) continue;
const selectedOptionIds = selections[otherAttr.codigo] ?? [];
const selectedValues = selectedOptionIds
.map((id) => otherAttr.options.find((candidate) => candidate.id === id))
.filter((candidate): candidate is ProductAttributeOption => candidate !== undefined)
.map((candidate) => this.normalizeText(candidate.value || candidate.label));
const variantValues = this.getVariantAttributeValues(otherAttr, variant.values);
if (!selectedValues.every((value) => variantValues.includes(value))) {
return false;
}
}
return true;
});
availability[attribute.id][option.id] = isAvailable;
availability[attribute.codigo][option.id] = isAvailable;
}
}
@@ -86,7 +107,7 @@ export class ProductAttributeSelectorComponent {
constructor() {
effect(() => {
const attributes = this.attributes();
const attributes = this.visibleAttributes();
const defaultVariant = this.selectedVariant();
untracked(() => {
@@ -97,7 +118,7 @@ export class ProductAttributeSelectorComponent {
effect(() => {
const selections = this.selectedAttributeOptions();
const variants = this.variants();
const attributes = this.attributes();
const attributes = this.visibleAttributes();
untracked(() => {
this.emitMatchingVariant(selections, variants, attributes);
@@ -109,17 +130,28 @@ export class ProductAttributeSelectorComponent {
attribute: ProductAttribute,
option: ProductAttributeOption,
): boolean {
return this.selectedAttributeOptions()[attribute.id] === option.id;
return (this.selectedAttributeOptions()[attribute.codigo] ?? []).includes(option.id);
}
protected selectAttributeOption(
attribute: ProductAttribute,
option: ProductAttributeOption,
): void {
this.selectedAttributeOptions.update((current) => ({
...current,
[attribute.id]: option.id,
}));
this.selectedAttributeOptions.update((current) => {
const selected = current[attribute.codigo] ?? [];
const isMultiple = attribute.allow_multi_select ?? false;
let next: number[];
if (!isMultiple) {
next = [option.id];
} else if (selected.includes(option.id)) {
next = selected.filter((id) => id !== option.id);
} else {
next = [...selected, option.id];
}
return { ...current, [attribute.codigo]: next };
});
}
protected isColorOption(option: ProductAttributeOption): boolean {
@@ -134,51 +166,44 @@ export class ProductAttributeSelectorComponent {
attributes: ProductAttribute[],
defaultVariant: CatalogItemVariant | null,
): void {
const selections: Record<number, number> = {};
const selections: Record<string, number[]> = {};
for (const attribute of attributes) {
const optionId = this.findDefaultOptionId(attribute, defaultVariant);
if (optionId !== null) {
selections[attribute.id] = optionId;
const optionIds = this.findDefaultOptionIds(attribute, defaultVariant);
if (optionIds.length > 0) {
selections[attribute.codigo] = optionIds;
}
}
this.selectedAttributeOptions.set(selections);
}
private findDefaultOptionId(
private findDefaultOptionIds(
attribute: ProductAttribute,
variant: CatalogItemVariant | null,
): number | null {
): number[] {
if (!variant) {
return null;
return [];
}
const defaultValue = this.getDefaultVariantAttributeValue(attribute, variant.values);
if (!defaultValue) {
return null;
const defaultValues = this.getVariantAttributeValues(attribute, variant.values);
if (defaultValues.length === 0) {
return [];
}
const normalizedValue = this.normalizeText(defaultValue);
const matchByValue = attribute.options.find(
(option) => this.normalizeText(option.value) === normalizedValue,
);
if (matchByValue) {
return matchByValue.id;
}
const matchByLabel = attribute.options.find(
(option) => this.normalizeText(option.label) === normalizedValue,
);
return matchByLabel?.id ?? null;
return attribute.options
.filter(
(option) =>
defaultValues.includes(this.normalizeText(option.value)) ||
defaultValues.includes(this.normalizeText(option.label)),
)
.map((option) => option.id);
}
private getDefaultVariantAttributeValue(
private getVariantAttributeValues(
attribute: ProductAttribute,
variantAttributes: Record<string, string>,
): string | null {
variantAttributes: Record<string, string | string[]>,
): string[] {
const normalizedCodigo = this.normalizeText(attribute.codigo);
const normalizedNombre = this.normalizeText(attribute.nombre);
@@ -186,11 +211,11 @@ export class ProductAttributeSelectorComponent {
const normalizedKey = this.normalizeText(key);
if (normalizedKey === normalizedCodigo || normalizedKey === normalizedNombre) {
return value;
return (Array.isArray(value) ? value : [value]).map((item) => this.normalizeText(item));
}
}
return null;
return [];
}
private normalizeText(value: string | null | undefined): string {
@@ -234,7 +259,7 @@ export class ProductAttributeSelectorComponent {
}
private emitMatchingVariant(
selections: Record<number, number>,
selections: Record<string, number[]>,
variants: CatalogItemVariant[],
attributes: ProductAttribute[],
): void {
@@ -243,18 +268,22 @@ export class ProductAttributeSelectorComponent {
return;
}
const selectedValuesById: Record<number, string> = {};
const selectedValuesByCode: Record<string, string[]> = {};
let allSelected = true;
for (const attr of attributes) {
const selectedOptionId = selections[attr.id];
if (selectedOptionId === undefined) {
const selectedOptionIds = selections[attr.codigo] ?? [];
if (
selectedOptionIds.length === 0 ||
(!attr.allow_multi_select && selectedOptionIds.length !== 1)
) {
allSelected = false;
break;
}
const option = attr.options.find((o) => o.id === selectedOptionId);
if (option) {
selectedValuesById[attr.id] = this.normalizeText(option.value || option.label);
}
selectedValuesByCode[attr.codigo] = selectedOptionIds
.map((id) => attr.options.find((option) => option.id === id))
.filter((option): option is ProductAttributeOption => option !== undefined)
.map((option) => this.normalizeText(option.value || option.label))
.sort();
}
if (!allSelected) {
@@ -264,10 +293,12 @@ export class ProductAttributeSelectorComponent {
const matchingVariant = variants.find((variant) => {
return attributes.every((attr) => {
const selectedValue = selectedValuesById[attr.id];
const variantValue = this.getDefaultVariantAttributeValue(attr, variant.values);
if (!variantValue) return false;
return this.normalizeText(variantValue) === selectedValue;
const selectedValues = selectedValuesByCode[attr.codigo];
const variantValues = this.getVariantAttributeValues(attr, variant.values).sort();
return (
selectedValues.length === variantValues.length &&
selectedValues.every((value, index) => value === variantValues[index])
);
});
});

View File

@@ -1,11 +0,0 @@
<div class="sidebar-container">
<h2 class="sidebar-title">MI CUENTA</h2>
<div class="divider"></div>
<nav class="sidebar-nav">
<a routerLink="/mi-cuenta/datos-personales" routerLinkActive="active" class="nav-link"
>Datos Personales</a
>
<a routerLink="/mi-cuenta/compras" routerLinkActive="active" class="nav-link">Mis compras</a>
<a routerLink="/mi-cuenta/tickets" routerLinkActive="active" class="nav-link">Mis tickets</a>
</nav>
</div>

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
:host {
display: block;
border-bottom: 1px solid #dddddd;
border-bottom: 1px solid var(--border-color);
}
:host(:last-child) {
@@ -24,7 +24,7 @@
aspect-ratio: 4 / 3;
border-radius: 5px 0 0 5px;
background-color: #ffffff;
border: 1px solid #eaeaea;
border: 1px solid var(--border-color);
overflow: hidden;
img {

View File

@@ -23,7 +23,7 @@
justify-content: space-between;
align-items: center;
padding: 1rem 0;
border-bottom: 1px solid #DDDDDD;
border-bottom: 1px solid var(--border-color);
}
.skeleton-item:last-child {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -74,6 +74,7 @@
.ticket__actions {
display: flex;
grid-column: 4;
align-items: center;
gap: 8px;
}
@@ -91,6 +92,10 @@
color: #8a8a8a;
}
.ticket--disabled .ticket__actions {
grid-column: 3;
}
:host ::ng-deep .ticket--disabled button.ticket__qr-button:disabled {
--bs-btn-disabled-bg: #8a8a8a;
--bs-btn-disabled-border-color: #8a8a8a;

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,13 +1,21 @@
import { signal } from '@angular/core';
import { By } from '@angular/platform-browser';
import { TestBed } from '@angular/core/testing';
import { describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { TenantService } from '../../../../../../core/services/tenant.service';
import { ToastService } from '../../../../../../core/services/toast.service';
import { ModalService } from '../../../../../../core/services/modal.service';
import { TicketComponent } from './components/ticket/ticket.component';
import { TicketResponse, TicketService } from './ticket.service';
import { TicketsPage } from './tickets-page';
function withCustomLoading<T extends object>(service: T): T & { withCustomLoading: () => T } {
return Object.assign(service, {
withCustomLoading: () => service,
});
}
const ticket = (overrides: Partial<TicketResponse>): TicketResponse => ({
id: 1,
tenant_code: 'tenant',
@@ -26,6 +34,177 @@ const ticket = (overrides: Partial<TicketResponse>): TicketResponse => ({
});
describe('TicketsPage', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
{
provide: TenantService,
useValue: {
tenant: signal({
menues: [
{
id: 4,
code: 'account.tickets',
label: 'Entradas disponibles',
parent_menu_code: 'account',
content_type: 'dynamic',
route: '/mi-cuenta/tickets',
submenues: [],
},
],
}).asReadonly(),
},
},
],
});
});
it('shares the generated PDF as a file without text that Windows could prioritize', async () => {
const share = vi.fn().mockResolvedValue(undefined);
const canShare = vi.fn().mockReturnValue(true);
vi.stubGlobal('navigator', { canShare, share });
await TestBed.configureTestingModule({
imports: [TicketsPage],
providers: [{ provide: ToastService, useValue: { danger: vi.fn() } }],
})
.overrideComponent(TicketsPage, {
set: {
providers: [
{
provide: TicketService,
useValue: withCustomLoading({
getTickets: () => Promise.resolve([ticket({})]),
downloadPdf: () => Promise.resolve(new Blob(['pdf-content'])),
}),
},
],
},
})
.compileComponents();
const fixture = TestBed.createComponent(TicketsPage);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const page = fixture.nativeElement as HTMLElement;
page.querySelector<HTMLButtonElement>('[aria-label="Compartir ticket"]')?.click();
await vi.waitFor(() => expect(share).toHaveBeenCalledOnce());
const shareData = share.mock.calls[0][0] as ShareData;
const [sharedFile] = shareData.files ?? [];
expect(Object.keys(shareData)).toEqual(['files']);
expect(canShare).toHaveBeenCalledWith({ files: [sharedFile] });
expect(sharedFile).toBeInstanceOf(File);
expect(sharedFile.name).toBe('tickets_1.pdf');
expect(sharedFile.type).toBe('application/pdf');
expect(sharedFile.size).toBeGreaterThan(0);
vi.unstubAllGlobals();
});
it('shows a loading overlay and disables PDF actions while the file is generated', async () => {
let resolvePdf!: (pdf: Blob) => void;
const downloadPdf = vi.fn(
() =>
new Promise<Blob>((resolve) => {
resolvePdf = resolve;
}),
);
const createObjectUrl = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:ticket');
const revokeObjectUrl = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined);
let downloadedFilename = '';
const linkClick = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (
this: HTMLAnchorElement,
) {
downloadedFilename = this.download;
});
await TestBed.configureTestingModule({
imports: [TicketsPage],
providers: [{ provide: ToastService, useValue: { danger: vi.fn() } }],
})
.overrideComponent(TicketsPage, {
set: {
providers: [
{
provide: TicketService,
useValue: withCustomLoading({
getTickets: () => Promise.resolve([ticket({})]),
downloadPdf,
}),
},
],
},
})
.compileComponents();
const fixture = TestBed.createComponent(TicketsPage);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const page = fixture.nativeElement as HTMLElement;
page.querySelector<HTMLButtonElement>('[aria-label="Descargar ticket"]')?.click();
fixture.detectChanges();
expect(page.querySelector('.tickets-list__generating')?.textContent).toContain(
'Generando PDF...',
);
expect(page.querySelector('.tickets-list')?.getAttribute('aria-busy')).toBe('true');
expect(page.querySelector<HTMLButtonElement>('[aria-label="Descargar ticket"]')?.disabled).toBe(
true,
);
expect(page.querySelector<HTMLButtonElement>('[aria-label="Compartir ticket"]')?.disabled).toBe(
true,
);
resolvePdf(new Blob(['pdf'], { type: 'application/pdf' }));
await fixture.whenStable();
await vi.waitFor(() => expect(downloadedFilename).toBe('tickets_1.pdf'));
fixture.detectChanges();
expect(page.querySelector('.tickets-list__generating')).toBeNull();
expect(page.querySelector('.tickets-list')?.getAttribute('aria-busy')).toBe('false');
createObjectUrl.mockRestore();
revokeObjectUrl.mockRestore();
linkClick.mockRestore();
});
it('hides the active tickets header when there are no available tickets', async () => {
await TestBed.configureTestingModule({
imports: [TicketsPage],
providers: [{ provide: ToastService, useValue: { danger: vi.fn() } }],
})
.overrideComponent(TicketsPage, {
set: {
providers: [
{
provide: TicketService,
useValue: withCustomLoading({ getTickets: () => Promise.resolve([]) }),
},
],
},
})
.compileComponents();
const fixture = TestBed.createComponent(TicketsPage);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const page = fixture.nativeElement as HTMLElement;
expect(page.querySelector('.tickets-list__select-all')).toBeNull();
expect(page.querySelector('.tickets-list__bulk-actions')).toBeNull();
expect(page.textContent).not.toContain('TICKETS ACTIVOS');
expect(page.textContent).toContain('Aún no tenés tickets disponibles.');
});
it('separates expired and used tickets into the disabled inactive list', async () => {
const tickets = [
ticket({ id: 1 }),
@@ -40,7 +219,10 @@ describe('TicketsPage', () => {
.overrideComponent(TicketsPage, {
set: {
providers: [
{ provide: TicketService, useValue: { getTickets: () => Promise.resolve(tickets) } },
{
provide: TicketService,
useValue: withCustomLoading({ getTickets: () => Promise.resolve(tickets) }),
},
],
},
})
@@ -93,7 +275,9 @@ describe('TicketsPage', () => {
providers: [
{
provide: TicketService,
useValue: { getTickets: () => Promise.resolve([ticket({})]) },
useValue: withCustomLoading({
getTickets: () => Promise.resolve([ticket({})]),
}),
},
],
},

View File

@@ -3,12 +3,13 @@ import { Component, computed, inject, OnInit, signal } from '@angular/core';
import { ModalService } from '../../../../../../core/services/modal.service';
import { ToastService } from '../../../../../../core/services/toast.service';
import { IconButtonComponent } from '../../../../../../shared/components/icon-button/icon-button.component';
import { MenuContentSectionComponent } from '../../../../components/menu-content-section/menu-content-section.component';
import { TicketComponent } from './components/ticket/ticket.component';
import { TicketResponse, TicketService } from './ticket.service';
@Component({
selector: 'app-tickets-page',
imports: [IconButtonComponent, TicketComponent],
imports: [IconButtonComponent, TicketComponent, MenuContentSectionComponent],
providers: [TicketService],
templateUrl: './tickets-page.html',
styleUrl: './tickets-page.scss',
@@ -27,6 +28,7 @@ export class TicketsPage implements OnInit {
);
protected readonly selectedIds = signal<Set<number>>(new Set());
protected readonly isLoading = signal(true);
protected readonly isGeneratingPdf = signal(false);
protected readonly allSelected = computed(
() =>
this.activeTickets().length > 0 && this.selectedIds().size === this.activeTickets().length,
@@ -34,7 +36,7 @@ export class TicketsPage implements OnInit {
async ngOnInit(): Promise<void> {
try {
this.tickets.set(await this.ticketService.getTickets());
this.tickets.set(await this.ticketService.withCustomLoading().getTickets());
} catch {
this.toastService.danger('Hubo un error al cargar los tickets');
} finally {
@@ -86,4 +88,93 @@ export class TicketsPage implements OnInit {
ticket: ticket.ticket,
});
}
protected async downloadTicket(ticket: TicketResponse): Promise<void> {
await this.downloadTickets([ticket]);
}
protected async downloadSelected(): Promise<void> {
const tickets = this.activeTickets().filter((ticket) => this.selectedIds().has(ticket.id));
if (!tickets.length) {
this.toastService.danger('Seleccioná al menos un ticket para descargar.');
return;
}
await this.downloadTickets(tickets);
}
protected async shareTicket(ticket: TicketResponse): Promise<void> {
await this.shareTickets([ticket]);
}
protected async shareSelected(): Promise<void> {
const tickets = this.activeTickets().filter((ticket) => this.selectedIds().has(ticket.id));
if (!tickets.length) {
this.toastService.danger('Seleccioná al menos un ticket para compartir.');
return;
}
await this.shareTickets(tickets);
}
private async downloadTickets(tickets: TicketResponse[]): Promise<void> {
const pdf = await this.generateTicketsPdf(tickets);
if (pdf) this.downloadPdf(pdf, this.pdfFilename(tickets));
}
private async shareTickets(tickets: TicketResponse[]): Promise<void> {
const pdf = await this.generateTicketsPdf(tickets);
if (!pdf) return;
try {
const filename = this.pdfFilename(tickets);
const file = new File([pdf], filename, { type: 'application/pdf' });
if (navigator.canShare?.({ files: [file] })) {
await navigator.share({ files: [file] });
return;
}
this.downloadPdf(pdf, filename);
this.toastService.success('Tu navegador no permite compartir archivos; descargamos el PDF.');
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') return;
this.toastService.danger('No se pudo compartir el PDF de los tickets.');
}
}
private async generateTicketsPdf(tickets: TicketResponse[]): Promise<Blob | null> {
if (this.isGeneratingPdf()) return null;
this.isGeneratingPdf.set(true);
try {
return await this.ticketService
.withCustomLoading()
.downloadPdf(tickets.map((ticket) => ticket.id));
} catch {
this.toastService.danger('No se pudo generar el PDF de los tickets.');
return null;
} finally {
this.isGeneratingPdf.set(false);
}
}
private pdfFilename(tickets: TicketResponse[]): string {
return `tickets_${tickets.map((ticket) => ticket.id).join('_')}.pdf`;
}
private downloadPdf(pdf: Blob, filename: string): void {
const url = URL.createObjectURL(pdf);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 0);
}
}

View File

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

View File

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

View File

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

View File

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

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