134 Commits

Author SHA1 Message Date
32f86d14d6 fix(store-footer): constrain brand logo dimensions 2026-09-21 15:19:45 -03:00
49eaf0ad65 feat(tenant): expose timezone in storefront model 2026-09-21 15:19:45 -03:00
db97de6711 feat(event-detail): disable cancel button when no items are selected 2026-09-21 11:19:38 -03:00
c876b2b171 feat(store-header): enhance immersive header with compact mode and dynamic background 2026-09-21 11:18:24 -03:00
f6ff91ff3b refactor(map): change map style from minimal-light to standard across components 2026-09-21 11:08:53 -03:00
acac2858c3 style(event-detail): enlarge social icons 2026-09-21 11:00:00 -03:00
cd1c99f487 feat(checkout): show event purchase summary 2026-09-21 10:59:51 -03:00
607892173d Reapply "feat(header): refactor immersive store header for improved search functionality and styling"
This reverts commit 52f7b98162.
2026-09-21 10:16:57 -03:00
52f7b98162 Revert "feat(header): refactor immersive store header for improved search functionality and styling"
This reverts commit a06f8146e5.
2026-09-21 10:08:55 -03:00
a06f8146e5 feat(header): refactor immersive store header for improved search functionality and styling 2026-09-21 10:07:49 -03:00
fd60880812 feat(event-category): add event category page and update event listing with category filtering 2026-09-21 09:40:50 -03:00
e0b1f27b97 feat(categories): separate event and product navigation 2026-09-21 09:24:24 -03:00
e7d24607a6 feat(menus): guard public event routes 2026-09-21 09:16:44 -03:00
865893b96b feat(events): add category-filtered event pages 2026-09-21 09:16:44 -03:00
14bda25c56 feat(event-detail): enhance event detail page with improved layout and additional features
feat(counter-to): add countdown component for event timing
feat(fonts): include Gotham Book font for better typography
fix(location-map): make map height responsive with CSS variables
2026-09-18 16:37:39 -03:00
6997550767 feat(events): add multi-event storefront pages 2026-09-18 14:33:49 -03:00
657ef1d89f feat(events): add storefront event API client 2026-09-18 14:33:42 -03:00
018b1d8ec7 chore(storefront): log tenant bootstrap data in development 2026-09-18 11:42:33 -03:00
93a9ca7e5c fix(storefront): read active event social media from bootstrap 2026-09-18 11:32:29 -03:00
6ce734b2c2 feat(store-header): implement immersive categories and update header properties 2026-09-18 09:49:51 -03:00
7f4aa81d4b feat: add immersive store header with carousel and Smooch font
- Implemented a new immersive store header component with a background image, logo, hero content, and a continuous image carousel.
- Integrated the Smooch font for enhanced typography in the header.
- Updated the store layout to support the new immersive header type.
- Added necessary styles and templates for the immersive header and carousel components.
- Enhanced unit tests to cover the new immersive header functionality.
2026-09-18 09:11:09 -03:00
6927da6342 feat(store-header): implement standard store header with dynamic properties 2026-09-17 16:49:12 -03:00
2f98fd6234 feat(tenant): add header_type property to Tenant interface 2026-09-17 16:02:21 -03:00
de5c533d85 refactor(storefront): consume storefront website type 2026-09-17 14:26:04 -03:00
90a0ac3e36 Merge pull request 'feature/refund' (#6) from feature/refund into homologacion
Reviewed-on: https://gitea.quo.ar/tbianchini/shopit-front/pulls/6
2026-09-17 15:19:23 +00:00
de10de2376 feat(cart): prevent automatic variant replacement and handle selection errors 2026-09-17 11:39:43 -03:00
4923010afb refactor(event): replace bootstrap notices with claimed notices 2026-09-16 08:19:28 -03:00
f87f7f55a9 feat(event): display pending date change notices 2026-09-15 17:10:27 -03:00
17dbf52181 feat(event): show date status information in storefront 2026-09-15 17:10:00 -03:00
a7e1d8995f fix(modal): preserve sequential dialogs 2026-09-14 14:50:15 -03:00
b5fae81795 feat(storefront): show event date notices 2026-09-14 14:50:11 -03:00
8664c86915 refactor(purchases): remove duplicated refund amount 2026-09-14 12:29:52 -03:00
9e72ec0c60 feat(tenant): add ticket refund configuration options 2026-09-11 16:27:23 -03:00
e9aae7a4c3 feat(tickets): handle refunded ticket states 2026-09-10 16:10:54 -03:00
6545be498f feat(tenant): add refund configuration fields 2026-09-10 15:29:47 -03:00
8c15ad7cc1 refactor(modal): replace 'content' with 'description' and update related tests 2026-09-09 08:50:05 -03:00
f859eba8a8 fix(logout): enhance logout handling with success and error toasts 2026-09-07 10:14:11 -03:00
8ba34c6c7a fix(auth): update logout response to include success message and adjust types
fix(tenant): add asset_url to Tenant interface and implement preconnect logic
2026-09-07 09:30:06 -03:00
d7c015e71e fix(product-list): prioritize first image in non-carousel layouts and update tests 2026-09-07 09:04:03 -03:00
d8d608c435 fix(product-carousel): correct track variable in thumbnail loop 2026-09-07 09:03:34 -03:00
985316d3b4 fix(product-attribute-selector): improve variant availability logic and add tests for maximum quantity handling 2026-09-04 11:36:21 -03:00
d601cc1984 fix(product-attribute-selector): update variant values type and normalize value extraction 2026-09-04 11:36:13 -03:00
70d47ac310 Merge pull request 'homologacion' (#4) from homologacion into main
Reviewed-on: https://gitea.quo.ar/tbianchini/shopit-front/pulls/4
2026-09-04 12:01:45 +00:00
5d1d7086c7 Merge branch 'develop' into homologacion 2026-09-03 13:39:41 -03:00
9a6ec3d1f2 feat(hero-banner): add conditional class for media presence and adjust styles 2026-09-03 13:39:23 -03:00
38ba5f4195 Merge branch 'develop' into homologacion 2026-09-03 08:55:50 -03:00
4bd74d95cb Merge branch 'fix/zoom_block' of https://gitea.quo.ar/tbianchini/shopit-front into fix/zoom_block 2026-09-03 08:37:54 -03:00
6f87aae939 feat(modal): enhance modal behavior and viewport handling for iOS devices 2026-09-03 08:37:51 -03:00
71ef09b413 feat(image-modal): enhance gesture handling and improve pinch zoom functionality 2026-09-03 08:37:51 -03:00
1d54cb1c48 feat(image-modal): enhance swipe-to-dismiss functionality with settling animation 2026-09-03 08:37:51 -03:00
7d52e11bca feat(image-modal): add swipe-to-dismiss functionality for zoomed images 2026-09-03 08:37:51 -03:00
ebae063a0f feat(ticket-selector): implement zoomable image feature for seating map 2026-09-03 08:37:51 -03:00
f0c1e74c1b fix(viewport): update viewport meta tag to disable user scaling 2026-09-03 08:37:51 -03:00
709859e731 feat(componentes-test): add image modal demo 2026-09-03 08:37:51 -03:00
e95a7612c2 test(modal): cover image viewer behavior 2026-09-03 08:37:51 -03:00
8bc0e165c7 feat(modal): add zoomable image viewer 2026-09-03 08:37:51 -03:00
158b28ec1a feat(checkout-page): implement countdown expiration check and purchase validation 2026-09-03 08:36:41 -03:00
8c22485d49 feat(modal): enhance modal behavior and viewport handling for iOS devices 2026-09-02 15:17:32 -03:00
2462f25f2f feat(image-modal): enhance gesture handling and improve pinch zoom functionality 2026-09-02 14:52:55 -03:00
63b01f366a feat(image-modal): enhance swipe-to-dismiss functionality with settling animation 2026-09-02 14:45:36 -03:00
ddd0b1b56d feat(image-modal): add swipe-to-dismiss functionality for zoomed images 2026-09-02 14:39:10 -03:00
2cad0c39b4 feat(ticket-selector): implement zoomable image feature for seating map 2026-09-02 14:26:12 -03:00
3089873232 fix(viewport): update viewport meta tag to disable user scaling 2026-09-02 14:14:27 -03:00
3fa0235163 feat(componentes-test): add image modal demo 2026-09-02 14:13:51 -03:00
0910d90363 test(modal): cover image viewer behavior 2026-09-02 14:13:45 -03:00
f3d446d012 feat(modal): add zoomable image viewer 2026-09-02 14:13:38 -03:00
71c462aaba Merge pull request 'homologacion' (#3) from homologacion into main
Reviewed-on: https://gitea.quo.ar/tbianchini/shopit-front/pulls/3
2026-08-31 11:36:15 +00:00
481eb30795 Merge branch 'main' into homologacion 2026-08-31 11:36:09 +00:00
afebc7f639 Merge pull request 'homo_experimental' (#2) from homo_experimental into homologacion
Reviewed-on: https://gitea.quo.ar/tbianchini/shopit-front/pulls/2
2026-08-31 11:25:31 +00:00
1ec48f7e21 Merge branch 'homo_experimental' into develop 2026-08-28 12:39:01 -03:00
07972760fc Merge branch 'fix/TL-1860' into homo_experimental 2026-08-28 11:28:13 -03:00
848c3b280f fix(product-vertical-with-cart-card): change overflow to visible for improved layout 2026-08-28 11:26:32 -03:00
c387a01d62 Merge branch 'fix/TL-1860' into develop 2026-08-28 09:27:59 -03:00
5d9adc0494 Merge branch 'fix/TL-1860' into homo_experimental 2026-08-28 09:27:30 -03:00
593580d648 fix(store-layout): ensure full width and minimum width for layout components 2026-08-28 09:23:27 -03:00
eb524c7d99 feat(checkout): show transfer candidate reason after polling 2026-08-27 16:55:21 -03:00
65e8436ae1 refactor(checkout): rename transfer candidate reasons 2026-08-27 16:55:13 -03:00
07500f699d feat(checkout): show candidate DNI distance 2026-08-27 16:04:13 -03:00
4997665da6 feat(checkout): show transfer candidate issue 2026-08-27 15:38:23 -03:00
dbfadf417a refactor(checkout): remove unused completion request 2026-08-27 12:11:16 -03:00
dddb086cd8 refactor(catalog): remove obsolete products request 2026-08-27 12:11:16 -03:00
a825b3693b fix(catalog): refresh after expired cart replacement 2026-08-27 09:54:21 -03:00
679342ec8d fix(checkout): defer catalog refresh for expired reservations 2026-08-27 09:54:15 -03:00
a5420e6a42 feat(checkout): refresh catalog availability on checkout failure 2026-08-27 09:50:33 -03:00
91b8788c91 feat(checkout): implement mobile countdown display and logic 2026-08-27 09:20:24 -03:00
b6a52ceb8c feat(checkout): enhance countdown synchronization and cleanup logic 2026-08-27 09:03:38 -03:00
51d051733c fix(store-layout): update checkout countdown logic and test description 2026-08-27 08:59:41 -03:00
f62cd08807 feat(store-header): display checkout countdown 2026-08-27 08:52:57 -03:00
d61b1e4b0a feat(checkout): synchronize purchase countdown 2026-08-27 08:52:42 -03:00
1bffefece3 fix(store-header): adjust category menu structure and set height for categories 2026-08-27 08:46:53 -03:00
b0d2cd05bc feat(checkout): add completePurchase method and update PurchaseStatusResponse 2026-08-26 17:00:35 -03:00
520397baa5 Merge pull request 'homologacion' (#1) from homologacion into main
Reviewed-on: https://gitea.quo.ar/tbianchini/shopit-front/pulls/1
2026-08-26 18:10:12 +00:00
591f53c102 Squashed commit of the following:
commit 38d03588b1
Merge: 8e987d0 6f4aa3b
Author: ncoronel <ncoronel@quo.ar>
Date:   Wed Aug 26 10:25:23 2026 -0300

    Merge branch 'fix/cart-expiration-notification' of https://gitea.quo.ar/tbianchini/shopit-front into fix/cart-expiration-notification

commit 8e987d0ce6
Author: ncoronel <ncoronel@quo.ar>
Date:   Wed Aug 26 10:02:26 2026 -0300

    fix(purchase-status): clear cart on approved purchase

commit e171b9a233
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 17:06:07 2026 -0300

    refactor(checkout): use purchase id route segment

commit 643d43adea
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 17:05:59 2026 -0300

    fix(checkout): handle expired purchase exits

commit d89d01b511
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:47:17 2026 -0300

    fix(checkout): refresh expired carts

commit eeb245209b
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:46:12 2026 -0300

    fix(checkout): show backend start errors

commit 9d3754c2d8
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:33:44 2026 -0300

    fix(cart): refresh state after expiration

commit 6f4aa3b1bd
Author: ncoronel <ncoronel@quo.ar>
Date:   Wed Aug 26 10:02:26 2026 -0300

    fix(purchase-status): clear cart on approved purchase

commit 2b342ec235
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 17:06:07 2026 -0300

    refactor(checkout): use purchase id route segment

commit d06b146104
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 17:05:59 2026 -0300

    fix(checkout): handle expired purchase exits

commit f4e0a9e028
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:47:17 2026 -0300

    fix(checkout): refresh expired carts

commit 8c2b738806
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:46:12 2026 -0300

    fix(checkout): show backend start errors

commit 1362d0c163
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:33:44 2026 -0300

    fix(cart): refresh state after expiration
2026-08-26 10:29:23 -03:00
85529f40f2 fix(register-page): implement password visibility toggle for registration fields 2026-08-26 10:02:56 -03:00
4a952af784 fix(favicon): update favicon handling to use a default SVG icon 2026-08-25 11:44:24 -03:00
b1d4c4d13d fix(reset-password): implement password visibility toggle for input fields 2026-08-25 11:26:56 -03:00
8861e42fff fix(stepper): add disabled state to prevent navigation and provide visual feedback 2026-08-24 14:07:12 -03:00
7fbdd3844f fix(purchase-detail): add in-review section with WhatsApp contact option 2026-08-24 12:35:33 -03:00
f2c1542e46 fix(purchase-list): refactor purchase list to consolidate in-review and paid purchases, add status message display 2026-08-24 12:35:25 -03:00
def86405dc fix(purchase-list): conditionally render paginator for in-review and paid purchases 2026-08-24 09:17:27 -03:00
870de3ca6e feat(purchase-list): implement pagination for in-review and paid purchases 2026-08-24 09:16:52 -03:00
d8d489e846 fix(checkout): increase polling max attempts for QR and transfer payments 2026-08-22 23:08:20 +00:00
4183d6386d fix(checkout): increase polling max attempts for QR and transfer payments 2026-08-22 23:06:38 +00:00
8089298383 feat(catalog): show product availability tooltips 2026-08-21 16:47:29 -03:00
d51db00247 feat(ui): add reusable information tooltip 2026-08-21 16:47:21 -03:00
88c3a08a23 fix(checkout): lock reviewed payments and refresh cart on exit 2026-08-21 16:16:11 -03:00
21e89805d0 fix(checkout): lock modifications after transfer submission 2026-08-21 15:50:18 -03:00
195ac73aed fix(checkout): disable cart access 2026-08-21 15:50:12 -03:00
c24a8b946d feat(catalog): refresh availability after cart changes 2026-08-21 14:19:32 -03:00
70060f65ec feat(cart): notify catalog availability changes 2026-08-21 14:19:28 -03:00
02ff829c06 refactor(checkout): cancel purchase without reloading cart 2026-08-21 13:42:59 -03:00
69c836a578 refactor(cart): preserve cart when checkout starts 2026-08-21 13:42:59 -03:00
87cc430f05 revert(checkout): keep tenant editing policy contract 2026-08-21 11:09:00 -03:00
3e9e35c681 refactor(checkout): remove purchase item editing client 2026-08-21 11:05:39 -03:00
0bd6dc3b22 fix(checkout): restore cart on every exit 2026-08-21 11:04:09 -03:00
f056c32f49 feat(checkout): improve error handling for expired purchases and refactor toast service usage 2026-08-21 10:29:47 -03:00
11df4dbe72 Merge branch 'feature/restore-cart-on-modify' into develop 2026-08-21 10:14:37 -03:00
9b69a1d387 feat(checkout): add error handling for expired purchases in checkout process 2026-08-21 10:10:20 -03:00
4251ca8a8a fix(checkout): redirect expired purchases from checkout 2026-08-20 17:01:34 -03:00
b14f34d3e8 test(checkout): cover cart restoration before editing 2026-08-20 16:30:09 -03:00
d636b2b82b feat(checkout): restore cart before modifying items 2026-08-20 16:28:58 -03:00
cfa1091116 feat(checkout): enhance payment submission with error handling and polling logic 2026-08-20 15:53:33 -03:00
d0b1607ff4 refactor(checkout): update checkout navigation logic to retain purchase state 2026-08-20 13:53:52 -03:00
92546a28c0 feat(catalog): add anchor IDs to featured groups 2026-08-20 12:46:14 -03:00
ce26fb5d1a feat(tenant): display contact details in store footer 2026-08-20 12:46:14 -03:00
38a57adf55 feat(checkout): implement transfer payment polling with cancellation 2026-08-20 11:09:52 -03:00
e1d9590905 feat(checkout): map editing policy to cart controls 2026-08-20 10:56:33 -03:00
00a01e1a8e feat(cart): open header cart from query param 2026-08-20 10:56:27 -03:00
56e011bc7a feat(cart): update cart layout styles for improved responsiveness and structure 2026-08-20 08:42:41 -03:00
5c6a03b10d feat(catalog): replace stock_tecnico with maximum_addable_quantity across components and services 2026-08-19 17:00:03 -03:00
cc446433f6 Merge branch 'homologacion' 2026-08-19 15:36:19 -03:00
2d0664a7c7 feat(cart): implement cart editing policy with granular permissions 2026-08-19 10:16:06 -03:00
2192eea09f Merge branch 'homologacion' 2026-08-18 17:26:28 -03:00
755d6a9903 feat(angular.json): enhance production and homo configurations with optimization and license extraction 2026-08-18 15:18:01 -03:00
188 changed files with 7741 additions and 1471 deletions

View File

@@ -47,6 +47,9 @@
},
"configurations": {
"production": {
"optimization": true,
"extractLicenses": true,
"sourceMap": false,
"budgets": [
{
"type": "initial",
@@ -84,6 +87,9 @@
]
},
"homo": {
"optimization": true,
"extractLicenses": true,
"sourceMap": false,
"fileReplacements": [
{
"replace": "src/environments/environment.ts",

Binary file not shown.

View File

@@ -0,0 +1,95 @@
Copyright (c) 2011, Pablo Impallari (www.impallari.com|impallari@gmail.com),
Copyright (c) 2011, Igino Marini. (www.ikern.com|mail@iginomarini.com),
with Reserved Font Name Kaushan Script.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@@ -0,0 +1,93 @@
Copyright 2017 The Smooch Project Authors (https://github.com/googlefonts/smooch)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

View File

@@ -1,3 +1,11 @@
@font-face {
font-family: 'Smooch';
src: url('/fonts/Smooch/Smooch-Regular.ttf') format('truetype');
font-style: normal;
font-weight: 400;
font-display: swap;
}
@font-face {
font-family: "Gotham";
src: url("/fonts/Gotham/Gotham Thin.otf") format("opentype");
@@ -30,6 +38,14 @@
font-display: swap;
}
@font-face {
font-family: "Gotham Book";
src: url("/fonts/Gotham/Gotham Book.otf") format("opentype");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Gotham";
src: url("/fonts/Gotham/Gotham Italic.otf") format("opentype");

View File

@@ -14,7 +14,6 @@ describe('isStoreCatalogRequest', () => {
});
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'],

View File

@@ -17,7 +17,7 @@ import { TenantUrlSerializer } from './core/services/tenant-url.serializer';
export function isStoreCatalogRequest(request: HttpRequest<unknown>): boolean {
return (
request.method === 'GET' &&
/\/api\/tenants\/[^/?#]+\/(?:catalog(?:\/featured-groups\/\d+\/items)?|catalog-items(?:\/\d+)?)\/?(?:[?#]|$)/.test(
/\/api\/tenants\/[^/?#]+\/(?:catalog(?:\/featured-groups\/\d+\/items)?|catalog-items(?:\/\d+)?|events(?:\/\d+)?)\/?(?:[?#]|$)/.test(
request.urlWithParams,
)
);

View File

@@ -7,6 +7,7 @@ import { of } from 'rxjs';
import { App } from './app';
import { AuthService } from './core/services/auth/auth.service';
import { CartService } from './core/services/cart/cart.service';
import { CheckoutService } from './core/services/checkout.service';
import { Tenant } from './core/services/tenant.interface';
import { TenantService } from './core/services/tenant.service';
import { routes } from './app.routes';
@@ -97,6 +98,21 @@ async function renderAppAt(
{
provide: AuthService,
useValue: authService
},
{
provide: CheckoutService,
useValue: {
withCustomLoading() {
return this;
},
getPurchase: vi.fn().mockResolvedValue({
id: 25,
status: 'created',
items: [],
subtotal: '0.00',
total: '0.00'
})
}
}
]
}).compileComponents();
@@ -246,15 +262,33 @@ describe('app routes', () => {
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));
it('redirects unauthenticated users from /checkout/:id to /login', async () => {
const { router } = await renderAppAt('/checkout/25', createTenantServiceStub(), createAuthServiceStub(false));
expect(router.url).toBe('/login?returnUrl=%2Fcheckout');
expect(router.url).toBe('/login?returnUrl=%2Fcheckout%2F25');
});
it('allows authenticated users to access /checkout', async () => {
const { router } = await renderAppAt('/checkout', createTenantServiceStub(), createAuthServiceStub(true));
it('allows authenticated users to access /checkout/:id', async () => {
const checkoutTenant = {
...tenant,
menues: [
{
id: 1,
code: 'checkout',
label: 'Checkout',
parent_menu_code: null,
content_type: 'dynamic' as const,
route: '/checkout',
submenues: []
}
]
};
const { router } = await renderAppAt(
'/checkout/25',
createTenantServiceStub('ready', checkoutTenant),
createAuthServiceStub(true)
);
expect(router.url).toBe('/checkout');
expect(router.url).toBe('/checkout/25');
});
});

View File

@@ -1,10 +1,7 @@
import '@angular/compiler';
import { signal } from '@angular/core';
import { TestBed, getTestBed } from '@angular/core/testing';
import {
BrowserTestingModule,
platformBrowserTesting
} from '@angular/platform-browser/testing';
import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
import { provideRouter } from '@angular/router';
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
@@ -27,7 +24,7 @@ const tenant: Tenant = {
footer_bg_color: '#313131',
header_logo: 'https://example.com/header.png',
footer_logo: 'https://example.com/footer.png',
categories: []
categories: [],
};
function createTenantServiceStub(status: 'ready' | 'not-found', currentTenant: Tenant | null) {
@@ -38,17 +35,14 @@ function createTenantServiceStub(status: 'ready' | 'not-found', currentTenant: T
tenant: tenantState.asReadonly(),
status: statusState.asReadonly(),
getTenant: () => tenantState(),
bootstrap: vi.fn().mockResolvedValue(undefined)
bootstrap: vi.fn().mockResolvedValue(undefined),
};
}
describe('App', () => {
beforeAll(() => {
try {
getTestBed().initTestEnvironment(
BrowserTestingModule,
platformBrowserTesting()
);
getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
} catch {
// Test environment may already be initialized by another setup entrypoint.
}
@@ -65,9 +59,9 @@ describe('App', () => {
provideRouter([]),
{
provide: TenantService,
useValue: createTenantServiceStub('ready', tenant)
}
]
useValue: createTenantServiceStub('ready', tenant),
},
],
}).compileComponents();
const fixture = TestBed.createComponent(App);
@@ -75,26 +69,27 @@ describe('App', () => {
expect(fixture.componentInstance).toBeTruthy();
expect(fixture.nativeElement.style.getPropertyValue('--tenant-primary')).toBe(
tenant.primary_color
tenant.primary_color,
);
expect(fixture.nativeElement.style.getPropertyValue('--tenant-secondary')).toBe(
tenant.secondary_color
tenant.secondary_color,
);
expect(fixture.nativeElement.style.getPropertyValue('--tenant-danger')).toBe(
tenant.danger_color
tenant.danger_color,
);
expect(fixture.nativeElement.style.getPropertyValue('--tenant-primary-rgb')).toBe(
'99, 118, 243'
'99, 118, 243',
);
expect(fixture.nativeElement.style.getPropertyValue('--tenant-secondary-rgb')).toBe(
'160, 160, 160'
'160, 160, 160',
);
expect(fixture.nativeElement.style.getPropertyValue('--tenant-danger-rgb')).toBe(
'255, 136, 136'
'255, 136, 136',
);
expect(document.title).toBe(tenant.site_title);
expect(document.head.querySelector<HTMLLinkElement>('link[rel~="icon"]')?.getAttribute('href'))
.toBe(tenant.favicon);
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 () => {
@@ -104,17 +99,21 @@ describe('App', () => {
provideRouter([]),
{
provide: TenantService,
useValue: createTenantServiceStub('not-found', null)
}
]
useValue: createTenantServiceStub('not-found', null),
},
],
}).compileComponents();
const fixture = TestBed.createComponent(App);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('No encontramos una tienda para este dominio');
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');
expect(
document.head.querySelector<HTMLLinkElement>('link[rel~="icon"]')?.getAttribute('href'),
).toBe("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'/%3E");
});
});

View File

@@ -9,9 +9,13 @@ import { GlobalLoadingComponent } from './shared/components/global-loading/globa
import { ModalHostComponent } from './shared/components/modal-host/modal-host.component';
import { ToastContainerComponent } from './shared/components/toast-container/toast-container.component';
const EMPTY_FAVICON = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'/%3E";
function hexToRgb(hex: string): string {
const cleanHex = hex.replace('#', '').trim();
let r = 0, g = 0, b = 0;
let r = 0,
g = 0,
b = 0;
if (cleanHex.length === 3) {
r = parseInt(cleanHex[0] + cleanHex[0], 16);
g = parseInt(cleanHex[1] + cleanHex[1], 16);
@@ -26,12 +30,7 @@ function hexToRgb(hex: string): string {
@Component({
selector: 'app-root',
imports: [
RouterOutlet,
ToastContainerComponent,
ModalHostComponent,
GlobalLoadingComponent
],
imports: [RouterOutlet, ToastContainerComponent, ModalHostComponent, GlobalLoadingComponent],
templateUrl: './app.html',
styleUrl: './app.scss',
host: {
@@ -54,8 +53,8 @@ function hexToRgb(hex: string): string {
'[style.--tenant-header-bg]': 'tenantHeaderBgColor()',
'[style.--tenant-footer-bg]': 'tenantFooterBgColor()',
'[style.--color-header-bg]': 'tenantHeaderBgColor()',
'[style.--color-footer-bg]': 'tenantFooterBgColor()'
}
'[style.--color-footer-bg]': 'tenantFooterBgColor()',
},
})
export class App {
private readonly tenantService = inject(TenantService);
@@ -66,7 +65,7 @@ export class App {
effect(() => {
const tenant = this.tenantService.tenant();
const siteTitle = tenant?.site_title?.trim() || 'ShopitFront';
const faviconHref = tenant?.favicon || 'favicon.ico';
const faviconHref = tenant?.favicon || EMPTY_FAVICON;
this.title.setTitle(siteTitle);
@@ -85,37 +84,27 @@ export class App {
protected readonly status = this.tenantService.status;
protected readonly tenantPrimaryColor = computed(
() => this.tenantService.tenant()?.primary_color ?? DEFAULT_TENANT_BRANDING.primaryColor
() => this.tenantService.tenant()?.primary_color ?? DEFAULT_TENANT_BRANDING.primaryColor,
);
protected readonly tenantSecondaryColor = computed(
() => this.tenantService.tenant()?.secondary_color ?? DEFAULT_TENANT_BRANDING.secondaryColor
() => this.tenantService.tenant()?.secondary_color ?? DEFAULT_TENANT_BRANDING.secondaryColor,
);
protected readonly tenantDangerColor = computed(
() => this.tenantService.tenant()?.danger_color ?? DEFAULT_TENANT_BRANDING.dangerColor
() => this.tenantService.tenant()?.danger_color ?? DEFAULT_TENANT_BRANDING.dangerColor,
);
protected readonly tenantSuccessColor = computed(
() => this.tenantService.tenant()?.success_color ?? DEFAULT_TENANT_BRANDING.successColor
);
protected readonly tenantPrimaryColorRgb = computed(() =>
hexToRgb(this.tenantPrimaryColor())
() => this.tenantService.tenant()?.success_color ?? DEFAULT_TENANT_BRANDING.successColor,
);
protected readonly tenantPrimaryColorRgb = computed(() => hexToRgb(this.tenantPrimaryColor()));
protected readonly tenantSecondaryColorRgb = computed(() =>
hexToRgb(this.tenantSecondaryColor())
);
protected readonly tenantDangerColorRgb = computed(() =>
hexToRgb(this.tenantDangerColor())
);
protected readonly tenantSuccessColorRgb = computed(() =>
hexToRgb(this.tenantSuccessColor())
hexToRgb(this.tenantSecondaryColor()),
);
protected readonly tenantDangerColorRgb = computed(() => hexToRgb(this.tenantDangerColor()));
protected readonly tenantSuccessColorRgb = computed(() => hexToRgb(this.tenantSuccessColor()));
protected readonly tenantHeaderBgColor = computed(
() =>
this.tenantService.tenant()?.header_bg_color ??
DEFAULT_TENANT_BRANDING.headerBgColor
() => this.tenantService.tenant()?.header_bg_color ?? DEFAULT_TENANT_BRANDING.headerBgColor,
);
protected readonly tenantFooterBgColor = computed(
() =>
this.tenantService.tenant()?.footer_bg_color ??
DEFAULT_TENANT_BRANDING.footerBgColor
() => this.tenantService.tenant()?.footer_bg_color ?? DEFAULT_TENANT_BRANDING.footerBgColor,
);
}

View File

@@ -1,8 +1,9 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter, Router, UrlTree } from '@angular/router';
import { provideRouter, Router, UrlSerializer, UrlTree } from '@angular/router';
import { Tenant } from '../services/tenant.interface';
import { TenantService } from '../services/tenant.service';
import { TenantUrlSerializer } from '../services/tenant-url.serializer';
import { hasMenuGuard } from './menu.guard';
const tenant: Tenant = {
@@ -36,9 +37,10 @@ function runGuard(menuCode: string, url: string, currentTenant: Tenant | null) {
TestBed.configureTestingModule({
providers: [
provideRouter([]),
{ provide: UrlSerializer, useClass: TenantUrlSerializer },
{
provide: TenantService,
useValue: { tenant: () => currentTenant },
useValue: { tenant: () => currentTenant, getTenant: () => currentTenant },
},
],
});
@@ -62,7 +64,7 @@ describe('hasMenuGuard', () => {
});
it('redirects a missing menu route to the store root', () => {
const result = runGuard('checkout', '/checkout', tenant);
const result = runGuard('checkout', '/checkout/25', tenant);
expect(result instanceof UrlTree).toBe(true);
expect(TestBed.inject(Router).serializeUrl(result as UrlTree)).toBe('/');
@@ -73,4 +75,16 @@ describe('hasMenuGuard', () => {
expect(runGuard('index', '/', tenantWithoutMenus)).toBe(true);
});
it('allows a tenant base path as the store root when its menu is missing', () => {
const tenantWithoutMenus = { ...tenant, base_path: '/onticket', menues: [] };
expect(runGuard('index', '/onticket', tenantWithoutMenus)).toBe(true);
});
it('allows a tenant base path with query parameters as the store root', () => {
const tenantWithoutMenus = { ...tenant, base_path: '/onticket', menues: [] };
expect(runGuard('index', '/onticket?ref=home', tenantWithoutMenus)).toBe(true);
});
});

View File

@@ -1,5 +1,5 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { CanActivateFn, PRIMARY_OUTLET, Router } from '@angular/router';
import { findMenu } from '../services/menu.utils';
import { TenantService } from '../services/tenant.service';
@@ -16,7 +16,9 @@ export const hasMenuGuard = (menuCode: string): CanActivateFn => {
const hasMenu = findMenu(tenant.menues ?? [], menuCode) !== undefined;
if (hasMenu || state.url === '/') {
const primarySegments = router.parseUrl(state.url).root.children[PRIMARY_OUTLET]?.segments ?? [];
if (hasMenu || primarySegments.length === 0) {
return true;
}

View File

@@ -22,14 +22,18 @@
<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">
<i class="fa-solid fa-phone" aria-hidden="true"></i>
<span>54 9 (0341) 6658247</span>
</div>
@if (address) {
<div class="d-flex align-items-center gap-2">
<i class="fa-solid fa-location-dot" aria-hidden="true"></i>
<span>{{ address }}</span>
</div>
}
@if (phone) {
<div class="d-flex align-items-center gap-2">
<i class="fa-solid fa-phone" aria-hidden="true"></i>
<span>{{ phone }}</span>
</div>
}
</div>
<p class="mb-0 small store-layout__muted-text store-layout__copyright">

View File

@@ -22,8 +22,9 @@
.store-layout__brand-logo {
display: block;
width: 100%;
height: 100%;
width: auto;
height: auto;
max-width: 130px;
max-height: 65px;
object-fit: contain;
object-position: left center;

View File

@@ -27,5 +27,7 @@ export class StoreFooterComponent {
@Input() logoUrl: string | null = null;
@Input() backgroundImageUrl: string | null = null;
@Input() storeName: string | null = null;
@Input() address: string | null = null;
@Input() phone: string | null = null;
readonly logoutClick = output<void>();
}

View File

@@ -0,0 +1,106 @@
<header class="immersive-header" [class.immersive-header--compact]="!showHero()">
<div
class="immersive-header__backdrop"
[style.background-image]="backgroundImageUrl() ? 'url(' + backgroundImageUrl() + ')' : null"
></div>
<div class="immersive-header__content">
<nav
class="immersive-header__nav container-xl px-3 px-md-4"
aria-label="Navegación de la tienda"
>
<a routerLink="/" class="immersive-header__brand" aria-label="Ir al inicio">
@if (logoUrl()) {
<img [src]="logoUrl()" alt="Logo del comercio" />
}
</a>
<div class="immersive-header__actions">
@if (displaySearchBar()) {
<form class="immersive-header__search" role="search" (submit)="submitSearch($event)">
<label class="visually-hidden" for="immersive-search-input">Buscar productos</label>
<app-input
id="immersive-search-input"
placeholder="Buscar"
type="search"
[(value)]="searchTerm"
[invalid]="showSearchError()"
(valueChange)="showSearchError.set(false)"
/>
@if (showSearchError()) {
<span id="immersive-search-error" class="immersive-header__search-error" role="alert"
>Ingresá al menos 3 caracteres.</span
>
}
</form>
}
@if (ticketsMenu(); as menu) {
<app-button
variant="secondary"
hostClass="immersive-header__tickets"
(click)="ticketsClick.emit()"
>
{{ menu.label }}
</app-button>
}
@if (displayCart()) {
<app-cart-icon
[quantity]="cartDisabled() ? null : cartQuantity()"
[disabled]="cartDisabled()"
[ariaLabel]="
cartDisabled() ? 'Carrito no disponible durante el checkout' : 'Carrito de compras'
"
(click)="onCartClick()"
/>
}
<div class="immersive-header__account">
<app-icon-button
variant="user"
ariaLabel="Mi cuenta"
ariaControls="immersive-user-dropdown"
[ariaExpanded]="isUserDropdownOpen()"
(clicked)="toggleUserDropdown()"
/>
@if (isUserDropdownOpen()) {
<app-user-dropdown
id="immersive-user-dropdown"
[user]="user()"
[accountNavigationMenus]="accountNavigationMenus()"
(logoutClick)="onLogoutClick()"
/>
}
</div>
</div>
</nav>
@if (checkoutRemainingTime(); as remainingTime) {
<div
class="immersive-header__timer container-xl px-3 px-md-4"
role="timer"
aria-label="Tiempo restante de compra"
>
Tiempo restante de compra: <strong>{{ remainingTime }}</strong>
</div>
}
@if (showHero()) {
@if (heroContent(); as content) {
<div class="immersive-header__copy">
<p class="immersive-header__eyebrow">{{ content.eyebrow }}</p>
<h1 class="immersive-header__title">{{ content.title }}</h1>
<p class="immersive-header__description">{{ content.description }}</p>
</div>
}
@if (carouselImages().length) {
<app-continuous-image-carousel
[images]="carouselImages()"
ariaLabel="Imágenes del evento"
/>
}
}
</div>
</header>

View File

@@ -0,0 +1,185 @@
:host {
display: block;
}
.immersive-header {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr);
isolation: isolate;
min-height: 640px;
color: #fff;
background: #f5f5f5;
}
.immersive-header__content {
grid-area: 1 / 1;
position: relative;
z-index: 1;
min-width: 0;
padding-top: clamp(1.75rem, 4vw, 2.5rem);
padding-bottom: 0.5rem;
}
.immersive-header__backdrop {
grid-area: 1 / 1;
position: relative;
z-index: 0;
background-color: #101010;
background-position: center 42%;
background-size: cover;
}
.immersive-header__backdrop::before,
.immersive-header__backdrop::after {
content: '';
position: absolute;
inset: 0;
}
.immersive-header__backdrop::before {
background: rgba(0, 0, 0, 0.7);
}
.immersive-header__backdrop::after {
background: linear-gradient(180deg, rgba(0, 0, 0, 0.48) 0%, rgba(0, 0, 0, 0.4) 47%, #f5f5f5 100%);
}
.immersive-header__nav {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1.5rem;
min-height: 2.25rem;
}
.immersive-header__brand {
display: inline-flex;
flex: none;
align-items: center;
}
.immersive-header__brand img {
display: block;
width: clamp(105px, 13vw, 145px);
max-height: 2.25rem;
object-fit: contain;
}
.immersive-header__actions {
display: flex;
align-items: center;
gap: clamp(0.35rem, 1vw, 1rem);
}
.immersive-header__search {
position: relative;
width: clamp(115px, 17vw, 205px);
}
.immersive-header__search-error {
position: absolute;
top: calc(100% + 0.25rem);
right: 0;
width: max-content;
color: var(--tenant-danger, #dc3545);
font-size: 0.7rem;
}
:host ::ng-deep .immersive-header__tickets {
width: auto;
}
.immersive-header__account {
position: relative;
display: flex;
}
.immersive-header__account app-user-dropdown {
position: absolute;
top: 100%;
right: 0;
z-index: 20;
color: #222;
}
:host ::ng-deep .immersive-header__actions .icon-btn,
:host ::ng-deep .immersive-header__actions .cart-icon {
color: var(--tenant-primary, #ff7006);
}
.immersive-header__copy {
position: relative;
z-index: 1;
margin: clamp(2.1rem, 5vw, 3.25rem) auto 0;
padding-inline: 1rem;
text-align: center;
}
.immersive-header__eyebrow,
.immersive-header__title {
font-family: 'Smooch', cursive;
font-weight: 400;
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.55);
}
.immersive-header__eyebrow {
margin: 0;
font-size: 50px;
line-height: 1;
}
.immersive-header__title {
margin: -0.15rem 0 0;
font-size: 140px;
line-height: 1.05;
}
.immersive-header__description {
margin: 1.15rem 0 0;
font-size: clamp(0.75rem, 1.4vw, 0.95rem);
text-shadow: 0 1px 5px rgba(0, 0, 0, 0.6);
}
.immersive-header__timer {
position: relative;
z-index: 1;
margin-top: 0.75rem;
text-align: right;
font-size: 0.75rem;
}
.immersive-header app-continuous-image-carousel {
display: block;
position: relative;
z-index: 1;
margin-top: clamp(3.25rem, 7vw, 5rem);
}
.immersive-header--compact {
min-height: 0;
padding-bottom: 1.5rem;
color: #666666;
background: transparent;
}
.immersive-header--compact .immersive-header__backdrop {
display: none;
}
:host ::ng-deep .immersive-header--compact .immersive-header__actions .icon-btn,
:host ::ng-deep .immersive-header--compact .immersive-header__actions .cart-icon {
color: #666666;
}
@media (max-width: 600px) {
.immersive-header__nav {
flex-wrap: wrap;
gap: 0.5rem;
}
.immersive-header__actions {
flex: 1;
justify-content: flex-end;
}
.immersive-header__search {
width: clamp(105px, 38vw, 180px);
}
.immersive-header__copy {
margin-top: 3rem;
}
.immersive-header__eyebrow {
font-size: clamp(2rem, 8vw, 50px);
}
.immersive-header__title {
font-size: clamp(3rem, 12vw, 5rem);
}
}

View File

@@ -0,0 +1,110 @@
import {
Component,
ElementRef,
HostListener,
computed,
inject,
input,
output,
signal,
} from '@angular/core';
import { RouterLink } from '@angular/router';
import { AuthUser } from '../../../services/auth/auth.interfaces';
import { Menu, WebsiteExtras } from '../../../services/tenant.interface';
import { ButtonComponent } from '../../../../shared/components/button/button.component';
import { CartIconComponent } from '../../../../shared/components/cart-icon/cart-icon.component';
import { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.component';
import { InputComponent } from '../../../../shared/components/input/input.component';
import { ContinuousImageCarouselComponent } from '../../../../shared/components/continuous-image-carousel/continuous-image-carousel.component';
import { UserDropdownComponent } from './user-dropdown/user-dropdown.component';
@Component({
selector: 'app-immersive-store-header',
imports: [
RouterLink,
ButtonComponent,
CartIconComponent,
IconButtonComponent,
InputComponent,
ContinuousImageCarouselComponent,
UserDropdownComponent,
],
templateUrl: './immersive-store-header.component.html',
styleUrl: './immersive-store-header.component.scss',
})
export class ImmersiveStoreHeaderComponent {
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
readonly logoUrl = input<string | null>(null);
readonly backgroundImageUrl = input<string | null>(null);
readonly heroContent = input<WebsiteExtras['immersiveHero']>(undefined);
readonly carouselImages = input<readonly string[]>([]);
readonly showHero = input(true);
readonly cartQuantity = input(0);
readonly user = input<AuthUser | null>(null);
readonly isAuthenticated = input(false);
readonly accountNavigationMenus = input<readonly Menu[]>([]);
readonly ticketsMenu = input<Menu | null>(null);
readonly displaySearchBar = input(true);
readonly displayCart = input(true);
readonly cartDisabled = input(false);
readonly checkoutRemainingSeconds = input<number | null>(null);
readonly cartClick = output<void>();
readonly ticketsClick = output<void>();
readonly loginClick = output<void>();
readonly logoutClick = output<void>();
readonly searchSubmit = output<string>();
protected searchTerm = '';
protected readonly showSearchError = signal(false);
protected readonly isUserDropdownOpen = signal(false);
protected readonly checkoutRemainingTime = computed(() => {
const seconds = this.checkoutRemainingSeconds();
if (seconds === null) return null;
return `${Math.floor(seconds / 60)
.toString()
.padStart(2, '0')}:${(seconds % 60).toString().padStart(2, '0')}`;
});
protected submitSearch(event?: Event): void {
event?.preventDefault();
const term = this.searchTerm.trim();
if (term.length < 3) {
this.showSearchError.set(true);
return;
}
this.showSearchError.set(false);
this.searchSubmit.emit(term);
}
protected toggleUserDropdown(): void {
if (!this.isAuthenticated()) {
this.loginClick.emit();
return;
}
this.isUserDropdownOpen.update((open) => !open);
}
protected onCartClick(): void {
if (!this.cartDisabled()) this.cartClick.emit();
}
protected onLogoutClick(): void {
this.isUserDropdownOpen.set(false);
this.logoutClick.emit();
}
@HostListener('document:click', ['$event'])
protected onDocumentClick(event: MouseEvent): void {
if (event.target instanceof Node && !this.host.nativeElement.contains(event.target)) {
this.isUserDropdownOpen.set(false);
}
}
@HostListener('document:keydown.escape')
protected onEscapeKey(): void {
this.isUserDropdownOpen.set(false);
}
}

View File

@@ -0,0 +1,192 @@
<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">
<div class="row align-items-center gy-3 gy-md-0">
<div class="col-auto col-md-auto order-1 store-layout__brand-column">
<a
routerLink="/"
class="store-layout__brand-slot d-inline-flex align-items-center gap-3 text-decoration-none"
data-testid="store-header-brand-slot"
aria-label="Logo del comercio"
>
@if (logoUrl()) {
<img class="store-layout__brand-logo" [src]="logoUrl()" alt="Logo del comercio" />
} @else {
<div class="store-layout__brand-logo-shell" aria-hidden="true">
<span class="store-layout__brand-logo-skeleton"></span>
</div>
}
</a>
</div>
@if (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]="cartDisabled() ? null : cartQuantity()"
[disabled]="cartDisabled()"
[ariaLabel]="
cartDisabled() ? 'Carrito no disponible durante el checkout' : 'Carrito de compras'
"
[title]="cartDisabled() ? 'Carrito no disponible durante el checkout' : 'Carrito'"
(click)="onCartClick()"
/>
}
<div class="store-layout__user-menu d-none d-md-inline-flex position-relative">
<app-icon-button
variant="user"
ariaLabel="Mi cuenta"
ariaControls="store-user-dropdown"
[ariaExpanded]="isUserDropdownOpen()"
title="Mi cuenta"
(clicked)="toggleUserDropdown()"
/>
@if (isUserDropdownOpen()) {
<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 class="store-layout__categories d-none d-md-block">
<div class="container-xl h-100 px-3 px-md-4 d-flex align-items-center">
@if (displayCategories()) {
<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>
}
@if (checkoutRemainingTime(); as remainingTime) {
<div
class="store-layout__checkout-timer ms-auto d-none d-md-flex align-items-baseline gap-3"
role="timer"
aria-label="Tiempo restante de compra"
>
<span class="store-layout__checkout-timer-label">Tiempo restante de compra:</span>
<span class="store-layout__checkout-timer-value">{{ remainingTime }}</span>
</div>
}
</div>
</div>
</header>

View File

@@ -0,0 +1,246 @@
.store-layout__header {
color: #ffffff;
background-color: var(--tenant-header-bg);
background-repeat: no-repeat;
background-position: center;
background-size: cover;
}
.store-layout__header-main {
border-bottom: 1px solid var(--border-color);
}
.store-layout__categories {
height: 3.5rem;
}
.store-layout__checkout-timer {
color: var(--tenant-primary);
font-weight: 700;
white-space: nowrap;
}
.store-layout__checkout-timer-label {
font-size: 13px;
}
.store-layout__checkout-timer-value {
font-size: 20px;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.store-layout__brand-slot {
min-width: 150px;
}
.store-layout__brand-column {
max-width: 12rem;
}
.store-layout__brand-logo-shell {
position: relative;
width: min(100%, 12rem);
height: 2.75rem;
}
.store-layout__brand-logo {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
}
.store-layout__brand-logo-skeleton {
display: block;
width: 100%;
height: 100%;
border-radius: 0.75rem;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0.12) 0%,
rgba(255, 255, 255, 0.22) 50%,
rgba(255, 255, 255, 0.12) 100%
);
background-size: 200% 100%;
animation: store-header-logo-skeleton 1.2s ease-in-out infinite;
}
.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: var(--border-color);
background-color: rgba(255, 255, 255, 0.96);
}
.store-layout__search-input::placeholder {
color: #666666;
}
.store-layout__search-input:focus {
border-color: var(--border-color);
box-shadow: none;
outline: none;
}
.store-layout__search-button {
color: #a0a0a0;
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: var(--border-color);
background-color: #ffffff;
}
.store-layout__action-button {
color: #a0a0a0;
}
.store-layout__action-button:hover,
.store-layout__action-button:focus-visible {
color: #8a8a8a;
}
.store-layout__user-menu {
position: relative;
display: inline-flex;
}
.store-layout__category-trigger {
font-size: 0.8rem;
letter-spacing: 0.02em;
color: #a0a0a0;
}
.store-layout__category-trigger:hover,
.store-layout__category-trigger:focus-visible {
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;
}
to {
background-position: -200% 0;
}
}
@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

@@ -0,0 +1,172 @@
import {
Component,
computed,
ElementRef,
HostListener,
inject,
input,
output,
signal,
} from '@angular/core';
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-standard-store-header',
imports: [
ButtonComponent,
CartIconComponent,
IconButtonComponent,
RouterLink,
ReactiveFormsModule,
UserDropdownComponent,
CategoryDropdownComponent,
],
templateUrl: './standard-store-header.component.html',
styleUrl: './standard-store-header.component.scss',
})
export class StandardStoreHeaderComponent {
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 cartDisabled = input(false);
readonly checkoutRemainingSeconds = input<number | null>(null);
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 });
protected readonly checkoutRemainingTime = computed(() => {
const remainingSeconds = this.checkoutRemainingSeconds();
if (remainingSeconds === null) {
return null;
}
const minutes = Math.floor(remainingSeconds / 60);
const seconds = remainingSeconds % 60;
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
});
protected onCartClick(): void {
if (this.cartDisabled()) {
return;
}
this.cartClick.emit();
}
@HostListener('document:click', ['$event'])
protected onDocumentClick(event: MouseEvent): void {
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 {
if (!this.isAuthenticated()) {
this.loginClick.emit();
return;
}
this.isUserDropdownOpen.update((isOpen) => !isOpen);
}
protected onLoginClick(): void {
this.isUserDropdownOpen.set(false);
this.loginClick.emit();
}
protected onLogoutClick(): void {
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

@@ -1,178 +1,68 @@
<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">
<div class="row align-items-center gy-3 gy-md-0">
<div class="col-auto col-md-auto order-1 store-layout__brand-column">
<a
routerLink="/"
class="store-layout__brand-slot d-inline-flex align-items-center gap-3 text-decoration-none"
data-testid="store-header-brand-slot"
aria-label="Logo del comercio"
>
@if (logoUrl()) {
<img class="store-layout__brand-logo" [src]="logoUrl()" alt="Logo del comercio" />
} @else {
<div class="store-layout__brand-logo-shell" aria-hidden="true">
<span class="store-layout__brand-logo-skeleton"></span>
</div>
}
</a>
</div>
@if (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"
ariaControls="store-user-dropdown"
[ariaExpanded]="isUserDropdownOpen()"
title="Mi cuenta"
(clicked)="toggleUserDropdown()"
/>
@if (isUserDropdownOpen()) {
<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>
@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>
@switch (headerType()) {
@case ('immersive') {
<app-immersive-store-header
[logoUrl]="logoUrl()"
[backgroundImageUrl]="backgroundImageUrl()"
[heroContent]="heroContent()"
[carouselImages]="carouselImages()"
[showHero]="showHero()"
[cartQuantity]="cartQuantity()"
[user]="user()"
[isAuthenticated]="isAuthenticated()"
[accountNavigationMenus]="accountNavigationMenus()"
[ticketsMenu]="ticketsMenu()"
[displaySearchBar]="displaySeachBar()"
[displayCart]="displayCart()"
[cartDisabled]="cartDisabled()"
[checkoutRemainingSeconds]="checkoutRemainingSeconds()"
(cartClick)="cartClick.emit()"
(ticketsClick)="ticketsClick.emit()"
(loginClick)="loginClick.emit()"
(logoutClick)="logoutClick.emit()"
(searchSubmit)="searchSubmit.emit($event)"
/>
}
</header>
@default {
<ng-container *ngTemplateOutlet="standardHeader" />
}
}
<ng-template #standardHeader>
<app-standard-store-header
[logoUrl]="logoUrl()"
[backgroundImageUrl]="backgroundImageUrl()"
[cartQuantity]="cartQuantity()"
[user]="user()"
[isAuthenticated]="isAuthenticated()"
[accountNavigationMenus]="accountNavigationMenus()"
[ticketsMenu]="ticketsMenu()"
[categories]="categories()"
[displayCategories]="displayCategories()"
[displaySeachBar]="displaySeachBar()"
[displayCart]="displayCart()"
[cartDisabled]="cartDisabled()"
[checkoutRemainingSeconds]="checkoutRemainingSeconds()"
(cartClick)="cartClick.emit()"
(ticketsClick)="ticketsClick.emit()"
(loginClick)="loginClick.emit()"
(logoutClick)="logoutClick.emit()"
(searchSubmit)="searchSubmit.emit($event)"
(categorySelect)="categorySelect.emit($event)"
/>
</ng-template>
@if (headerType() === 'immersive' && displayCategories() && categories().length) {
<nav class="immersive-categories" aria-label="Categorías">
<div class="immersive-categories__list">
@for (category of categories(); track category.id) {
<button
type="button"
class="immersive-categories__item"
(click)="categorySelect.emit(category)"
>
{{ category.nombre | uppercase }}
</button>
}
</div>
</nav>
}

View File

@@ -1,226 +1,48 @@
.store-layout__header {
color: #ffffff;
background-color: var(--tenant-header-bg);
background-repeat: no-repeat;
background-position: center;
:host {
display: block;
}
:host(.immersive-shell--compact) {
background-color: #f5f5f5;
background-position: center 42%;
background-size: cover;
}
.store-layout__header-main {
border-bottom: 1px solid var(--border-color);
:host(.immersive-shell--compact) .immersive-categories {
border-top: 1px solid rgba(255, 255, 255, 0.6);
background: transparent;
}
.store-layout__brand-slot {
min-width: 150px;
}
.store-layout__brand-column {
max-width: 12rem;
}
.store-layout__brand-logo-shell {
position: relative;
width: min(100%, 12rem);
height: 2.75rem;
}
.store-layout__brand-logo {
display: block;
.immersive-categories {
width: 100%;
height: 100%;
object-fit: contain;
overflow-x: auto;
background: #f5f5f5;
}
.store-layout__brand-logo-skeleton {
display: block;
width: 100%;
height: 100%;
border-radius: 0.75rem;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0.12) 0%,
rgba(255, 255, 255, 0.22) 50%,
rgba(255, 255, 255, 0.12) 100%
);
background-size: 200% 100%;
animation: store-header-logo-skeleton 1.2s ease-in-out infinite;
.immersive-categories__list {
display: flex;
align-items: center;
justify-content: center;
gap: clamp(1rem, 3vw, 2.5rem);
width: max-content;
min-width: 100%;
min-height: 3.5rem;
padding: 0.75rem 1rem;
}
.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 {
.immersive-categories__item {
flex: none;
border: 0;
padding: 0;
background: transparent;
color: #666666;
border-color: var(--border-color);
background-color: rgba(255, 255, 255, 0.96);
}
.store-layout__search-input::placeholder {
color: #666666;
}
.store-layout__search-input:focus {
border-color: var(--border-color);
box-shadow: none;
outline: none;
}
.store-layout__search-button {
color: #a0a0a0;
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: var(--border-color);
background-color: #ffffff;
}
.store-layout__action-button {
color: #a0a0a0;
}
.store-layout__action-button:hover,
.store-layout__action-button:focus-visible {
color: #8a8a8a;
}
.store-layout__user-menu {
position: relative;
display: inline-flex;
}
.store-layout__category-trigger {
font-size: 0.8rem;
letter-spacing: 0.02em;
color: #a0a0a0;
}
.store-layout__category-trigger:hover,
.store-layout__category-trigger:focus-visible {
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;
}
to {
background-position: -200% 0;
}
}
@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 {
font-size: 15px;
font-weight: 700;
white-space: nowrap;
cursor: pointer;
color: #8a8a8a;
&:hover,
&:focus-visible {
color: #666666;
}
}
.immersive-categories__item:hover,
.immersive-categories__item:focus-visible {
text-decoration: underline;
}

View File

@@ -1,35 +1,40 @@
import { Component, ElementRef, HostListener, inject, input, output, signal } from '@angular/core';
import { Router, RouterLink } from '@angular/router';
import { NgTemplateOutlet, UpperCasePipe } from '@angular/common';
import { Component, computed, input, output } from '@angular/core';
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';
import { Category, Menu, WebsiteExtras } from '../../../services/tenant.interface';
import { ImmersiveStoreHeaderComponent } from './immersive-store-header.component';
import { StandardStoreHeaderComponent } from './standard-store-header.component';
export type StoreHeaderType = 'standard' | 'immersive';
@Component({
selector: 'app-store-header',
imports: [
ButtonComponent,
CartIconComponent,
IconButtonComponent,
RouterLink,
ReactiveFormsModule,
UserDropdownComponent,
CategoryDropdownComponent,
],
imports: [NgTemplateOutlet, UpperCasePipe, StandardStoreHeaderComponent, ImmersiveStoreHeaderComponent],
templateUrl: './store-header.component.html',
styleUrl: './store-header.component.scss',
host: {
'[class.immersive-shell--compact]': 'isCompactImmersive()',
'[style.background-image]': 'compactBackground()',
},
})
export class StoreHeaderComponent {
private readonly elementRef = inject(ElementRef<HTMLElement>);
private readonly router = inject(Router);
readonly headerType = input<StoreHeaderType>('standard');
readonly logoUrl = input<string | null>(null);
readonly backgroundImageUrl = input<string | null>(null);
readonly heroContent = input<WebsiteExtras['immersiveHero']>(undefined);
readonly carouselImages = input<readonly string[]>([]);
readonly showHero = input(true);
protected readonly isCompactImmersive = computed(
() => this.headerType() === 'immersive' && !this.showHero(),
);
protected readonly compactBackground = computed(() => {
if (!this.isCompactImmersive()) return null;
const gradient =
'linear-gradient(180deg, rgba(245, 245, 245, 0.72) 0%, rgba(245, 245, 245, 0.88) 58%, #f5f5f5 100%)';
const image = this.backgroundImageUrl();
return image ? `${gradient}, url(${JSON.stringify(image)})` : gradient;
});
readonly cartQuantity = input<number>(0);
readonly user = input<AuthUser | null>(null);
readonly isAuthenticated = input<boolean>(false);
@@ -39,103 +44,13 @@ export class StoreHeaderComponent {
readonly displayCategories = input(true);
readonly displaySeachBar = input(true);
readonly displayCart = input(true);
readonly cartDisabled = input(false);
readonly checkoutRemainingSeconds = input<number | null>(null);
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() && !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 {
if (!this.isAuthenticated()) {
this.loginClick.emit();
return;
}
this.isUserDropdownOpen.update((isOpen) => !isOpen);
}
protected onLoginClick(): void {
this.isUserDropdownOpen.set(false);
this.loginClick.emit();
}
protected onLogoutClick(): void {
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

@@ -1,6 +1,10 @@
<div class="store-layout d-flex flex-column flex-grow-1">
<app-store-header
ngSkipHydration
[headerType]="tenant()?.header_type ?? 'standard'"
[heroContent]="tenant()?.extras?.immersiveHero"
[carouselImages]="tenant()?.extras?.immersiveHeroCarousel ?? []"
[showHero]="isHomeRoute()"
[cartQuantity]="cartQuantity()"
[logoUrl]="tenant()?.header_logo ?? null"
[backgroundImageUrl]="tenant()?.header_bg_image ?? null"
@@ -8,11 +12,13 @@
[isAuthenticated]="isAuthenticated()"
[accountNavigationMenus]="accountNavigationMenus()"
[ticketsMenu]="ticketsMenu() ?? null"
[categories]="tenant()?.categories ?? []"
[displayCategories]="tenant()?.display_categories ?? true"
[categories]="navigationCategories()"
[displayCategories]="displayCategories()"
[displaySeachBar]="tenant()?.display_seach_bar ?? true"
[displayCart]="displayCart()"
(cartClick)="isCartOpen.set(!isCartOpen())"
[cartDisabled]="isCheckoutRoute()"
[checkoutRemainingSeconds]="headerCheckoutRemainingSeconds()"
(cartClick)="onCartClick()"
(ticketsClick)="onTicketsClick()"
(loginClick)="onLoginClick()"
(logoutClick)="onLogoutClick()"
@@ -20,7 +26,7 @@
(categorySelect)="onCategorySelect($event)"
/>
@if (displayCart() && isCartOpen()) {
@if (displayCart() && !isCheckoutRoute() && 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
@@ -29,7 +35,11 @@
[subtotal]="cartSubtotal()"
[discount]="cartDiscount()"
[total]="cartTotal()"
[readonly]="!cartEditingEnabled()"
[readonly]="!canModifyCart()"
[allowModify]="canModifyCart()"
[allowUpdateQuantity]="canUpdateCartQuantity()"
[allowUpdateVariant]="canUpdateCartVariant()"
[allowDelete]="canDeleteCartItems()"
[backgroundColor]="'#ffffff'"
(closed)="isCartOpen.set(false)"
>
@@ -63,6 +73,8 @@
[logoUrl]="tenant()?.footer_logo ?? null"
[backgroundImageUrl]="tenant()?.footer_bg_image ?? null"
[storeName]="tenant()?.nombre ?? null"
[address]="tenant()?.address ?? null"
[phone]="tenant()?.phone ?? null"
(logoutClick)="onLogoutClick()"
/>
</div>

View File

@@ -1,5 +1,7 @@
:host {
display: flex;
width: 100%;
min-width: 0;
min-height: 100dvh;
background: #f5f5f5;
color: #202020;
@@ -7,6 +9,8 @@
.store-layout {
position: relative;
width: 100%;
min-width: 0;
}
.store-layout__cart-overlay {
@@ -21,6 +25,8 @@
.store-layout__cart-dropdown {
position: absolute;
display: flex;
flex-direction: column;
top: 100px;
right: calc((100% - 1320px) / 2 + 1.5rem);
width: 100%;
@@ -34,6 +40,10 @@
overflow: hidden;
animation: store-layout-slide-down 0.2s ease-out;
> app-cart {
min-height: 0;
}
@media (max-width: 1400px) {
right: 1.5rem;
}

View File

@@ -1,10 +1,18 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { signal } from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http';
import { TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { provideRouter, Router } from '@angular/router';
import {
ActivatedRoute,
convertToParamMap,
ParamMap,
provideRouter,
Router,
UrlSerializer,
} from '@angular/router';
import { of } from 'rxjs';
import { BehaviorSubject, of, throwError } from 'rxjs';
import { Tenant } from '../../services/tenant.interface';
import { TenantService } from '../../services/tenant.service';
@@ -15,13 +23,19 @@ 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 { StandardStoreHeaderComponent } from './store-header/standard-store-header.component';
import { CheckoutService } from '../../services/checkout.service';
import { CheckoutCountdownService } from '../../services/checkout-countdown.service';
import { TenantUrlSerializer } from '../../services/tenant-url.serializer';
import { EventDateNoticeService } from '../../services/event-date-notice.service';
const tenant: Tenant = {
id: 1,
codigo: 'test',
nombre: 'Test Tenant',
dominio: 'localhost',
address: 'Calle Test 123, Rosario',
phone: '+54 341 555 1234',
primary_color: '#6376F3',
secondary_color: '#A0A0A0',
danger_color: '#FF8888',
@@ -32,6 +46,13 @@ const tenant: Tenant = {
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',
cart_editing_policy: {
code: 'full',
allow_modify: true,
allow_delete: true,
allow_update_quantity: true,
allow_update_variant: true,
},
categories: [],
menues: [
{
@@ -132,12 +153,16 @@ describe('StoreLayoutComponent', () => {
let tenantState = signal<Tenant | null>(tenant);
let cartState = signal<Cart | null>(null);
let authUserState = signal<AuthUser | null>(null);
let checkoutRemainingSecondsState = signal<number | null>(null);
let checkoutServiceStub: { startCheckout: ReturnType<typeof vi.fn> };
let queryParamMapState: BehaviorSubject<ParamMap>;
beforeEach(async () => {
tenantState = signal<Tenant | null>(tenant);
cartState = signal<Cart | null>(null);
authUserState = signal<AuthUser | null>(null);
checkoutRemainingSecondsState = signal<number | null>(null);
queryParamMapState = new BehaviorSubject(convertToParamMap({}));
const isAuthenticatedState = signal(false);
checkoutServiceStub = {
startCheckout: vi.fn().mockResolvedValue({ id: 55 }),
@@ -147,6 +172,11 @@ describe('StoreLayoutComponent', () => {
imports: [StoreLayoutComponent],
providers: [
provideRouter([]),
{ provide: UrlSerializer, useClass: TenantUrlSerializer },
{
provide: ActivatedRoute,
useValue: { queryParamMap: queryParamMapState.asObservable() },
},
{
provide: TenantService,
useValue: {
@@ -183,13 +213,25 @@ describe('StoreLayoutComponent', () => {
useValue: {
user: authUserState,
isAuthenticated: isAuthenticatedState,
logout: vi.fn().mockReturnValue(of(void 0)),
logout: vi.fn().mockReturnValue(of({ message: 'Sesión cerrada correctamente.' })),
},
},
{
provide: CheckoutService,
useValue: checkoutServiceStub,
},
{
provide: CheckoutCountdownService,
useValue: {
remainingSeconds: checkoutRemainingSecondsState.asReadonly(),
},
},
{
provide: EventDateNoticeService,
useValue: {
claim: vi.fn().mockReturnValue(of({ data: [] })),
},
},
],
}).compileComponents();
});
@@ -206,6 +248,206 @@ describe('StoreLayoutComponent', () => {
expect(compiled.querySelector('app-store-footer .store-layout__footer')).not.toBeNull();
});
it('renders the immersive header with configured copy and carousel images', () => {
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const header = fixture.debugElement.query(By.directive(StoreHeaderComponent));
expect(header.componentInstance.headerType()).toBe('standard');
expect(element.querySelector('app-standard-store-header')).not.toBeNull();
tenantState.set({
...tenant,
header_type: 'immersive',
extras: {
immersiveHero: {
eyebrow: 'Encendé tu',
title: 'experiencia',
description: 'Reservá tu entrada',
},
immersiveHeroCarousel: ['/first.jpg', '/second.jpg'],
},
});
fixture.detectChanges();
expect(header.componentInstance.headerType()).toBe('immersive');
expect(element.querySelector('app-standard-store-header')).toBeNull();
expect(element.querySelector('app-immersive-store-header')).not.toBeNull();
expect(element.querySelector('.immersive-header__eyebrow')?.textContent).toContain(
'Encendé tu',
);
expect(element.querySelector('.immersive-header__title')?.textContent).toContain('experiencia');
expect(element.querySelector('.immersive-header__description')?.textContent).toContain(
'Reservá tu entrada',
);
expect(element.querySelectorAll('.continuous-carousel__set')).toHaveLength(2);
expect(element.querySelector('.continuous-carousel__image')?.getAttribute('src')).toBe(
'/first.jpg',
);
expect(element.querySelector('app-cart-icon')).not.toBeNull();
});
it('shows immersive categories below the header and navigates on selection', () => {
tenantState.set({
...tenant,
header_type: 'immersive',
display_categories: true,
categories: [{ id: 11, nombre: 'Música', subcategories: [] }],
menues: [
...(tenant.menues ?? []),
{
id: 20,
code: 'product.category',
label: 'Categoría de productos',
parent_menu_code: null,
content_type: 'dynamic',
route: '/categoria/:id',
submenues: [],
},
],
});
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const header = element.querySelector('app-immersive-store-header header');
const categories = element.querySelector('app-store-header .immersive-categories');
const categoryButton = categories?.querySelector<HTMLButtonElement>('button');
expect(header).not.toBeNull();
expect(categories).not.toBeNull();
expect(header?.contains(categories)).toBe(false);
expect(categoryButton?.textContent?.trim()).toBe('MÚSICA');
const navigate = vi.spyOn(TestBed.inject(Router), 'navigate');
categoryButton?.click();
expect(navigate).toHaveBeenCalledWith(['/categoria', 11], { queryParams: { page: 1 } });
tenantState.set({
...tenant,
header_type: 'immersive',
display_categories: false,
categories: [{ id: 11, nombre: 'Música', subcategories: [] }],
});
fixture.detectChanges();
expect(element.querySelector('.immersive-categories')).toBeNull();
});
it('uses event categories and their dedicated route for multi-event storefronts', () => {
tenantState.set({
...tenant,
storefront_website_type_code: 'onticket_multi_event',
header_type: 'immersive',
display_categories: true,
categories: [],
event_categories: [{ id: 12, nombre: 'Teatro', subcategories: [] }],
menues: [
...(tenant.menues ?? []),
{
id: 21,
code: 'event.category',
label: 'Categoría de eventos',
parent_menu_code: null,
content_type: 'dynamic',
route: '/eventos/categoria/:id',
submenues: [],
},
],
});
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
const categoryButton = (fixture.nativeElement as HTMLElement).querySelector<HTMLButtonElement>(
'.immersive-categories button',
);
const navigate = vi.spyOn(TestBed.inject(Router), 'navigate');
expect(categoryButton?.textContent?.trim()).toBe('TEATRO');
categoryButton?.click();
expect(navigate).toHaveBeenCalledWith(['/eventos/categoria', 12], {
queryParams: { page: 1 },
});
});
it('opens the cart when requested through the openCart query parameter', () => {
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
expect((fixture.componentInstance as any).isCartOpen()).toBe(false);
queryParamMapState.next(convertToParamMap({ openCart: 'true' }));
fixture.detectChanges();
expect((fixture.componentInstance as any).isCartOpen()).toBe(true);
expect((fixture.nativeElement as HTMLElement).querySelector('app-cart')).not.toBeNull();
});
it('disables the cart icon and prevents opening the popup during checkout with a tenant base path', () => {
tenantState.set({ ...tenant, base_path: 'fiesta' });
const router = TestBed.inject(Router);
vi.spyOn(router, 'url', 'get').mockReturnValue('/fiesta/checkout/25');
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const cartButton = element.querySelector<HTMLButtonElement>('app-cart-icon button');
const header = fixture.debugElement.query(By.directive(StoreHeaderComponent));
expect(cartButton?.disabled).toBe(true);
expect(cartButton?.getAttribute('aria-label')).toBe(
'Carrito no disponible durante el checkout',
);
expect(element.querySelector('[data-testid="cart-badge"]')).toBeNull();
header.componentInstance.cartClick.emit();
queryParamMapState.next(convertToParamMap({ openCart: 'true' }));
fixture.detectChanges();
expect((fixture.componentInstance as any).isCartOpen()).toBe(false);
expect(element.querySelector('app-cart')).toBeNull();
expect(element.querySelector('.store-layout__cart-overlay')).toBeNull();
});
it('shows the synchronized countdown in the header whenever one is active', () => {
checkoutRemainingSecondsState.set(587);
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('.store-layout__checkout-timer-label')?.textContent).toContain(
'Tiempo restante de compra:',
);
expect(element.querySelector('.store-layout__checkout-timer-value')?.textContent?.trim()).toBe(
'09:47',
);
});
it('moves the countdown out of the header on multi-event checkout', () => {
tenantState.set({
...tenant,
storefront_website_type_code: 'onticket_multi_event',
checkout_summary_type: 'event',
});
checkoutRemainingSecondsState.set(587);
const router = TestBed.inject(Router);
vi.spyOn(router, 'url', 'get').mockReturnValue('/checkout/25');
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const header = fixture.debugElement.query(By.directive(StoreHeaderComponent));
expect(header.componentInstance.checkoutRemainingSeconds()).toBeNull();
expect(element.querySelector('.store-layout__checkout-timer')).toBeNull();
});
it('hides the configured header elements when the tenant disables them', () => {
tenantState.set({
...tenant,
@@ -230,7 +472,7 @@ describe('StoreLayoutComponent', () => {
expect(compiled.querySelector('app-cart')).toBeNull();
expect(compiled.querySelector('.store-layout__cart-overlay')).toBeNull();
const header = fixture.debugElement.query(By.directive(StoreHeaderComponent));
const header = fixture.debugElement.query(By.directive(StandardStoreHeaderComponent));
(header.componentInstance as any).toggleMobileMenu();
fixture.detectChanges();
@@ -267,6 +509,12 @@ describe('StoreLayoutComponent', () => {
expect(compiled.querySelector('.fa-whatsapp')).not.toBeNull();
expect(compiled.querySelector('.fa-facebook')).not.toBeNull();
expect(compiled.querySelector('.fa-linkedin-in')).not.toBeNull();
expect(compiled.querySelector('.store-layout__contact-details')?.textContent).toContain(
tenant.address,
);
expect(compiled.querySelector('.store-layout__contact-details')?.textContent).toContain(
tenant.phone,
);
});
it('navigates to search results when the search button is clicked', () => {
@@ -275,7 +523,7 @@ describe('StoreLayoutComponent', () => {
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
const header = fixture.debugElement.query(By.directive(StoreHeaderComponent));
const header = fixture.debugElement.query(By.directive(StandardStoreHeaderComponent));
const searchButton = header.query(By.css('.store-layout__search-button'));
(header.componentInstance as any).searchControl.setValue(' zapatillas ');
@@ -293,7 +541,7 @@ describe('StoreLayoutComponent', () => {
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
const header = fixture.debugElement.query(By.directive(StoreHeaderComponent));
const header = fixture.debugElement.query(By.directive(StandardStoreHeaderComponent));
const input = header.query(By.css('#store-search-input'));
const keyboardEvent = {
preventDefault: vi.fn(),
@@ -314,7 +562,7 @@ describe('StoreLayoutComponent', () => {
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
const header = fixture.debugElement.query(By.directive(StoreHeaderComponent));
const header = fixture.debugElement.query(By.directive(StandardStoreHeaderComponent));
const input = header.query(By.css('#store-search-input'));
const searchButton = header.query(By.css('.store-layout__search-button'));
@@ -478,6 +726,20 @@ describe('StoreLayoutComponent', () => {
expect(authService.logout).toHaveBeenCalled();
expect(cartService.clearCart).toHaveBeenCalled();
expect(router.navigate).toHaveBeenCalledWith(['/']);
expect(TestBed.inject(ToastService).info).toHaveBeenCalledWith('Sesión cerrada correctamente.');
});
it('shows a danger toast when logout fails', async () => {
const authService = TestBed.inject(AuthService);
const message = 'La sesión no pudo cerrarse en el servidor.';
(authService.logout as any).mockReturnValue(throwError(() => ({ error: { message } })));
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
await (fixture.componentInstance as any).onLogoutClick();
expect(TestBed.inject(ToastService).danger).toHaveBeenCalledWith(message);
});
it('provides account actions from the footer', () => {
@@ -517,7 +779,7 @@ describe('StoreLayoutComponent', () => {
const authService = TestBed.inject(AuthService);
const cartService = TestBed.inject(CartService);
const router = TestBed.inject(Router);
vi.spyOn(router, 'url', 'get').mockReturnValue('/checkout?purchase=25');
vi.spyOn(router, 'url', 'get').mockReturnValue('/checkout/25');
const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
const fixture = TestBed.createComponent(StoreLayoutComponent);
@@ -604,12 +866,44 @@ describe('StoreLayoutComponent', () => {
expect(checkoutServiceStub.startCheckout).toHaveBeenCalledWith('test', {
cart_id: 1,
});
expect(router.navigate).toHaveBeenCalledWith(['/checkout'], {
queryParams: { purchase: 55 },
});
expect(router.navigate).toHaveBeenCalledWith(['/checkout', 55]);
expect((fixture.componentInstance as any).isCartOpen()).toBe(false);
});
it('shows the backend message and refreshes the cart when its reservation expired', async () => {
const message = 'La reserva de stock venció. Usá el carrito activo para continuar.';
checkoutServiceStub.startCheckout.mockRejectedValue(
new HttpErrorResponse({
status: 422,
error: { code: 'stock_reservation.expired', message },
}),
);
cartState.set({
id: 1,
tenant_codigo: tenant.codigo,
status: 'active',
subtotal: '100.00',
items: [],
});
authUserState.set({
id: 7,
nombre_apellido: 'Juan Perez',
email: 'juan@example.com',
dni: '12345678',
telefono: '3415555555',
});
const fixture = TestBed.createComponent(StoreLayoutComponent);
const loadCart = vi.spyOn(TestBed.inject(CartService), 'loadCart');
fixture.detectChanges();
loadCart.mockClear();
await (fixture.componentInstance as any).onCheckoutClick();
expect(TestBed.inject(ToastService).danger).toHaveBeenCalledWith(message);
expect(loadCart).toHaveBeenCalledOnce();
});
it('allows modifying quantities directly in the regular cart without a toggle', () => {
cartState.set({
id: 1,
@@ -647,7 +941,16 @@ describe('StoreLayoutComponent', () => {
});
it('hides quantity selectors when the tenant disables cart editing', () => {
tenantState.set({ ...tenant, cart_editing_enabled: false });
tenantState.set({
...tenant,
cart_editing_policy: {
code: 'disabled',
allow_modify: false,
allow_delete: false,
allow_update_quantity: false,
allow_update_variant: false,
},
});
cartState.set({
id: 1,
tenant_codigo: tenant.codigo,
@@ -676,4 +979,62 @@ describe('StoreLayoutComponent', () => {
expect(cartItem.componentInstance.readonly()).toBe(true);
expect(cartItem.nativeElement.querySelector('app-quantity-selector')).toBeNull();
});
it('shows variant selectors only for the full cart editing policy', () => {
const variantCart: Cart = {
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: 10,
nombre: 'Producto',
imagen: null,
variant: { id: 10, precio: '100.00', stock_tecnico: 5, values: { talle: 'M' } },
variants: [
{ id: 10, precio: '100.00', stock_tecnico: 5, values: { talle: 'M' } },
{ id: 11, precio: '100.00', stock_tecnico: 5, values: { talle: 'L' } },
],
},
],
};
tenantState.set({
...tenant,
cart_editing_policy: {
code: 'quantity_and_remove',
allow_modify: true,
allow_delete: true,
allow_update_quantity: true,
allow_update_variant: false,
},
});
cartState.set(variantCart);
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
(fixture.componentInstance as any).isCartOpen.set(true);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('app-variant-selector')).toBeNull();
expect(fixture.nativeElement.querySelector('app-quantity-selector')).not.toBeNull();
tenantState.set({
...tenant,
cart_editing_policy: {
code: 'full',
allow_modify: true,
allow_delete: true,
allow_update_quantity: true,
allow_update_variant: true,
},
});
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('app-variant-selector')).not.toBeNull();
});
});

View File

@@ -1,5 +1,24 @@
import { Component, computed, inject, OnInit, signal } from '@angular/core';
import { Router, RouterOutlet } from '@angular/router';
import { isPlatformBrowser } from '@angular/common';
import { HttpErrorResponse } from '@angular/common/http';
import {
Component,
computed,
DestroyRef,
effect,
inject,
OnInit,
PLATFORM_ID,
signal,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import {
ActivatedRoute,
NavigationEnd,
PRIMARY_OUTLET,
Router,
RouterOutlet,
} from '@angular/router';
import { filter, firstValueFrom } from 'rxjs';
import { TenantService } from '../../services/tenant.service';
import { CartService } from '../../services/cart/cart.service';
import { StoreFooterComponent, StoreFooterSection } from './store-footer/store-footer.component';
@@ -9,9 +28,16 @@ import { ButtonComponent } from '../../../shared/components/button/button.compon
import { CartItem, CartItemVariantValue } from '../../services/cart/cart.interface';
import { AuthService } from '../../services/auth/auth.service';
import { findMenu } from '../../services/menu.utils';
import { CheckoutService } from '../../services/checkout.service';
import {
CheckoutService,
isExpiredStockReservationResponse,
} from '../../services/checkout.service';
import { ToastService } from '../../services/toast.service';
import { Category } from '../../services/tenant.interface';
import { CheckoutCountdownService } from '../../services/checkout-countdown.service';
import { EventDateNotice, EventDateNoticeService } from '../../services/event-date-notice.service';
import { ModalService } from '../../services/modal.service';
import { EventDateNoticeModalComponent } from '../../../shared/components/event-date-notice-modal/event-date-notice-modal.component';
@Component({
selector: 'app-store-layout',
@@ -30,15 +56,78 @@ export class StoreLayoutComponent implements OnInit {
private readonly cartService = inject(CartService);
private readonly authService = inject(AuthService);
private readonly checkoutService = inject(CheckoutService);
private readonly checkoutCountdownService = inject(CheckoutCountdownService);
private readonly toastService = inject(ToastService);
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
private readonly destroyRef = inject(DestroyRef);
private readonly platformId = inject(PLATFORM_ID);
private readonly eventDateNoticeService = inject(EventDateNoticeService);
private readonly modalService = inject(ModalService);
private claimedNoticeUserId: number | null = null;
protected readonly isCartOpen = signal(false);
protected readonly isCheckoutRoute = signal(this.isCheckoutUrl(this.router.url));
protected readonly isHomeRoute = signal(this.isHomeUrl(this.router.url));
protected readonly checkoutRemainingSeconds = this.checkoutCountdownService.remainingSeconds;
protected readonly isCreatingPurchase = signal(false);
protected readonly displayCart = computed(() => this.tenant()?.display_cart ?? true);
protected readonly cartEditingEnabled = computed(
() => this.tenant()?.cart_editing_enabled ?? true,
private readonly isMultiEventStorefront = computed(
() => this.tenant()?.storefront_website_type_code === 'onticket_multi_event',
);
protected readonly headerCheckoutRemainingSeconds = computed(() =>
this.isCheckoutRoute() && this.tenant()?.checkout_summary_type === 'event'
? null
: this.checkoutRemainingSeconds(),
);
protected readonly navigationCategories = computed(() =>
this.isMultiEventStorefront()
? (this.tenant()?.event_categories ?? [])
: (this.tenant()?.categories ?? []),
);
private readonly categoryMenu = computed(() =>
findMenu(
this.tenant()?.menues ?? [],
this.isMultiEventStorefront() ? 'event.category' : 'product.category',
),
);
protected readonly displayCategories = computed(
() => (this.tenant()?.display_categories ?? true) && this.categoryMenu() !== undefined,
);
protected readonly displayCart = computed(() => this.tenant()?.display_cart ?? true);
protected readonly cartEditingPolicy = computed(() => this.tenant()?.cart_editing_policy ?? null);
protected readonly canModifyCart = computed(
() => this.cartEditingPolicy()?.allow_modify ?? false,
);
protected readonly canDeleteCartItems = computed(
() => this.cartEditingPolicy()?.allow_delete ?? false,
);
protected readonly canUpdateCartQuantity = computed(
() => this.cartEditingPolicy()?.allow_update_quantity ?? false,
);
protected readonly canUpdateCartVariant = computed(
() => this.cartEditingPolicy()?.allow_update_variant ?? false,
);
constructor() {
effect(() => {
const user = this.authService.user();
if (!isPlatformBrowser(this.platformId) || !user) {
this.claimedNoticeUserId = null;
return;
}
if (this.claimedNoticeUserId === user.id) {
return;
}
this.claimedNoticeUserId = user.id;
this.eventDateNoticeService.claim().subscribe({
next: ({ data }) => void this.showEventDateNotices(data, user.id),
error: (error) => console.error('Error loading event date notices', error),
});
});
}
protected readonly cartSubtotal = computed(() => {
const cart = this.cartService.cart();
@@ -92,6 +181,7 @@ export class StoreLayoutComponent implements OnInit {
attributes,
quantity: item.cantidad,
variantId: item.variant_id,
variants: item.variants,
};
}
@@ -135,15 +225,77 @@ export class StoreLayoutComponent implements OnInit {
});
ngOnInit(): void {
this.router.events
.pipe(
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((event) => {
const isCheckoutRoute = this.isCheckoutUrl(event.urlAfterRedirects);
this.isCheckoutRoute.set(isCheckoutRoute);
this.isHomeRoute.set(this.isHomeUrl(event.urlAfterRedirects));
if (isCheckoutRoute) {
this.isCartOpen.set(false);
}
});
this.route.queryParamMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => {
if (params.get('openCart') === 'true' && !this.isCheckoutRoute()) {
this.isCartOpen.set(true);
}
});
this.cartService.loadCart().subscribe({
error: (err) => console.error('Error loading cart', err),
});
}
private isCheckoutUrl(url: string): boolean {
const primarySegments = this.router.parseUrl(url).root.children[PRIMARY_OUTLET]?.segments ?? [];
return primarySegments[0]?.path === 'checkout';
}
private isHomeUrl(url: string): boolean {
const primarySegments = this.router.parseUrl(url).root.children[PRIMARY_OUTLET]?.segments ?? [];
return primarySegments.length === 0;
}
protected onLoginClick(): void {
void this.router.navigate(['/login']);
}
protected onCartClick(): void {
if (this.isCheckoutRoute()) {
return;
}
this.isCartOpen.update((isOpen) => !isOpen);
}
private async showEventDateNotices(notices: EventDateNotice[], userId: number): Promise<void> {
for (const notice of notices) {
if (this.authService.user()?.id !== userId) {
return;
}
const modalRef = this.modalService.open<EventDateNoticeModalComponent, void, EventDateNotice>(
EventDateNoticeModalComponent,
{
size: 'sm',
data: notice,
},
);
await firstValueFrom(modalRef.afterClosed$);
if (modalRef.dismissReason() === 'replaced') {
return;
}
}
}
protected onSearch(term: string): void {
void this.router.navigate(['/buscar'], {
queryParams: { q: term, page: 1 },
@@ -151,7 +303,9 @@ export class StoreLayoutComponent implements OnInit {
}
protected onCategorySelect(category: Category): void {
void this.router.navigate(['/categoria', category.id], {
const route = this.isMultiEventStorefront() ? '/eventos/categoria' : '/categoria';
void this.router.navigate([route, category.id], {
queryParams: { page: 1 },
});
}
@@ -167,29 +321,45 @@ export class StoreLayoutComponent implements OnInit {
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.
// Leave checkout before closing the authenticated session so its component
// can stop payment polling cleanly. The checkout itself remains pending.
if (isLeavingCheckout) {
const navigationSucceeded = await this.router.navigate(['/']);
if (!navigationSucceeded) {
this.toastService.danger('No se pudo cerrar la sesión. Intentá nuevamente.');
return;
}
}
this.authService.logout().subscribe({
next: () => {
next: ({ message }) => {
this.cartService.clearCart();
this.isCartOpen.set(false);
this.toastService.info(message || 'Sesión cerrada correctamente.');
if (!isLeavingCheckout) {
void this.router.navigate(['/']);
}
},
error: (err) => console.error('Error logging out', err),
error: (error: unknown) => {
this.toastService.danger(this.resolveLogoutErrorMessage(error));
console.error('Error logging out', error);
},
});
}
private resolveLogoutErrorMessage(error: unknown): string {
const payload =
typeof error === 'object' && error !== null && 'error' in error
? (error as { error?: { message?: unknown } }).error
: undefined;
return typeof payload?.message === 'string' && payload.message.trim()
? payload.message
: 'No se pudo cerrar la sesión. Intentá nuevamente.';
}
protected async onCheckoutClick(): Promise<void> {
if (this.isCreatingPurchase()) {
return;
@@ -216,14 +386,21 @@ export class StoreLayoutComponent implements OnInit {
cart_id: cart.id,
});
this.cartService.clearCart();
this.isCartOpen.set(false);
await this.router.navigate(['/checkout'], {
queryParams: { purchase: purchase.id },
});
await this.router.navigate(['/checkout', purchase.id]);
} catch (error) {
console.error('Failed to create cart purchase:', error);
this.toastService.danger('No se pudo iniciar la compra.');
const message =
error instanceof HttpErrorResponse && typeof error.error?.message === 'string'
? error.error.message
: 'No se pudo iniciar la compra.';
this.toastService.danger(message);
if (error instanceof HttpErrorResponse && isExpiredStockReservationResponse(error.error)) {
this.cartService.loadCart(true).subscribe({
error: (refreshError) => console.error('Error refreshing expired cart', refreshError),
});
}
} finally {
this.isCreatingPurchase.set(false);
}

View File

@@ -24,12 +24,12 @@ describe('auth guards', () => {
});
const result = TestBed.runInInjectionContext(() =>
authGuard(null as never, { url: '/checkout?mode=direct' } as never),
authGuard(null as never, { url: '/checkout/25' } as never),
);
expect(result instanceof UrlTree).toBe(true);
expect(TestBed.inject(Router).serializeUrl(result as UrlTree)).toBe(
'/login?returnUrl=%2Fcheckout%3Fmode%3Ddirect',
'/login?returnUrl=%2Fcheckout%2F25',
);
});
@@ -39,7 +39,7 @@ describe('auth guards', () => {
});
const result = TestBed.runInInjectionContext(() =>
authGuard(null as never, { url: '/checkout' } as never),
authGuard(null as never, { url: '/checkout/25' } as never),
);
expect(result).toBe(true);

View File

@@ -33,6 +33,10 @@ export interface LoginResponse {
user: AuthUser;
}
export interface LogoutResponse {
message: string;
}
export interface RegisterResponse {
message: string;
data: AuthUser;

View File

@@ -10,6 +10,7 @@ import {
AuthUser,
LoginPayload,
LoginResponse,
LogoutResponse,
RegisterPayload,
RegisterResponse,
ResetPasswordPayload,
@@ -155,14 +156,14 @@ export class AuthService extends BaseApiService {
.pipe(tap((user) => this.userState.set(user)));
}
logout(): Observable<void> {
logout(): Observable<LogoutResponse> {
if (!this.tokenState()) {
this.clearSession();
return of(void 0);
return of({ message: 'Sesión cerrada correctamente.' });
}
return this.http
.post<void>(`${environment.url}logout`, {})
.post<LogoutResponse>(`${environment.url}logout`, {})
.pipe(tap(() => this.clearSession()));
}

View File

@@ -24,6 +24,7 @@ export interface CartItem {
nombre: string | null;
imagen: string | null;
variant: CartItemVariant | null;
variants?: CartItemVariant[];
}
export interface Cart {

View File

@@ -7,11 +7,13 @@ import { CartService } from './cart.service';
import { TenantService } from '../tenant.service';
import { Cart } from './cart.interface';
import { LOADING_MODE } from '../global-loading/loading-mode';
import { CatalogAvailabilityService } from '../catalog/catalog-availability.service';
describe('CartService', () => {
let service: CartService;
let httpMock: HttpTestingController;
let tenantServiceMock: any;
let catalogAvailabilityService: CatalogAvailabilityService;
const mockCart: Cart = {
id: 123,
@@ -53,6 +55,7 @@ describe('CartService', () => {
});
service = TestBed.inject(CartService);
catalogAvailabilityService = TestBed.inject(CatalogAvailabilityService);
httpMock = TestBed.inject(HttpTestingController);
});
@@ -80,6 +83,20 @@ describe('CartService', () => {
req.flush({ data: mockCart });
});
it('refreshes catalog availability only after the expired cart has been reloaded', () => {
const availabilityChanged = vi.fn();
catalogAvailabilityService.availabilityChanged$.subscribe(availabilityChanged);
service.loadCart(true).subscribe();
expect(availabilityChanged).not.toHaveBeenCalled();
const req = httpMock.expectOne('http://api.test/tenants/acme/cart');
req.flush({ data: mockCart });
expect(service.cart()).toEqual(mockCart);
expect(availabilityChanged).toHaveBeenCalledOnce();
});
it('propagates a custom loading mode to the request context', () => {
service.withCustomLoading().loadCart().subscribe();
@@ -101,6 +118,9 @@ describe('CartService', () => {
});
it('should add item and update signal', () => {
const availabilityChanged = vi.fn();
catalogAvailabilityService.availabilityChanged$.subscribe(availabilityChanged);
service.addItem(5, 10, 2).subscribe((res) => {
expect(res.data).toEqual(mockCart);
expect(service.cart()).toEqual(mockCart);
@@ -115,9 +135,13 @@ describe('CartService', () => {
});
expect(req.request.withCredentials).toBe(true);
req.flush({ data: mockCart });
expect(availabilityChanged).toHaveBeenCalledOnce();
});
it('should update item quantity and update signal', () => {
const availabilityChanged = vi.fn();
catalogAvailabilityService.availabilityChanged$.subscribe(availabilityChanged);
const updatedCart = { ...mockCart, subtotal: '30.00' };
updatedCart.items[0].cantidad = 3;
@@ -131,9 +155,13 @@ describe('CartService', () => {
expect(req.request.body).toEqual({ cantidad: 3 });
expect(req.request.withCredentials).toBe(true);
req.flush({ data: updatedCart });
expect(availabilityChanged).toHaveBeenCalledOnce();
});
it('should remove item and update signal', () => {
const availabilityChanged = vi.fn();
catalogAvailabilityService.availabilityChanged$.subscribe(availabilityChanged);
const emptyCart: Cart = {
id: 123,
tenant_codigo: 'acme',
@@ -151,5 +179,18 @@ describe('CartService', () => {
expect(req.request.method).toBe('DELETE');
expect(req.request.withCredentials).toBe(true);
req.flush({ data: emptyCart });
expect(availabilityChanged).toHaveBeenCalledOnce();
});
it('does not notify an availability change when a cart mutation fails', () => {
const availabilityChanged = vi.fn();
catalogAvailabilityService.availabilityChanged$.subscribe(availabilityChanged);
service.removeItem(10).subscribe({ error: vi.fn() });
const req = httpMock.expectOne('http://api.test/tenants/acme/cart/items/10');
req.flush({ message: 'Error' }, { status: 500, statusText: 'Server Error' });
expect(availabilityChanged).not.toHaveBeenCalled();
});
});

View File

@@ -3,6 +3,7 @@ import { catchError, map, Observable, tap } from 'rxjs';
import { ApiResponse } from '../api-response.interface';
import { BaseApiService } from '../base-api.service';
import { CatalogAvailabilityService } from '../catalog/catalog-availability.service';
import { TenantService } from '../tenant.service';
import { Cart } from './cart.interface';
@@ -10,6 +11,7 @@ import { Cart } from './cart.interface';
providedIn: 'root',
})
export class CartService extends BaseApiService {
private readonly catalogAvailabilityService = inject(CatalogAvailabilityService);
private readonly tenantService = inject(TenantService);
private readonly cartState = signal<Cart | null>(null);
@@ -32,14 +34,19 @@ export class CartService extends BaseApiService {
});
}
loadCart(): Observable<Cart> {
loadCart(refreshCatalogAvailability = false): Observable<Cart> {
return this.http
.get<ApiResponse<Cart>>(`${this.tenantApiUrl}/cart`, {
withCredentials: true,
})
.pipe(
map((response) => response.data),
tap((cart) => this.cartState.set(cart)),
tap((cart) => {
this.cartState.set(cart);
if (refreshCatalogAvailability) {
this.catalogAvailabilityService.notifyAvailabilityChanged();
}
}),
);
}
@@ -60,6 +67,7 @@ export class CartService extends BaseApiService {
.pipe(
tap((response) => {
this.cartState.set(response.data);
this.catalogAvailabilityService.notifyAvailabilityChanged();
this.isUpdatingState.set(false);
}),
catchError((error) => {
@@ -93,6 +101,7 @@ export class CartService extends BaseApiService {
.pipe(
tap((response) => {
this.cartState.set(response.data);
this.catalogAvailabilityService.notifyAvailabilityChanged();
this.isUpdatingState.set(false);
}),
catchError((error) => {
@@ -111,6 +120,7 @@ export class CartService extends BaseApiService {
.pipe(
tap((response) => {
this.cartState.set(response.data);
this.catalogAvailabilityService.notifyAvailabilityChanged();
this.isUpdatingState.set(false);
}),
catchError((error) => {

View File

@@ -0,0 +1,15 @@
import { Injectable } from '@angular/core';
import { Observable, Subject } from 'rxjs';
@Injectable({
providedIn: 'root',
})
export class CatalogAvailabilityService {
private readonly availabilityChangedSubject = new Subject<void>();
readonly availabilityChanged$: Observable<void> = this.availabilityChangedSubject.asObservable();
notifyAvailabilityChanged(): void {
this.availabilityChangedSubject.next();
}
}

View File

@@ -66,12 +66,13 @@ export interface CatalogItemVariant {
event_date_id?: number | null;
event_date_ids?: number[];
event_dates?: string[];
stock_tecnico: number | null;
maximum_addable_quantity?: number | null;
unavailable_message?: string | null;
minimum_use_date?: string | null;
maximum_use_date?: string | null;
effective_minimum_use_date?: string | null;
effective_maximum_use_date?: string | null;
values: Record<string, string | string[]>;
values: Record<string, CatalogVariantValue>;
}
export interface SelectedCatalogItemVariant extends CatalogItemVariant {
@@ -98,7 +99,7 @@ export interface CatalogItemDetail {
attributes: ProductAttribute[];
variants: CatalogItemVariant[];
selected_variant?: SelectedCatalogItemVariant;
stock_tecnico?: number | null;
maximum_addable_quantity?: number | null;
images?: string[];
}
@@ -113,7 +114,8 @@ export interface CatalogFeaturedItemVariant {
id: number;
descripcion?: string | null;
precio?: string;
stock_tecnico: number | null;
maximum_addable_quantity?: number | null;
unavailable_message?: string | null;
values: Record<string, CatalogVariantValue>;
}
@@ -145,7 +147,8 @@ export interface CatalogFeaturedItem {
descripcion?: string | null;
precio: number | string;
image?: string | null;
stock_tecnico?: number | null;
maximum_addable_quantity?: number | null;
unavailable_message?: string | null;
variants?: CatalogFeaturedItemVariant[];
}
@@ -155,6 +158,7 @@ export type CatalogFeaturedItems =
export interface CatalogFeaturedGroup {
id: number;
code: string;
title: string;
layout: CatalogProductLayout;
group_layout: CatalogGroupLayout;

View File

@@ -14,7 +14,6 @@ import {
CatalogItemDetail,
CatalogVariantOptionsResponse,
CategoryItemsResponse,
Product,
} from './catalog.interface';
type HttpParamValue = string | number | boolean | readonly (string | number | boolean)[];
@@ -33,12 +32,6 @@ export class CatalogService extends BaseApiService {
return this.tenantService.getTenantApiUrl();
}
getProductos(params?: ApiPaginationQueryParams): Observable<ApiPaginatedResponse<Product[]>> {
return this.http.get<ApiPaginatedResponse<Product[]>>(`${this.tenantApiUrl}/productos`, {
params: this.buildHttpParams(params),
});
}
getCatalog(): Observable<CatalogFeaturedGroup[]> {
return this.http.get<CatalogFeaturedGroup[]>(`${this.tenantApiUrl}/catalog`);
}

View File

@@ -0,0 +1,69 @@
import { TestBed } from '@angular/core/testing';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { CheckoutCountdownService } from './checkout-countdown.service';
describe('CheckoutCountdownService', () => {
let service: CheckoutCountdownService;
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-08-27T12:00:00.000Z'));
TestBed.configureTestingModule({ providers: [CheckoutCountdownService] });
service = TestBed.inject(CheckoutCountdownService);
});
afterEach(() => {
service.clear();
vi.useRealTimers();
});
it('counts down from the checkout server timing without depending on the client clock', () => {
service.synchronize({
expires_at: '2026-08-27T15:10:00.000Z',
expires_in_seconds: 600,
server_time: '2026-08-27T15:00:00.000Z',
});
expect(service.remainingSeconds()).toBe(600);
vi.advanceTimersByTime(1_000);
expect(service.remainingSeconds()).toBe(599);
});
it('recalculates against the deadline after a delayed browser interval', () => {
service.synchronize({
expires_at: null,
expires_in_seconds: 10,
server_time: '2026-08-27T15:00:00.000Z',
});
vi.setSystemTime(new Date('2026-08-27T12:00:07.000Z'));
vi.advanceTimersByTime(1_000);
expect(service.remainingSeconds()).toBe(2);
});
it('clears the countdown when checkout has no expiration', () => {
service.synchronize({
expires_at: null,
expires_in_seconds: null,
server_time: '2026-08-27T15:00:00.000Z',
});
expect(service.remainingSeconds()).toBeNull();
});
it('keeps the active countdown when a partial checkout response omits timing fields', () => {
service.synchronize({
expires_at: null,
expires_in_seconds: 10,
server_time: '2026-08-27T15:00:00.000Z',
});
service.synchronize({});
expect(service.remainingSeconds()).toBe(10);
});
});

View File

@@ -0,0 +1,97 @@
import { Injectable, OnDestroy, signal } from '@angular/core';
export interface CheckoutTiming {
expires_at: string | null;
expires_in_seconds: number | null;
server_time: string;
}
@Injectable({
providedIn: 'root',
})
export class CheckoutCountdownService implements OnDestroy {
private readonly remainingSecondsState = signal<number | null>(null);
private deadlineMs: number | null = null;
private intervalId: ReturnType<typeof setInterval> | null = null;
readonly remainingSeconds = this.remainingSecondsState.asReadonly();
synchronize(timing: Partial<CheckoutTiming>): void {
const remainingSeconds = this.resolveRemainingSeconds(timing);
if (remainingSeconds === undefined) {
return;
}
if (remainingSeconds === null) {
this.clear();
return;
}
this.stopInterval();
this.deadlineMs = Date.now() + remainingSeconds * 1_000;
this.updateRemainingSeconds();
if (remainingSeconds > 0) {
this.intervalId = setInterval(() => this.updateRemainingSeconds(), 1_000);
}
}
clear(): void {
this.stopInterval();
this.deadlineMs = null;
this.remainingSecondsState.set(null);
}
ngOnDestroy(): void {
this.clear();
}
private resolveRemainingSeconds(timing: Partial<CheckoutTiming>): number | null | undefined {
if (timing.expires_at === null && timing.expires_in_seconds === null) {
return null;
}
const expiresAt =
typeof timing.expires_at === 'string' ? Date.parse(timing.expires_at) : Number.NaN;
const serverTime =
typeof timing.server_time === 'string' ? Date.parse(timing.server_time) : Number.NaN;
if (Number.isFinite(expiresAt) && Number.isFinite(serverTime)) {
return Math.max(0, Math.ceil((expiresAt - serverTime) / 1_000));
}
if (
typeof timing.expires_in_seconds === 'number' &&
Number.isFinite(timing.expires_in_seconds)
) {
return Math.max(0, Math.ceil(timing.expires_in_seconds));
}
if (Number.isFinite(expiresAt)) {
return Math.max(0, Math.ceil((expiresAt - Date.now()) / 1_000));
}
return undefined;
}
private updateRemainingSeconds(): void {
if (this.deadlineMs === null) {
return;
}
const remainingSeconds = Math.max(0, Math.ceil((this.deadlineMs - Date.now()) / 1_000));
this.remainingSecondsState.set(remainingSeconds);
if (remainingSeconds === 0) {
this.stopInterval();
}
}
private stopInterval(): void {
if (this.intervalId !== null) {
clearInterval(this.intervalId);
this.intervalId = null;
}
}
}

View File

@@ -1,9 +1,10 @@
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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { environment } from '../../../environments/environment';
import { CatalogAvailabilityService } from './catalog/catalog-availability.service';
import { CheckoutService } from './checkout.service';
describe('CheckoutService', () => {
@@ -38,4 +39,59 @@ describe('CheckoutService', () => {
await expect(purchasePromise).resolves.toMatchObject({ id: 55 });
});
it('refreshes catalog availability when starting checkout fails', async () => {
const catalogAvailabilityService = TestBed.inject(CatalogAvailabilityService);
const notifyAvailabilityChanged = vi.spyOn(
catalogAvailabilityService,
'notifyAvailabilityChanged',
);
const purchasePromise = service.startCheckout('desfile', { cart_id: 12 });
const request = httpMock.expectOne(`${environment.url}tenants/desfile/compras/start-checkout`);
request.flush(
{ message: 'No hay stock disponible.' },
{ status: 422, statusText: 'Unprocessable Entity' },
);
await expect(purchasePromise).rejects.toBeTruthy();
expect(notifyAvailabilityChanged).toHaveBeenCalledOnce();
});
it('waits for the expired cart refresh before refreshing catalog availability', async () => {
const catalogAvailabilityService = TestBed.inject(CatalogAvailabilityService);
const notifyAvailabilityChanged = vi.spyOn(
catalogAvailabilityService,
'notifyAvailabilityChanged',
);
const purchasePromise = service.startCheckout('desfile', { cart_id: 12 });
const request = httpMock.expectOne(`${environment.url}tenants/desfile/compras/start-checkout`);
request.flush(
{
code: 'stock_reservation.expired',
message: 'La reserva de stock venció.',
},
{ status: 422, statusText: 'Unprocessable Entity' },
);
await expect(purchasePromise).rejects.toBeTruthy();
expect(notifyAvailabilityChanged).not.toHaveBeenCalled();
});
it('preserves checkout timing fields when completing a purchase', async () => {
const purchasePromise = service.completePurchase('desfile', 55);
const request = httpMock.expectOne(`${environment.url}tenants/desfile/compras/55/complete`);
const response = {
status: 'pending_payment',
expires_at: '2026-08-26T20:00:00.000Z',
expires_in_seconds: 900,
server_time: '2026-08-26T19:45:00.000Z',
};
expect(request.request.method).toBe('POST');
request.flush({ data: response });
await expect(purchasePromise).resolves.toEqual(response);
});
});

View File

@@ -1,8 +1,11 @@
import { Injectable } from '@angular/core';
import { inject, Injectable } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { environment } from '../../../environments/environment';
import { ApiPaginatedResponse } from './api-paginated-response.interface';
import { ApiPaginationQueryParams } from './api-pagination-query-params.interface';
import { BaseApiService } from './base-api.service';
import { CatalogAvailabilityService } from './catalog/catalog-availability.service';
export interface UpdatePurchaseCustomerPayload {
dni: string;
@@ -43,6 +46,21 @@ export function isInsufficientStockResponse(value: unknown): value is Insufficie
);
}
export interface ExpiredStockReservationResponse {
code: 'stock_reservation.expired';
message: string;
}
export function isExpiredStockReservationResponse(
value: unknown,
): value is ExpiredStockReservationResponse {
return (
typeof value === 'object' &&
value !== null &&
(value as Partial<ExpiredStockReservationResponse>).code === 'stock_reservation.expired'
);
}
export type StartCheckoutPayload =
| {
cart_id: number;
@@ -53,6 +71,32 @@ export type StartCheckoutPayload =
export interface PurchaseStatusResponse {
status: string | null;
expires_at: string | null;
expires_in_seconds: number | null;
server_time: string;
payment_verification?: PurchasePaymentVerificationResponse;
}
export type PurchasePaymentCandidateReason =
| 'ambiguous_exact_match'
| 'exact_dni_near_amount'
| 'exact_amount_near_dni';
export interface PurchasePaymentCandidatePrimaryResponse {
reason: PurchasePaymentCandidateReason;
dni_distance: number | null;
payment_amount: string;
purchase_amount: string;
amount_difference: string;
confidence: 'exact' | 'high' | 'medium';
detected_at: string | null;
}
export interface PurchasePaymentVerificationResponse {
status: 'pending' | 'candidate';
candidate_count: number;
primary: PurchasePaymentCandidatePrimaryResponse | null;
reasons: PurchasePaymentCandidateReason[];
}
export interface PurchaseSummaryResponse extends PurchaseStatusResponse {
@@ -77,11 +121,17 @@ export interface PurchaseDetailItemResponse {
nombre: string;
descripcion: string | null;
slug: string;
imagen: string | null;
imagen?: string | null;
attributes: PurchaseDetailAttributeResponse[];
};
}
export interface PurchaseCheckoutEventResponse {
id: number;
title: string;
image: string | null;
}
export interface PurchaseDetailResponse extends PurchaseStatusResponse {
id: number;
cart_id: number | null;
@@ -89,7 +139,6 @@ 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;
@@ -97,6 +146,7 @@ export interface PurchaseDetailResponse extends PurchaseStatusResponse {
email: string | null;
items_source: 'purchase';
items: PurchaseDetailItemResponse[];
event?: PurchaseCheckoutEventResponse | null;
tickets_count?: number;
has_generated_tickets?: boolean;
subtotal: string;
@@ -107,24 +157,34 @@ export interface PurchaseDetailResponse extends PurchaseStatusResponse {
providedIn: 'root',
})
export class CheckoutService extends BaseApiService {
private readonly catalogAvailabilityService = inject(CatalogAvailabilityService);
async startCheckout(
tenantCode: string,
payload: StartCheckoutPayload,
): Promise<PurchaseDetailResponse> {
const response = await firstValueFrom(
this.http.post<{ data?: PurchaseDetailResponse } | PurchaseDetailResponse>(
`${environment.url}tenants/${tenantCode}/compras/start-checkout`,
payload,
),
);
try {
const response = await firstValueFrom(
this.http.post<{ data?: PurchaseDetailResponse } | PurchaseDetailResponse>(
`${environment.url}tenants/${tenantCode}/compras/start-checkout`,
payload,
),
);
const purchase = this.extractResponseData<PurchaseDetailResponse>(response);
const purchase = this.extractResponseData<PurchaseDetailResponse>(response);
if (!purchase?.id) {
throw new Error('Error al crear la compra.');
if (!purchase?.id) {
throw new Error('Error al crear la compra.');
}
return purchase;
} catch (error) {
const responseBody = (error as { error?: unknown } | null)?.error;
if (!isExpiredStockReservationResponse(responseBody)) {
this.catalogAvailabilityService.notifyAvailabilityChanged();
}
throw error;
}
return purchase;
}
async generatePaymentIntent(
@@ -169,65 +229,6 @@ export class CheckoutService extends BaseApiService {
return purchase;
}
async updateItemQuantity(
tenantCode: string,
purchaseId: number,
itemId: number,
quantity: number,
): Promise<PurchaseDetailResponse> {
const response = await firstValueFrom(
this.http.patch<{ data?: PurchaseDetailResponse } | PurchaseDetailResponse>(
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/items/${itemId}`,
{ quantity },
),
);
const purchase = this.extractResponseData<PurchaseDetailResponse>(response);
if (!purchase) {
throw new Error('Error al actualizar la cantidad del producto.');
}
return purchase;
}
async prepareItemEditing(
tenantCode: string,
purchaseId: number,
): Promise<PurchaseDetailResponse> {
const response = await firstValueFrom(
this.http.post<{ data?: PurchaseDetailResponse } | PurchaseDetailResponse>(
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/edit-items`,
{},
),
);
const purchase = this.extractResponseData<PurchaseDetailResponse>(response);
if (!purchase) {
throw new Error('Error al preparar la compra para editarla.');
}
return purchase;
}
async completePurchase(tenantCode: string, purchaseId: number): Promise<PurchaseStatusResponse> {
const response = await firstValueFrom(
this.http.post<{ data?: PurchaseStatusResponse } | PurchaseStatusResponse>(
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/complete`,
{},
),
);
const purchase = this.extractResponseData<PurchaseStatusResponse>(response);
if (!purchase) {
throw new Error('Error al finalizar la compra.');
}
return {
status: purchase.status ?? null,
};
}
async submitPurchaseForReview(
tenantCode: string,
purchaseId: number,
@@ -244,7 +245,7 @@ export class CheckoutService extends BaseApiService {
throw new Error('Error al enviar la compra a revisi\u00f3n.');
}
return { status: purchase.status ?? null };
return purchase;
}
async cancelPurchase(tenantCode: string, purchaseId: number): Promise<PurchaseStatusResponse> {
@@ -260,18 +261,26 @@ export class CheckoutService extends BaseApiService {
throw new Error('Error al cancelar la compra.');
}
return { status: purchase.status ?? null };
return purchase;
}
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));
pagination: ApiPaginationQueryParams = {},
): Promise<ApiPaginatedResponse<PurchaseSummaryResponse[]>> {
const params: Record<string, string | number> = {};
if (status) params['status'] = status;
if (pagination.page) params['page'] = pagination.page;
if (pagination.per_page) params['per_page'] = pagination.per_page;
const response = await firstValueFrom(
this.http.get<ApiPaginatedResponse<PurchaseSummaryResponse[]>>(
`${environment.url}tenants/${tenantCode}/compras`,
{ params },
),
);
if (!response) {
throw new Error('Error al obtener las compras.');
}

View File

@@ -0,0 +1,59 @@
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 { EventDateNotice, EventDateNoticeService } from './event-date-notice.service';
import { ApiResponse } from './api-response.interface';
import { TenantService } from './tenant.service';
describe('EventDateNoticeService', () => {
let service: EventDateNoticeService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
EventDateNoticeService,
provideHttpClient(),
provideHttpClientTesting(),
{
provide: TenantService,
useValue: { getTenant: () => ({ codigo: 'festival' }) },
},
],
});
service = TestBed.inject(EventDateNoticeService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('claims pending notices for the active tenant', () => {
const response = {
data: [
{
type: 'suspended' as const,
change_ids: [10],
title: 'FECHA CANCELADA!',
message: [{ text: 'La fecha fue cancelada.', bold: false }],
},
],
};
let result: ApiResponse<EventDateNotice[]> | undefined;
service.claim().subscribe((value) => (result = value));
const request = httpMock.expectOne(
`${environment.url}tenants/festival/event-date-notices/claim`,
);
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual({});
request.flush(response);
expect(result).toEqual(response);
});
});

View File

@@ -0,0 +1,39 @@
import { inject, Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../../environments/environment';
import { ApiResponse } from './api-response.interface';
import { BaseApiService } from './base-api.service';
import { TenantService } from './tenant.service';
export type EventDateNoticeType = 'rescheduled' | 'suspended';
export interface EventDateNoticeSegment {
text: string;
bold: boolean;
}
export interface EventDateNotice {
type: EventDateNoticeType;
change_ids: number[];
title: string;
message: EventDateNoticeSegment[];
}
@Injectable({ providedIn: 'root' })
export class EventDateNoticeService extends BaseApiService {
private readonly tenantService = inject(TenantService);
claim(): Observable<ApiResponse<EventDateNotice[]>> {
const tenant = this.tenantService.getTenant();
if (!tenant) {
throw new Error('No se pudo resolver el tenant activo.');
}
return this.withoutLoading().http.post<ApiResponse<EventDateNotice[]>>(
`${environment.url}tenants/${tenant.codigo}/event-date-notices/claim`,
{},
);
}
}

View File

@@ -0,0 +1,35 @@
import { ApiPaginatedResponse } from '../api-paginated-response.interface';
export interface StoreEvent {
id: number;
event_category_id: number | null;
title: string;
subtitle: string | null;
description: string | null;
location: string | null;
exact_location: { latitude: number; longitude: number } | null;
date_text: string | null;
start_time?: string | null;
starts_at?: string | null;
dates?: { id: number; date: string; time_start: string }[];
social_media?: { code: string; url: string }[];
attachment_id: number | null;
image: string | null;
catalog_items?: {
id: number;
name: string;
description: string | null;
price: string;
image: string | null;
requires_selection: boolean;
maximum_quantity: number | null;
variants: {
id: number;
price: string;
event_date_ids: number[];
maximum_quantity: number | null;
}[];
}[];
}
export type StoreEventPage = ApiPaginatedResponse<StoreEvent[]>;

View File

@@ -0,0 +1,24 @@
import { inject, Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { BaseApiService } from '../base-api.service';
import { ApiResponse } from '../api-response.interface';
import { TenantService } from '../tenant.service';
import { StoreEvent, StoreEventPage } from './event.interface';
@Injectable({ providedIn: 'root' })
export class EventService extends BaseApiService {
private readonly tenantService = inject(TenantService);
list(page: number, query = '', categoryId?: number): Observable<StoreEventPage> {
return this.http.get<StoreEventPage>(`${this.tenantService.getTenantApiUrl()}/events`, {
params: { page, ...(query ? { q: query } : {}), ...(categoryId ? { category_id: categoryId } : {}) },
});
}
get(id: number): Observable<ApiResponse<StoreEvent>> {
return this.http.get<ApiResponse<StoreEvent>>(
`${this.tenantService.getTenantApiUrl()}/events/${id}`,
);
}
}

View File

@@ -7,6 +7,7 @@ import { firstValueFrom } from 'rxjs';
import { ConfirmDeleteModalComponent } from '../../shared/components/confirm-delete-modal/confirm-delete-modal.component';
import { ConfirmModalComponent } from '../../shared/components/confirm-modal/confirm-modal.component';
import { ImageModalComponent } from '../../shared/components/image-modal/image-modal.component';
import { QrModalComponent } from '../../shared/components/qr-modal/qr-modal.component';
import { SimpleModalComponent } from '../../shared/components/simple-modal/simple-modal.component';
import { MODAL_DATA, ModalRef, ModalService } from './modal.service';
@@ -79,6 +80,21 @@ describe('ModalService', () => {
expect(ref.dismissReason()).toBeNull();
});
it('keeps a modal opened synchronously from an afterClosed subscriber', () => {
const firstRef = service.open(FirstTestModalComponent);
let secondRef: ModalRef | undefined;
firstRef.afterClosed$.subscribe(() => {
secondRef = service.open(SecondTestModalComponent);
});
firstRef.close();
expect(secondRef).toBeDefined();
expect(service.activeModal()?.ref).toBe(secondRef);
expect(service.activeModal()?.component).toBe(SecondTestModalComponent);
});
it('tracks dismiss reasons when closing programmatically', () => {
const ref = service.open(FirstTestModalComponent);
const closedSpy = vi.fn();
@@ -121,7 +137,7 @@ describe('ModalService', () => {
it('opens the standard confirm modal with default labels', () => {
const result$ = service.openConfirm({
title: 'Confirmar compra',
content: 'Esto confirmara la compra actual.',
description: 'Esto confirmara la compra actual.',
});
const activeModal = service.activeModal();
@@ -129,9 +145,9 @@ describe('ModalService', () => {
expect(activeModal?.component).toBe(ConfirmModalComponent);
expect(result$).toBeDefined();
expect(activeModal?.config).toEqual({
title: 'Confirmar compra',
data: {
content: 'Esto confirmara la compra actual.',
title: 'Confirmar compra',
description: 'Esto confirmara la compra actual.',
confirmLabel: 'Confirmar',
cancelLabel: 'Cancelar',
},
@@ -145,7 +161,7 @@ describe('ModalService', () => {
it('maps the confirm modal close result to true', async () => {
const result$ = service.openConfirm({
title: 'Confirmar compra',
content: 'Esto confirmara la compra actual.',
description: 'Esto confirmara la compra actual.',
});
const activeModal = service.activeModal();
const resultPromise = firstValueFrom(result$);
@@ -158,7 +174,7 @@ describe('ModalService', () => {
it('maps dismissing a confirm modal to false', async () => {
const result$ = service.openConfirmDelete({
title: 'Eliminar producto',
content: 'Se eliminara el producto.',
description: 'Se eliminara el producto.',
});
const activeModal = service.activeModal();
const resultPromise = firstValueFrom(result$);
@@ -173,7 +189,7 @@ describe('ModalService', () => {
it('opens the delete confirm modal preserving modal overrides', () => {
service.openConfirmDelete({
title: 'Eliminar producto',
content: 'Se eliminara el producto.',
description: 'Se eliminara el producto.',
confirmLabel: 'Eliminar',
cancelLabel: 'Conservar',
size: 'lg',
@@ -186,9 +202,9 @@ describe('ModalService', () => {
expect(activeModal?.component).toBe(ConfirmDeleteModalComponent);
expect(activeModal?.config).toEqual({
title: 'Eliminar producto',
data: {
content: 'Se eliminara el producto.',
title: 'Eliminar producto',
description: 'Se eliminara el producto.',
confirmLabel: 'Eliminar',
cancelLabel: 'Conservar',
},
@@ -202,16 +218,16 @@ describe('ModalService', () => {
it('opens the simple modal with default button label', () => {
service.openSimple({
title: 'Aviso',
content: 'Este es un aviso simple.',
description: 'Este es un aviso simple.',
});
const activeModal = service.activeModal();
expect(activeModal?.component).toBe(SimpleModalComponent);
expect(activeModal?.config).toEqual({
title: 'Aviso',
data: {
content: 'Este es un aviso simple.',
title: 'Aviso',
description: 'Este es un aviso simple.',
buttonLabel: 'Entendido',
},
size: 'md',
@@ -224,7 +240,7 @@ describe('ModalService', () => {
it('maps the simple modal close result to undefined', async () => {
const result$ = service.openSimple({
title: 'Aviso',
content: 'Este es un aviso simple.',
description: 'Este es un aviso simple.',
});
const activeModal = service.activeModal();
const resultPromise = firstValueFrom(result$);
@@ -258,6 +274,36 @@ describe('ModalService', () => {
showCloseButton: true,
});
});
it('opens the image viewer fullscreen with normalized zoom limits', () => {
service.openImage({
title: 'Producto',
src: '/images/producto.webp',
alt: 'Producto visto de frente',
initialZoom: 8,
minZoom: 0,
maxZoom: 3,
});
const activeModal = service.activeModal();
expect(activeModal?.component).toBe(ImageModalComponent);
expect(activeModal?.config).toEqual({
title: 'Producto',
size: 'full',
presentation: 'fullscreen-media',
data: {
src: '/images/producto.webp',
alt: 'Producto visto de frente',
initialZoom: 3,
minZoom: 0.1,
maxZoom: 3,
},
closeOnBackdrop: true,
closeOnEscape: true,
showCloseButton: true,
});
});
});
@Component({

View File

@@ -2,16 +2,19 @@ import { InjectionToken, Injectable, Type, signal } from '@angular/core';
import { Observable, Subject, map } from 'rxjs';
import { ConfirmDeleteModalComponent } from '../../shared/components/confirm-delete-modal/confirm-delete-modal.component';
import { ConfirmModalComponent } from '../../shared/components/confirm-modal/confirm-modal.component';
import { ImageModalComponent } from '../../shared/components/image-modal/image-modal.component';
import { QrModalComponent } from '../../shared/components/qr-modal/qr-modal.component';
import { SimpleModalComponent } from '../../shared/components/simple-modal/simple-modal.component';
export type ModalSize = 'qr' | 'sm' | 'md' | 'lg' | 'xl' | 'full';
export type ModalDismissReason = 'backdrop' | 'escape' | 'programmatic' | 'replaced';
export type ModalDismissReason = 'backdrop' | 'escape' | 'programmatic' | 'replaced' | 'swipe';
export type ModalPresentation = 'dialog' | 'fullscreen-media';
export interface ModalConfig<TData = unknown> {
title?: string;
data?: TData;
size?: ModalSize;
presentation?: ModalPresentation;
closeOnBackdrop?: boolean;
closeOnEscape?: boolean;
showCloseButton?: boolean;
@@ -28,24 +31,28 @@ export interface NormalizedModalConfig<TData = unknown> extends Omit<
}
export interface ConfirmModalData {
content: string;
title: string;
description?: string;
confirmLabel: string;
cancelLabel: string;
}
export interface ConfirmModalConfig extends Omit<ModalConfig<ConfirmModalData>, 'data'> {
content: string;
title: string;
description?: string;
confirmLabel?: string;
cancelLabel?: string;
}
export interface SimpleModalData {
content: string;
title: string;
description?: string;
buttonLabel: string;
}
export interface SimpleModalConfig extends Omit<ModalConfig<SimpleModalData>, 'data'> {
content: string;
title: string;
description?: string;
buttonLabel?: string;
}
@@ -62,6 +69,25 @@ export interface QrModalConfig extends Omit<ModalConfig<QrModalData>, 'data' | '
ticket: string;
}
export interface ImageModalData {
src: string;
alt: string;
initialZoom: number;
minZoom: number;
maxZoom: number;
}
export interface ImageModalConfig extends Omit<
ModalConfig<ImageModalData>,
'data' | 'presentation' | 'size'
> {
src: string;
alt: string;
initialZoom?: number;
minZoom?: number;
maxZoom?: number;
}
export interface ActiveModalState<TResult = unknown, TData = unknown> {
component: Type<unknown>;
config: NormalizedModalConfig<TData>;
@@ -195,13 +221,40 @@ export class ModalService {
});
}
openImage(config: ImageModalConfig): Observable<void> {
return this.openImageRef(config).afterClosed$.pipe(map(() => undefined));
}
openImageRef(config: ImageModalConfig): ModalRef<void> {
const { src, alt, initialZoom = 1, minZoom = 1, maxZoom = 4, ...modalConfig } = config;
const normalizedMinZoom = Math.max(0.1, minZoom);
const normalizedMaxZoom = Math.max(normalizedMinZoom, maxZoom);
const normalizedInitialZoom = Math.min(
normalizedMaxZoom,
Math.max(normalizedMinZoom, initialZoom),
);
return this.open(ImageModalComponent, {
...modalConfig,
size: 'full',
presentation: 'fullscreen-media',
data: {
src,
alt,
initialZoom: normalizedInitialZoom,
minZoom: normalizedMinZoom,
maxZoom: normalizedMaxZoom,
},
});
}
private close<TResult>(ref: ModalRef<TResult>, result?: TResult): void {
if (this.activeModalState()?.ref !== ref) {
return;
}
ref.finalize(result);
this.activeModalState.set(null);
ref.finalize(result);
}
private dismiss<TResult>(
@@ -212,8 +265,8 @@ export class ModalService {
return;
}
ref.finalize(undefined, reason);
this.activeModalState.set(null);
ref.finalize(undefined, reason);
}
private normalizeConfig<TData>(config: ModalConfig<TData>): NormalizedModalConfig<TData> {
@@ -225,7 +278,8 @@ export class ModalService {
private buildConfirmModalConfig(config: ConfirmModalConfig): ModalConfig<ConfirmModalData> {
const {
content,
title,
description,
confirmLabel = DEFAULT_CONFIRM_MODAL_LABELS.confirmLabel,
cancelLabel = DEFAULT_CONFIRM_MODAL_LABELS.cancelLabel,
...modalConfig
@@ -234,7 +288,8 @@ export class ModalService {
return {
...modalConfig,
data: {
content,
title,
description,
confirmLabel,
cancelLabel,
},
@@ -242,12 +297,13 @@ export class ModalService {
}
private buildSimpleModalConfig(config: SimpleModalConfig): ModalConfig<SimpleModalData> {
const { content, buttonLabel = 'Entendido', ...modalConfig } = config;
const { title, description, buttonLabel = 'Entendido', ...modalConfig } = config;
return {
...modalConfig,
data: {
content,
title,
description,
buttonLabel,
},
};

View File

@@ -1,5 +1,15 @@
import { ApiResponse } from './api-response.interface';
export type CartEditingPolicyCode = 'disabled' | 'quantity_and_remove' | 'full';
export interface CartEditingPolicy {
code: CartEditingPolicyCode;
allow_modify: boolean;
allow_delete: boolean;
allow_update_quantity: boolean;
allow_update_variant: boolean;
}
export interface BankAccount {
id: number;
tenant_code: string;
@@ -9,16 +19,6 @@ 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_id?: string | ResponsiveImage | null;
title_html?: string | null;
@@ -41,6 +41,8 @@ export interface EventDate {
date: string;
start_time: string;
end_time: string;
info_text: string | null;
isCanceled: boolean;
}
export interface EventConfig {
@@ -57,16 +59,29 @@ export interface ActiveEventDate {
date: string;
time_start: string;
time_end: string;
info_text: string | null;
isCanceled: boolean;
}
export interface TenantEvent {
id: number;
title: string;
location: string;
subtitle: string | null;
description: string | null;
location: string | null;
date_text: string | null;
dates: ActiveEventDate[];
social_media: SocialMedia[];
}
export interface WebsiteExtras {
carousel?: string[];
immersiveHeroCarousel?: string[];
immersiveHero?: {
eyebrow: string;
title: string;
description: string;
};
heroConfig?: HeroConfig | null;
additionalInfoConfig?: AdditionalInfoConfig | null;
[extraName: string]: unknown;
@@ -102,7 +117,11 @@ export interface Tenant {
nombre: string;
dominio: string;
base_path?: string;
timezone?: string;
site_title?: string | null;
asset_url?: string | null;
address?: string | null;
phone?: string | null;
favicon?: string | null;
primary_color: string;
secondary_color: string;
@@ -114,9 +133,9 @@ export interface Tenant {
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;
storefront_website_type_code?: string | null;
header_type?: 'standard' | 'immersive';
checkout_summary_type?: 'default' | 'event';
extras?: WebsiteExtras;
event?: TenantEvent | null;
selected_bank_account_id?: number | null;
@@ -127,11 +146,17 @@ export interface Tenant {
display_categories?: boolean;
display_seach_bar?: boolean;
display_cart?: boolean;
cart_editing_enabled?: boolean;
cart_editing_policy?: CartEditingPolicy;
checkout_editing_policy?: CartEditingPolicy;
display_cart_item_images?: boolean;
allow_ticket_refund?: boolean;
allow_ticket_total_refund?: boolean;
allow_ticket_partial_refund?: boolean;
ticket_partial_refund_percentage?: string;
social_media?: SocialMedia[];
menues?: Menu[];
categories: Category[];
categories?: Category[];
event_categories?: Category[];
}
export type TenantBootstrapResponse = ApiResponse<Tenant>;

View File

@@ -15,6 +15,7 @@ const tenant: Tenant = {
codigo: 'test',
nombre: 'Test Tenant',
dominio: 'localhost',
asset_url: 'https://s3.example.com/assets',
primary_color: '#6376F3',
secondary_color: '#A0A0A0',
danger_color: '#FF8888',
@@ -44,6 +45,7 @@ const tenantResponse: TenantBootstrapResponse = {
describe('TenantService', () => {
beforeEach(() => {
document.head.querySelectorAll('link[rel="preconnect"]').forEach((link) => link.remove());
try {
window.history.replaceState({}, '', 'http://localhost:4200/');
} catch (e) {
@@ -72,6 +74,9 @@ describe('TenantService', () => {
expect(service.status()).toBe('ready');
expect(service.tenant()).toEqual(tenant);
expect(service.getTenant()).toEqual(tenant);
expect(
document.head.querySelector('link[rel="preconnect"][href="https://s3.example.com/"]'),
).not.toBeNull();
httpController.verify();
});

View File

@@ -1,4 +1,4 @@
import { isPlatformBrowser, isPlatformServer } from '@angular/common';
import { DOCUMENT, isPlatformBrowser, isPlatformServer } from '@angular/common';
import { HttpErrorResponse } from '@angular/common/http';
import {
inject,
@@ -26,6 +26,7 @@ import {
providedIn: 'root',
})
export class TenantService extends BaseApiService {
private readonly document = inject(DOCUMENT);
private readonly platformId = inject(PLATFORM_ID);
private readonly request = inject(REQUEST, { optional: true });
private readonly responseInit = inject(RESPONSE_INIT, { optional: true });
@@ -182,10 +183,44 @@ export class TenantService extends BaseApiService {
}
private setReady(tenant: Tenant): void {
if (isPlatformBrowser(this.platformId) && !environment.production) {
console.info('[Tenant bootstrap]', tenant);
}
this.ensureAssetPreconnect(tenant.asset_url);
this.tenantState.set(tenant);
this.statusState.set('ready');
}
private ensureAssetPreconnect(assetUrl: string | null | undefined): void {
if (!assetUrl) {
return;
}
let origin: string;
try {
const url = new URL(assetUrl);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return;
}
origin = url.origin;
} catch {
return;
}
const existingPreconnects =
this.document.head.querySelectorAll<HTMLLinkElement>('link[rel="preconnect"]');
if ([...existingPreconnects].some((link) => link.href === `${origin}/`)) {
return;
}
const link = this.document.createElement('link');
link.rel = 'preconnect';
link.href = origin;
this.document.head.append(link);
}
private setNotFound(): void {
this.tenantState.set(null);
this.statusState.set('not-found');

View File

@@ -1,3 +1,4 @@
import { DOCUMENT } from '@angular/common';
import { TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import { ToastService } from './toast.service';
@@ -6,14 +7,16 @@ describe('ToastService', () => {
let service: ToastService;
beforeEach(() => {
vi.useFakeTimers();
TestBed.configureTestingModule({
providers: [ToastService]
});
TestBed.inject(DOCUMENT).defaultView?.sessionStorage.removeItem('shopit.pending-toast');
service = TestBed.inject(ToastService);
vi.useFakeTimers();
});
afterEach(() => {
TestBed.inject(DOCUMENT).defaultView?.sessionStorage.removeItem('shopit.pending-toast');
vi.useRealTimers();
});
@@ -98,4 +101,23 @@ describe('ToastService', () => {
expect(service.toasts().length).toBe(1);
});
it('should restore a toast queued for after a full page reload', () => {
service.showAfterReload('Sesión iniciada correctamente.', 'success', 0);
TestBed.resetTestingModule();
TestBed.configureTestingModule({ providers: [ToastService] });
service = TestBed.inject(ToastService);
expect(service.toasts()).toEqual([
expect.objectContaining({
message: 'Sesión iniciada correctamente.',
type: 'success',
duration: 0,
}),
]);
expect(
TestBed.inject(DOCUMENT).defaultView?.sessionStorage.getItem('shopit.pending-toast'),
).toBeNull();
});
});

View File

@@ -1,4 +1,7 @@
import { Injectable, signal } from '@angular/core';
import { DOCUMENT, isPlatformBrowser } from '@angular/common';
import { inject, Injectable, PLATFORM_ID, signal } from '@angular/core';
const PENDING_TOAST_STORAGE_KEY = 'shopit.pending-toast';
export interface Toast {
id: string;
@@ -11,9 +14,15 @@ export interface Toast {
providedIn: 'root'
})
export class ToastService {
private readonly document = inject(DOCUMENT);
private readonly platformId = inject(PLATFORM_ID);
private readonly toastsSignal = signal<Toast[]>([]);
readonly toasts = this.toastsSignal.asReadonly();
constructor() {
this.restorePendingToast();
}
show(message: string, type: 'success' | 'danger' | 'info' = 'info', duration = 3000): string {
const id = Math.random().toString(36).substring(2, 9);
const newToast: Toast = { id, message, type, duration };
@@ -41,7 +50,50 @@ export class ToastService {
return this.show(message, 'success', duration);
}
showAfterReload(
message: string,
type: Toast['type'] = 'info',
duration = 3000,
): void {
if (!isPlatformBrowser(this.platformId)) {
return;
}
this.document.defaultView?.sessionStorage.setItem(
PENDING_TOAST_STORAGE_KEY,
JSON.stringify({ message, type, duration }),
);
}
dismiss(id: string): void {
this.toastsSignal.update((toasts) => toasts.filter((t) => t.id !== id));
}
private restorePendingToast(): void {
if (!isPlatformBrowser(this.platformId)) {
return;
}
const storage = this.document.defaultView?.sessionStorage;
const pendingToast = storage?.getItem(PENDING_TOAST_STORAGE_KEY);
if (!pendingToast) {
return;
}
storage?.removeItem(PENDING_TOAST_STORAGE_KEY);
try {
const parsed = JSON.parse(pendingToast) as Partial<Toast>;
if (
typeof parsed.message === 'string' &&
(parsed.type === 'success' || parsed.type === 'danger' || parsed.type === 'info')
) {
this.show(parsed.message, parsed.type, parsed.duration);
}
} catch {
// Ignore malformed session data left by an older or interrupted client.
}
}
}

View File

@@ -83,6 +83,9 @@
Abrir modal ancho
</app-button>
<app-button (click)="openSimpleModal()"> Abrir simple modal </app-button>
<app-button variant="secondary" (click)="openImageModal()">
Abrir visor de imagen
</app-button>
</div>
<div class="modal-showcase__result" data-testid="modal-last-result">
{{ lastModalResult }}
@@ -703,7 +706,7 @@
<app-location-map
[locations]="mapLocations"
[markerColor]="tenant()?.primary_color ?? '#24a9e0'"
mapStyle="minimal-light"
mapStyle="standard"
ariaLabel="Mapa de puntos de atención en Rosario"
/>
</div>

View File

@@ -51,6 +51,7 @@ function createModalServiceStub() {
open: vi.fn(),
openConfirm: vi.fn().mockReturnValue(of(true)),
openConfirmDelete: vi.fn().mockReturnValue(of(false)),
openImage: vi.fn().mockReturnValue(of(undefined)),
};
}
@@ -281,6 +282,9 @@ describe('ReutilizablesTestPageComponent', () => {
const deleteButton = buttons.find((button) =>
button.textContent?.includes('Abrir confirm delete'),
) as HTMLButtonElement;
const imageButton = buttons.find((button) =>
button.textContent?.includes('Abrir visor de imagen'),
) as HTMLButtonElement;
expect(element.querySelector('[data-testid="modal-last-result"]')?.textContent).toContain(
'Todavia no se abrio ningun modal.',
@@ -290,18 +294,25 @@ describe('ReutilizablesTestPageComponent', () => {
fixture.detectChanges();
deleteButton.click();
fixture.detectChanges();
imageButton.click();
fixture.detectChanges();
expect(modalServiceStub.openConfirm).toHaveBeenCalledWith({
title: 'Confirmar accion',
content: 'Caso base para verificar apertura, cierre y devolucion de resultado.',
description: 'Caso base para verificar apertura, cierre y devolucion de resultado.',
confirmLabel: 'Confirmar',
});
expect(modalServiceStub.openConfirmDelete).toHaveBeenCalledWith({
title: 'Eliminar producto',
content:
description:
'Esta accion eliminara el producto del catalogo. Podras volver a crearlo manualmente.',
confirmLabel: 'Eliminar',
});
expect(modalServiceStub.openImage).toHaveBeenCalledWith({
title: 'Mochila urbana roja',
src: '/images/carousel-mochila-roja.webp',
alt: 'Mochila urbana roja vista de frente',
});
expect(element.querySelector('[data-testid="modal-last-result"]')?.textContent).toContain(
'Resultado: false',
);

View File

@@ -232,7 +232,7 @@ export class ReutilizablesTestPageComponent {
{
id: 1001,
precio: 250000,
stock_tecnico: 1,
maximum_addable_quantity: 1,
values: {
tipo: ticketOption('vip_lunch', 'VIP + Lunch'),
sector: ticketOption('a', 'Sector A'),
@@ -243,7 +243,7 @@ export class ReutilizablesTestPageComponent {
{
id: 1002,
precio: 250000,
stock_tecnico: 1,
maximum_addable_quantity: 1,
values: {
tipo: ticketOption('vip_lunch', 'VIP + Lunch'),
sector: ticketOption('a', 'Sector A'),
@@ -254,7 +254,7 @@ export class ReutilizablesTestPageComponent {
{
id: 1003,
precio: 250000,
stock_tecnico: 1,
maximum_addable_quantity: 1,
values: {
tipo: ticketOption('vip_lunch', 'VIP + Lunch'),
sector: ticketOption('c', 'Sector C'),
@@ -265,7 +265,7 @@ export class ReutilizablesTestPageComponent {
{
id: 1004,
precio: 200000,
stock_tecnico: 1,
maximum_addable_quantity: 1,
values: {
tipo: ticketOption('vip_lunch', 'VIP + Lunch'),
sector: ticketOption('a', 'Sector A'),
@@ -276,7 +276,7 @@ export class ReutilizablesTestPageComponent {
{
id: 1005,
precio: 200000,
stock_tecnico: 0,
maximum_addable_quantity: 0,
values: {
tipo: ticketOption('vip_lunch', 'VIP + Lunch'),
sector: ticketOption('a', 'Sector A'),
@@ -287,7 +287,7 @@ export class ReutilizablesTestPageComponent {
{
id: 1006,
precio: 100000,
stock_tecnico: 1,
maximum_addable_quantity: 1,
values: {
tipo: ticketOption('general', 'General'),
sector: ticketOption('b', 'Sector B'),
@@ -298,7 +298,7 @@ export class ReutilizablesTestPageComponent {
{
id: 1007,
precio: 100000,
stock_tecnico: 1,
maximum_addable_quantity: 1,
values: {
tipo: ticketOption('general', 'General'),
sector: ticketOption('b', 'Sector B'),
@@ -309,7 +309,7 @@ export class ReutilizablesTestPageComponent {
{
id: 1008,
precio: 90000,
stock_tecnico: 1,
maximum_addable_quantity: 1,
values: {
tipo: ticketOption('general', 'General'),
sector: ticketOption('d', 'Sector D'),
@@ -320,7 +320,7 @@ export class ReutilizablesTestPageComponent {
{
id: 1009,
precio: 65000,
stock_tecnico: 1,
maximum_addable_quantity: 1,
values: {
tipo: ticketOption('general', 'General'),
sector: ticketOption('d', 'Sector D'),
@@ -331,7 +331,7 @@ export class ReutilizablesTestPageComponent {
{
id: 1010,
precio: 40000,
stock_tecnico: 1,
maximum_addable_quantity: 1,
values: {
tipo: ticketOption('general', 'General'),
sector: ticketOption('d', 'Sector D'),
@@ -448,7 +448,7 @@ export class ReutilizablesTestPageComponent {
protected openBasicModal(): void {
this.openConfirmModal({
title: 'Confirmar accion',
content: 'Caso base para verificar apertura, cierre y devolucion de resultado.',
description: 'Caso base para verificar apertura, cierre y devolucion de resultado.',
confirmLabel: 'Confirmar',
});
}
@@ -456,7 +456,7 @@ export class ReutilizablesTestPageComponent {
protected openConfirmDeleteModal(): void {
this.openConfirmDelete({
title: 'Eliminar producto',
content:
description:
'Esta accion eliminara el producto del catalogo. Podras volver a crearlo manualmente.',
confirmLabel: 'Eliminar',
});
@@ -465,7 +465,7 @@ export class ReutilizablesTestPageComponent {
protected openLockedModal(): void {
this.openConfirmModal({
title: 'Modal bloqueado',
content: 'Este modal no se cierra tocando el backdrop ni con la tecla Escape.',
description: 'Este modal no se cierra tocando el backdrop ni con la tecla Escape.',
confirmLabel: 'Entendido',
closeOnBackdrop: false,
closeOnEscape: false,
@@ -475,7 +475,7 @@ export class ReutilizablesTestPageComponent {
protected openWideModal(): void {
this.openConfirmModal({
title: 'Modal ancho',
content: 'Demuestra una variante visual mas amplia para contenido mas pesado.',
description: 'Demuestra una variante visual mas amplia para contenido mas pesado.',
confirmLabel: 'Seguir',
size: 'xl',
});
@@ -485,7 +485,7 @@ export class ReutilizablesTestPageComponent {
this.modalService
.openSimple({
title: 'Mensaje del sistema',
content: 'Este es un mensaje simple del sistema que no requiere confirmación.',
description: 'Este es un mensaje simple del sistema que no requiere confirmación.',
buttonLabel: 'Entendido',
})
.subscribe(() => {
@@ -493,6 +493,14 @@ export class ReutilizablesTestPageComponent {
});
}
protected openImageModal(): void {
this.modalService.openImage({
title: 'Mochila urbana roja',
src: '/images/carousel-mochila-roja.webp',
alt: 'Mochila urbana roja vista de frente',
});
}
private openConfirmModal(config: Parameters<ModalService['openConfirm']>[0]): void {
this.modalService.openConfirm(config).subscribe((confirmed) => {
this.lastModalResult = `Resultado: ${confirmed}`;

View File

@@ -0,0 +1,19 @@
<a class="event-card" [routerLink]="['/evento', event().id]">
<div class="event-card__image">
@if (event().image) {
<img [src]="event().image" [alt]="event().title" />
}
</div>
<div class="event-card__body">
<h2 class="event-card__title">{{ event().title | uppercase }}</h2>
@if (event().subtitle) {
<p class="event-card__subtitle">{{ event().subtitle }}</p>
}
@if (event().date_text) {
<p class="event-card__meta"><i class="fa-regular fa-calendar" aria-hidden="true"></i>{{ event().date_text }}</p>
}
@if (event().location) {
<p class="event-card__meta"><i class="fa-solid fa-location-dot" aria-hidden="true"></i>{{ event().location }}</p>
}
</div>
</a>

View File

@@ -0,0 +1,11 @@
:host { display: block; min-width: 0; }
.event-card { display: block; height: 100%; color: inherit; background: #fff; text-decoration: none; box-shadow: 0 12px 30px rgba(0, 0, 0, .05); transition: transform .2s, box-shadow .2s; }
.event-card:hover { transform: translateY(-3px); box-shadow: 0 16px 32px rgba(0, 0, 0, .1); }
.event-card:focus-visible { outline: 2px solid var(--tenant-primary); outline-offset: 3px; }
.event-card__image { width: 100%; aspect-ratio: 1.43; background: #e5e6e7; }
.event-card__image img { width: 100%; height: 100%; object-fit: cover; display: block; }
.event-card__body { padding: 1rem 1.1rem 1.2rem; }
.event-card__title { margin: 0; font-size: 15px; font-weight: 700; line-height: 1.3; color: #666666; }
.event-card__subtitle { margin: .25rem 0 .8rem; font-size: 12px; font-weight: 400; color: #666666; }
.event-card__meta { display: flex; align-items: baseline; gap: .45rem; margin: .2rem 0 0; font-size: 9px; font-weight: 400; font-style: italic; color: #666666; }
.event-card__meta i { width: .75rem; flex: none; }

View File

@@ -0,0 +1,16 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { UpperCasePipe } from '@angular/common';
import { RouterLink } from '@angular/router';
import { StoreEvent } from '../../../../core/services/event/event.interface';
@Component({
selector: 'app-event-card',
imports: [RouterLink, UpperCasePipe],
templateUrl: './event-card.component.html',
styleUrl: './event-card.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EventCardComponent {
readonly event = input.required<StoreEvent>();
}

View File

@@ -0,0 +1,20 @@
@if (loading()) {
<p class="event-list__message">Cargando eventos...</p>
} @else if (error()) {
<p class="event-list__message" role="alert">No pudimos cargar los eventos.</p>
} @else if (results(); as page) {
@if (page.data.length) {
<div class="event-list__grid">
@for (event of page.data; track event.id) {
<app-event-card [event]="event" />
}
</div>
@if (page.meta.last_page > 1) {
<div class="event-list__pagination">
<app-paginator [page]="page.meta.current_page" [totalPages]="page.meta.last_page" (pageChange)="changePage($event)" />
</div>
}
} @else {
<p class="event-list__message">{{ search() ? 'No encontramos eventos para tu búsqueda.' : 'No hay eventos disponibles en este momento.' }}</p>
}
}

View File

@@ -0,0 +1,5 @@
.event-list__grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: clamp(1.25rem, 3vw, 2.5rem); }
.event-list__pagination { display: flex; justify-content: center; margin-top: 3rem; }
.event-list__message { text-align: center; padding: 2rem 0; }
@media (max-width: 767.98px) { .event-list__grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 479.98px) { .event-list__grid { grid-template-columns: 1fr; } }

View File

@@ -0,0 +1,68 @@
import { ChangeDetectionStrategy, Component, DestroyRef, inject, input, OnInit, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router } from '@angular/router';
import { catchError, combineLatest, distinctUntilChanged, map, of, switchMap, tap } from 'rxjs';
import { EventService } from '../../../../core/services/event/event.service';
import { StoreEventPage } from '../../../../core/services/event/event.interface';
import { PaginatorComponent } from '../../../../shared/components/paginator/paginator.component';
import { EventCardComponent } from '../event-card/event-card.component';
@Component({
selector: 'app-event-list',
imports: [EventCardComponent, PaginatorComponent],
templateUrl: './event-list.component.html',
styleUrl: './event-list.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EventListComponent implements OnInit {
private readonly eventService = inject(EventService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly destroyRef = inject(DestroyRef);
readonly search = input(false);
readonly categoryId = input<number | null>(null);
protected readonly results = signal<StoreEventPage | null>(null);
protected readonly loading = signal(true);
protected readonly error = signal(false);
protected readonly query = signal('');
ngOnInit(): void {
combineLatest([this.route.queryParamMap, this.route.paramMap]).pipe(
map(([params, routeParams]) => {
const requestedPage = Number(params.get('page'));
return {
page: Number.isSafeInteger(requestedPage) && requestedPage > 0 ? requestedPage : 1,
query: this.search() ? (params.get('q') ?? '').trim() : '',
categoryId: this.categoryId() === null ? null : Number(routeParams.get('id')),
};
}),
distinctUntilChanged((a, b) => a.page === b.page && a.query === b.query && a.categoryId === b.categoryId),
tap(({ query }) => {
this.query.set(query);
this.loading.set(true);
this.error.set(false);
this.results.set(null);
}),
switchMap(({ page, query, categoryId }) => this.eventService.list(page, query, categoryId ?? undefined).pipe(
catchError(() => {
this.error.set(true);
return of(null);
}),
)),
takeUntilDestroyed(this.destroyRef),
).subscribe((results) => {
this.results.set(results);
this.loading.set(false);
});
}
protected changePage(page: number): void {
void this.router.navigate([], {
relativeTo: this.route,
queryParams: { page: page === 1 ? null : page },
queryParamsHandling: 'merge',
});
}
}

View File

@@ -11,7 +11,7 @@
.menu-content-section__header {
display: flex;
align-items: center;
gap: 0.75rem;
gap: var(--menu-content-prefix-gap, 0.75rem);
margin-bottom: 1.25rem;
}

View File

@@ -1,7 +1,10 @@
import { TestBed } from '@angular/core/testing';
import { beforeEach, describe, expect, it } from 'vitest';
import { ProductAttribute } from '../../../../core/services/catalog/catalog.interface';
import {
CatalogItemVariant,
ProductAttribute,
} from '../../../../core/services/catalog/catalog.interface';
import { ProductAttributeSelectorComponent } from './product-attribute-selector.component';
describe('ProductAttributeSelectorComponent', () => {
@@ -24,6 +27,63 @@ describe('ProductAttributeSelectorComponent', () => {
}).compileComponents();
});
it.each([{ size: { value: 'S', label: 'Small' } }, { size: [{ value: 'S', label: 'Small' }] }])(
'initializes and matches structured variant values: %j',
(values) => {
const fixture = TestBed.createComponent(ProductAttributeSelectorComponent);
const variant: CatalogItemVariant = { id: 1, maximum_addable_quantity: null, values };
const emittedIds: Array<number | null> = [];
fixture.componentInstance.variantChange.subscribe((selected) =>
emittedIds.push(selected?.id ?? null),
);
fixture.componentRef.setInput('attributes', [sizeAttribute]);
fixture.componentRef.setInput('inventoryPolicy', 'unlimited');
fixture.componentRef.setInput('variants', [variant]);
fixture.componentRef.setInput('selectedVariant', variant);
fixture.detectChanges();
const button = fixture.nativeElement.querySelector(
'.attribute-selector__text-option',
) as HTMLButtonElement;
expect(button.getAttribute('aria-pressed')).toBe('true');
expect(button.disabled).toBe(false);
expect(emittedIds.at(-1)).toBe(1);
},
);
it.each([0, 2, null])(
'uses maximum quantity %s for alternatives to a preselected option',
(maximum) => {
const fixture = TestBed.createComponent(ProductAttributeSelectorComponent);
const variants: CatalogItemVariant[] = [
{ id: 1, maximum_addable_quantity: 3, values: { size: { value: 'S', label: 'Small' } } },
{
id: 2,
maximum_addable_quantity: maximum,
values: { size: { value: 'M', label: 'Medium' } },
},
];
const emittedIds: Array<number | null> = [];
fixture.componentInstance.variantChange.subscribe((variant) =>
emittedIds.push(variant?.id ?? null),
);
fixture.componentRef.setInput('attributes', [sizeAttribute]);
fixture.componentRef.setInput('inventoryPolicy', maximum === null ? 'unlimited' : 'tracked');
fixture.componentRef.setInput('variants', variants);
fixture.componentRef.setInput('selectedVariant', variants[0]);
fixture.detectChanges();
const buttons = fixture.nativeElement.querySelectorAll(
'.attribute-selector__text-option',
) as NodeListOf<HTMLButtonElement>;
expect(buttons[1].disabled).toBe(maximum === 0);
buttons[1].click();
fixture.detectChanges();
expect(emittedIds.at(-1)).toBe(maximum === 0 ? 1 : 2);
expect(buttons[0].disabled).toBe(false);
},
);
it('keeps an unlimited option available when maximum quantity is null', () => {
const fixture = TestBed.createComponent(ProductAttributeSelectorComponent);
fixture.componentRef.setInput('attributes', [sizeAttribute]);
@@ -31,7 +91,7 @@ describe('ProductAttributeSelectorComponent', () => {
fixture.componentRef.setInput('variants', [
{
id: 1,
stock_tecnico: null,
maximum_addable_quantity: null,
values: { size: 'S' },
},
]);
@@ -51,12 +111,12 @@ describe('ProductAttributeSelectorComponent', () => {
fixture.componentRef.setInput('variants', [
{
id: 1,
stock_tecnico: 0,
maximum_addable_quantity: 0,
values: { size: 'S' },
},
{
id: 2,
stock_tecnico: 2,
maximum_addable_quantity: 2,
values: { size: 'M' },
},
]);
@@ -92,9 +152,18 @@ describe('ProductAttributeSelectorComponent', () => {
]);
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'] } },
{ id: 1, maximum_addable_quantity: null, values: { event_date: '1' } },
{ id: 2, maximum_addable_quantity: null, values: { event_date: '2' } },
{
id: 3,
maximum_addable_quantity: null,
values: {
event_date: [
{ value: '1', label: '09/10/2026' },
{ value: '2', label: '10/10/2026' },
],
},
},
]);
fixture.detectChanges();
@@ -135,8 +204,8 @@ describe('ProductAttributeSelectorComponent', () => {
]);
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' } },
{ id: 1, maximum_addable_quantity: null, values: { size: 'S', internal_type: 'adult' } },
{ id: 2, maximum_addable_quantity: null, values: { size: 'M', internal_type: 'child' } },
]);
fixture.detectChanges();

View File

@@ -51,22 +51,15 @@ export class ProductAttributeSelectorComponent {
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 variantAttrValues = this.getVariantAttributeValues(attribute, variant.values);
const desiredOptionIds = selectedForAttribute.includes(option.id)
? selectedForAttribute
: [...selectedForAttribute, option.id];
const desiredOptionIds = !attribute.allow_multi_select
? [option.id]
: 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)
@@ -202,7 +195,7 @@ export class ProductAttributeSelectorComponent {
private getVariantAttributeValues(
attribute: ProductAttribute,
variantAttributes: Record<string, string | string[]>,
variantAttributes: CatalogItemVariant['values'],
): string[] {
const normalizedCodigo = this.normalizeText(attribute.codigo);
const normalizedNombre = this.normalizeText(attribute.nombre);
@@ -211,7 +204,9 @@ export class ProductAttributeSelectorComponent {
const normalizedKey = this.normalizeText(key);
if (normalizedKey === normalizedCodigo || normalizedKey === normalizedNombre) {
return (Array.isArray(value) ? value : [value]).map((item) => this.normalizeText(item));
return (Array.isArray(value) ? value : [value]).map((item) =>
this.normalizeText(typeof item === 'string' ? item : item.value || item.label),
);
}
}
@@ -227,7 +222,7 @@ export class ProductAttributeSelectorComponent {
}
private isVariantAvailable(variant: CatalogItemVariant): boolean {
return this.inventoryPolicy() === 'unlimited' || (variant.stock_tecnico ?? 0) > 0;
return variant.maximum_addable_quantity !== 0;
}
private findFirstHexValue(value: unknown): string | null {

View File

@@ -54,7 +54,7 @@
<!-- Thumbnails Row -->
@if (images().length > 1) {
<div class="product-carousel__thumbnails">
@for (image of images(); track image; let idx = $index) {
@for (image of images(); track $index; let idx = $index) {
<button
type="button"
class="product-carousel__thumbnail border-0 p-0 overflow-hidden bg-light"

View File

@@ -1,11 +1,29 @@
<div class="purchase-item" [routerLink]="[purchase.id]" style="cursor: pointer;">
<div class="purchase-info">
<span class="purchase-id">Compra {{ purchase.id }}.</span>
<span class="purchase-date">Fecha de compra: {{ purchase.date }}</span>
</div>
<div class="purchase-action">
<svg width="8" height="14" viewBox="0 0 8 14" fill="none" xmlns="http://www.w3.org/2000/svg" class="arrow-icon">
<path d="M1 1L7 7L1 13" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
</div>
<div class="purchase-item" [routerLink]="[purchase.id]">
<div class="purchase-info">
<span class="purchase-id">Compra {{ purchase.id }}.</span>
<span class="purchase-date">Fecha de compra: {{ purchase.date }}</span>
</div>
@if (purchase.statusMessage) {
<span class="purchase-status">{{ purchase.statusMessage }}</span>
}
<div class="purchase-action">
<svg
width="8"
height="14"
viewBox="0 0 8 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="arrow-icon"
>
<path
d="M1 1L7 7L1 13"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</div>
</div>

View File

@@ -2,33 +2,57 @@
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
padding-left:0;
transition: box-shadow 0.2s, border-radius 0.2s;
gap: 16px;
padding: 16px;
padding-left: 0;
transition:
box-shadow 0.2s,
border-radius 0.2s;
cursor: pointer;
}
.purchase-item:hover {
box-shadow: 0px 0px 30px 0px rgba(0, 0, 0, 0.07);
border-radius: 8px;
}
.purchase-item:hover .arrow-icon {
color: #5b75ff; /* Blue on hover */
color: var(--color-primary, var(--tenant-primary, #5b75ff));
}
.purchase-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.purchase-id {
font-weight: bold;
color: #666666;
font-size: 13px;
}
.purchase-date {
font-weight: 325;
color: #666666;
font-size: 10px;
}
.purchase-status {
margin-left: auto;
margin-right: 20px;
color: var(--color-primary, var(--tenant-primary, #5b75ff));
font-size: 16px;
font-weight: 700;
text-align: right;
}
.purchase-action {
display: flex;
flex-shrink: 0;
align-items: center;
}
.arrow-icon {
width: 8px;
height: 14px;

View File

@@ -9,6 +9,5 @@ import { RouterLink } from '@angular/router';
styleUrl: './purchase-list-item.scss',
})
export class PurchaseListItem {
@Input() purchase!: { id: number; date: string };
@Input() purchase!: { id: number; date: string; statusMessage?: string | null };
}

View File

@@ -1,29 +1,36 @@
<div class="purchase-list-container">
<ng-container *ngIf="isLoading(); else contentTpl">
<!-- Skeleton -->
<div class="skeleton-item" *ngFor="let i of [1, 2, 3, 4]">
<div class="skeleton-info">
<div class="skeleton-id"></div>
<div class="skeleton-date"></div>
</div>
<div class="skeleton-action"></div>
<div class="purchase-list-container">
@if (isLoading()) {
@for (i of [1, 2, 3, 4]; track i) {
<div class="skeleton-item">
<div class="skeleton-info">
<div class="skeleton-id"></div>
<div class="skeleton-date"></div>
</div>
</ng-container>
<div class="skeleton-action"></div>
</div>
}
} @else {
@if (purchases().length > 0) {
@for (purchase of purchases(); track purchase.id; let last = $last) {
<app-purchase-list-item [purchase]="purchase"></app-purchase-list-item>
@if (!last) {
<div class="purchase-divider"></div>
}
}
} @else {
<div class="empty-state">Aún no hay compras realizadas</div>
}
<ng-template #contentTpl>
<ng-container *ngIf="purchases().length > 0; else emptyTpl">
<ng-container *ngFor="let purchase of purchases(); let last = last">
<app-purchase-list-item
[purchase]="purchase">
</app-purchase-list-item>
<div class="purchase-divider" *ngIf="!last"></div>
</ng-container>
</ng-container>
</ng-template>
<ng-template #emptyTpl>
<div class="empty-state">
Aún no hay compras realizadas
</div>
</ng-template>
</div>
@if (pagination(); as paginationData) {
@if (paginationData.last_page > 1) {
<app-paginator
class="purchase-list__paginator"
[page]="paginationData.current_page"
[totalPages]="paginationData.last_page"
[disabled]="isLoading()"
(pageChange)="onPageChange($event)"
></app-paginator>
}
}
}
</div>

View File

@@ -4,9 +4,14 @@
width: 100%;
}
.purchase-list__paginator {
align-self: center;
margin-top: 1.5rem;
}
.purchase-divider {
height: 1px;
background-color: #DDDDDD;
background-color: #dddddd;
width: 100%;
}
@@ -39,7 +44,7 @@
.skeleton-id {
width: 100px;
height: 1rem;
background-color: #EEEEEE;
background-color: #eeeeee;
border-radius: 4px;
animation: pulse 1.5s infinite ease-in-out;
}
@@ -47,7 +52,7 @@
.skeleton-date {
width: 150px;
height: 0.8rem;
background-color: #EEEEEE;
background-color: #eeeeee;
border-radius: 4px;
animation: pulse 1.5s infinite ease-in-out;
}
@@ -55,19 +60,19 @@
.skeleton-action {
width: 24px;
height: 24px;
background-color: #EEEEEE;
background-color: #eeeeee;
border-radius: 50%;
animation: pulse 1.5s infinite ease-in-out;
}
@keyframes pulse {
0% {
background-color: #EEEEEE;
background-color: #eeeeee;
}
50% {
background-color: #E0E0E0;
background-color: #e0e0e0;
}
100% {
background-color: #EEEEEE;
background-color: #eeeeee;
}
}

View File

@@ -1,19 +1,24 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { PurchaseListItem } from '../purchase-list-item/purchase-list-item';
import { CheckoutService, PurchaseSummaryResponse } from '../../../../../../core/services/checkout.service';
import {
CheckoutService,
PurchaseSummaryResponse,
} from '../../../../../../core/services/checkout.service';
import { ToastService } from '../../../../../../core/services/toast.service';
import { TenantService } from '../../../../../../core/services/tenant.service';
import { ApiPaginationMeta } from '../../../../../../core/services/api-paginated-response.interface';
import { PaginatorComponent } from '../../../../../../shared/components/paginator/paginator.component';
type PurchaseListViewModel = {
id: number;
date: string;
statusMessage: string | null;
};
@Component({
selector: 'app-purchase-list',
standalone: true,
imports: [CommonModule, PurchaseListItem],
imports: [PurchaseListItem, PaginatorComponent],
templateUrl: './purchase-list.html',
styleUrl: './purchase-list.scss',
})
@@ -22,21 +27,28 @@ export class PurchaseList implements OnInit {
private readonly toastService = inject(ToastService);
private readonly tenantService = inject(TenantService);
purchases = signal<PurchaseListViewModel[]>([]);
isLoading = signal<boolean>(true);
protected readonly purchases = signal<PurchaseListViewModel[]>([]);
protected readonly pagination = signal<ApiPaginationMeta | null>(null);
protected readonly isLoading = signal(true);
async ngOnInit(): Promise<void> {
await this.loadPurchases();
}
protected async onPageChange(page: number): Promise<void> {
await this.loadPurchases(page);
}
private async loadPurchases(page = 1): Promise<void> {
this.isLoading.set(true);
try {
const tenantCode = this.tenantService.tenant()?.codigo || '';
const response = await this.checkoutService
.withCustomLoading()
.getPurchases(tenantCode, 'paid');
const mappedPurchases = response.data.map((purchase: PurchaseSummaryResponse) => ({
id: purchase.id,
date: this.formatDate(purchase.created_at),
}));
this.purchases.set(mappedPurchases);
.getPurchases(this.tenantCode(), 'paid,in_review', { page });
this.purchases.set(this.mapPurchases(response.data));
this.pagination.set(response.meta);
} catch (error) {
this.toastService.danger('Hubo un error al cargar las compras');
} finally {
@@ -44,6 +56,18 @@ export class PurchaseList implements OnInit {
}
}
private tenantCode(): string {
return this.tenantService.tenant()?.codigo || '';
}
private mapPurchases(purchases: PurchaseSummaryResponse[]): PurchaseListViewModel[] {
return purchases.map((purchase) => ({
id: purchase.id,
date: this.formatDate(purchase.created_at),
statusMessage: purchase.status === 'in_review' ? 'Esperando confirmación' : null,
}));
}
private formatDate(value: string | null): string {
if (!value) {
return '-';

View File

@@ -15,6 +15,27 @@
@if (isLoading()) {
<p class="purchase-loading">Cargando detalle de compra...</p>
} @else if (purchase(); as purchase) {
@if (purchase.isInReview) {
<section class="purchase-review" aria-labelledby="purchase-review-title">
<h3 id="purchase-review-title" class="purchase-review__title">ESPERANDO CONFIRMACIÓN</h3>
<p class="purchase-review__message">
Tu compra aún no ha sido confirmada.<br />
Si no te contactaste con nosotros, podés hacerlo a través del siguiente WhatsApp
</p>
@if (whatsappUrl()) {
<app-button
type="button"
variant="primary"
hostClass="purchase-review__button"
(click)="openWhatsApp()"
>
WhatsApp
</app-button>
}
</section>
}
<div
class="d-flex justify-content-between align-items-center mb-4 pb-3"
style="border-bottom: 1px solid #dddddd"

View File

@@ -4,6 +4,8 @@
}
.account-page {
--menu-content-prefix-gap: 0;
display: block;
width: 100%;
max-width: 600px;
@@ -50,6 +52,32 @@
font-size: 17px;
}
.purchase-review {
padding-bottom: 2rem;
margin-bottom: 2rem;
border-bottom: 1px solid #dddddd;
}
.purchase-review__title {
margin: 0 0 0.5rem;
color: var(--color-primary, var(--tenant-primary, #5b75ff));
font-size: 14px;
font-weight: 700;
line-height: 1.25;
}
.purchase-review__message {
margin: 0 0 1.5rem;
color: #777777;
font-size: 12px;
font-weight: 400;
line-height: 1.4;
}
.purchase-review__button {
width: 185px;
}
.purchase-loading,
.purchase-empty {
color: #666666;

View File

@@ -1,5 +1,4 @@
import { CommonModule } from '@angular/common';
import { Component, OnInit, inject, signal } from '@angular/core';
import { Component, OnInit, computed, inject, signal } from '@angular/core';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import {
@@ -8,6 +7,7 @@ import {
} from '../../../../../../core/services/checkout.service';
import { TenantService } from '../../../../../../core/services/tenant.service';
import { ToastService } from '../../../../../../core/services/toast.service';
import { ButtonComponent } from '../../../../../../shared/components/button/button.component';
import { MenuContentSectionComponent } from '../../../../components/menu-content-section/menu-content-section.component';
import { PurchaseItem, PurchaseItemViewModel } from '../../components/purchase-item/purchase-item';
@@ -16,12 +16,13 @@ type PurchaseDetailViewModel = {
date: string;
total: string;
items: PurchaseItemViewModel[];
isInReview: boolean;
};
@Component({
selector: 'app-purchase-detail-page',
standalone: true,
imports: [CommonModule, RouterLink, PurchaseItem, MenuContentSectionComponent],
imports: [RouterLink, PurchaseItem, MenuContentSectionComponent, ButtonComponent],
templateUrl: './purchase-detail-page.html',
styleUrl: './purchase-detail-page.scss',
})
@@ -34,6 +35,12 @@ export class PurchaseDetailPage implements OnInit {
readonly isLoading = signal(true);
readonly purchase = signal<PurchaseDetailViewModel | null>(null);
protected readonly whatsappUrl = computed(
() =>
this.tenantService
.tenant()
?.social_media?.find((socialMedia) => socialMedia.code === 'whatsapp')?.url ?? null,
);
async ngOnInit(): Promise<void> {
const purchaseId = this.route.snapshot.paramMap.get('id');
@@ -54,6 +61,7 @@ export class PurchaseDetailPage implements OnInit {
date: this.formatDate(response.created_at),
total: response.total,
items: this.mapItems(response),
isInReview: response.status === 'in_review',
});
} catch (error) {
console.error('Failed to fetch purchase detail:', error);
@@ -64,6 +72,14 @@ export class PurchaseDetailPage implements OnInit {
}
}
protected openWhatsApp(): void {
const url = this.whatsappUrl();
if (url) {
window.open(url, '_blank', 'noopener,noreferrer');
}
}
private formatDate(value: string | null): string {
if (!value) {
return '-';
@@ -87,7 +103,7 @@ export class PurchaseDetailPage implements OnInit {
id: item.id,
title: item.item_details.nombre || 'Producto sin nombre',
description: item.item_details.descripcion,
imageUrl: item.item_details.imagen,
imageUrl: item.item_details.imagen ?? null,
quantity: item.quantity,
attributes: item.item_details.attributes.map((attribute) => ({
label: attribute.name,

View File

@@ -15,6 +15,8 @@ export interface TicketResponse {
starts_at: string | null;
expires_at: string | null;
used_at: string | null;
status?: 'active' | 'expired' | 'used' | 'disabled' | 'cancelled' | 'refunded';
status_label?: string;
is_valid: boolean;
is_expired: boolean;
is_used: boolean;
@@ -26,9 +28,7 @@ export class TicketService extends BaseApiService {
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 ?? []);
}

View File

@@ -63,7 +63,11 @@ export class TicketsPage implements OnInit {
}
protected isInactive(ticket: TicketResponse): boolean {
return ticket.is_expired || ticket.is_used;
return (
(ticket.status !== undefined && ticket.status !== 'active') ||
ticket.is_expired ||
ticket.is_used
);
}
protected formatDate(ticket: TicketResponse): string | null {

View File

@@ -7,6 +7,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { CartService } from '../../../../core/services/cart/cart.service';
import { CatalogService } from '../../../../core/services/catalog/catalog.service';
import { CatalogAvailabilityService } from '../../../../core/services/catalog/catalog-availability.service';
import { Tenant } from '../../../../core/services/tenant.interface';
import { TenantService } from '../../../../core/services/tenant.service';
import { ToastService } from '../../../../core/services/toast.service';
@@ -107,6 +108,16 @@ describe('CategoryItemsPageComponent', () => {
expect(Array.isArray(productList.items())).toBe(false);
});
it('reloads the current category page when catalog availability changes', () => {
const fixture = TestBed.createComponent(CategoryItemsPageComponent);
fixture.detectChanges();
TestBed.inject(CatalogAvailabilityService).notifyAvailabilityChanged();
expect(getCategoryItems).toHaveBeenCalledTimes(2);
expect(getCategoryItems).toHaveBeenLastCalledWith(7, { page: 1 });
});
it('does not request the API when the category id is invalid', () => {
TestBed.overrideProvider(ActivatedRoute, {
useValue: {

View File

@@ -16,18 +16,23 @@ import {
finalize,
map,
of,
startWith,
switchMap,
tap,
} from 'rxjs';
import { AuthService } from '../../../../core/services/auth/auth.service';
import { CartService } from '../../../../core/services/cart/cart.service';
import { CheckoutService } from '../../../../core/services/checkout.service';
import {
CheckoutService,
isExpiredStockReservationResponse,
} from '../../../../core/services/checkout.service';
import {
CatalogGroupLayout,
CategoryItemsResponse,
} from '../../../../core/services/catalog/catalog.interface';
import { CatalogService } from '../../../../core/services/catalog/catalog.service';
import { CatalogAvailabilityService } from '../../../../core/services/catalog/catalog-availability.service';
import { TenantService } from '../../../../core/services/tenant.service';
import { ToastService } from '../../../../core/services/toast.service';
import {
@@ -50,6 +55,7 @@ interface CategoryRouteState {
})
export class CategoryItemsPageComponent {
private readonly cartService = inject(CartService);
private readonly catalogAvailabilityService = inject(CatalogAvailabilityService);
private readonly catalogService = inject(CatalogService);
private readonly injector = inject(Injector);
private readonly destroyRef = inject(DestroyRef);
@@ -65,22 +71,29 @@ export class CategoryItemsPageComponent {
protected readonly paginatedLayout: CatalogGroupLayout = 'paginated';
constructor() {
combineLatest([this.route.paramMap, this.route.queryParamMap])
.pipe(
map(
([params, queryParams]): CategoryRouteState => ({
categoryId: this.parsePositiveInteger(params.get('id')),
page: this.parsePositiveInteger(queryParams.get('page'), 1),
}),
),
distinctUntilChanged(
(previous, current) =>
previous.categoryId === current.categoryId && previous.page === current.page,
),
tap(() => {
this.results.set(null);
this.error.set(null);
const routeState$ = combineLatest([this.route.paramMap, this.route.queryParamMap]).pipe(
map(
([params, queryParams]): CategoryRouteState => ({
categoryId: this.parsePositiveInteger(params.get('id')),
page: this.parsePositiveInteger(queryParams.get('page'), 1),
}),
),
distinctUntilChanged(
(previous, current) =>
previous.categoryId === current.categoryId && previous.page === current.page,
),
tap(() => {
this.results.set(null);
this.error.set(null);
}),
);
combineLatest([
routeState$,
this.catalogAvailabilityService.availabilityChanged$.pipe(startWith(undefined)),
])
.pipe(
map(([routeState]) => routeState),
switchMap(({ categoryId, page }) => {
if (categoryId === 0) {
this.error.set('La categoría solicitada no es válida.');
@@ -157,10 +170,20 @@ export class CategoryItemsPageComponent {
],
},
);
await this.router.navigate(['/checkout'], { queryParams: { purchase: purchase.id } });
await this.router.navigate(['/checkout', purchase.id]);
} catch (error) {
console.error('Failed to create direct purchase:', error);
this.toastService.danger('No se pudo iniciar la compra directa.');
const message =
error instanceof HttpErrorResponse && typeof error.error?.message === 'string'
? error.error.message
: 'No se pudo iniciar la compra directa.';
this.toastService.danger(message);
if (error instanceof HttpErrorResponse && isExpiredStockReservationResponse(error.error)) {
this.cartService.loadCart(true).subscribe({
error: (refreshError) => console.error('Error refreshing expired cart', refreshError),
});
}
} finally {
this.creatingDirectPurchase.set(false);
}

View File

@@ -5,13 +5,12 @@
</div>
} @else {
<div class="checkout-page">
<div
class="checkout-page__stepper-col"
[class.checkout-page__stepper-col--editing]="isEditingItems()"
[attr.aria-hidden]="isEditingItems()"
[attr.inert]="isEditingItems() ? '' : null"
>
<app-stepper #stepper [initialStepIndex]="checkoutStepIndex()">
<div class="checkout-page__stepper-col">
<app-stepper
#stepper
[initialStepIndex]="checkoutStepIndex()"
[disabled]="hasSubmittedTransfer()"
>
<app-step label="Datos" [isValid]="isStep1Valid()">
<app-checkout-data-step
[form]="form"
@@ -24,6 +23,7 @@
<app-checkout-payment-step
[paymentMethods]="paymentMethods"
[selectedPaymentMethod]="selectedPaymentMethod()"
[paymentMethodDisabled]="hasSubmittedTransfer()"
[copiedTransferField]="copiedTransferField()"
[transferAccount]="transferAccount()"
[transferDni]="transferDni()"
@@ -34,6 +34,7 @@
[qrPaymentAmount]="cartTotal()"
[whatsappUrl]="whatsappUrl()"
[transferValidationStatus]="transferValidationStatus()"
[transferVerificationErrorTitle]="transferVerificationErrorTitle()"
(paymentMethodChange)="selectPaymentMethod($event)"
(copyTransferValue)="copyTransferValue($event.field, $event.value)"
(cancelStep)="onCancel()"
@@ -45,30 +46,50 @@
</app-stepper>
</div>
@if (isEditingItems()) {
<div class="checkout-page__editing-notice" role="status">
<p>Terminá de modificar las cantidades para continuar con el pago.</p>
</div>
}
<div class="checkout-page__cart-col">
@if (usesEventCheckoutSummary()) {
@if (checkoutRemainingTime(); as remainingTime) {
<div
class="checkout-page__summary-countdown d-flex align-items-baseline justify-content-center gap-3"
role="timer"
aria-label="Tiempo restante de compra"
>
<span class="checkout-page__summary-countdown-label">Tiempo restante de compra:</span>
<span class="checkout-page__summary-countdown-value">{{ remainingTime }}</span>
</div>
}
@if (checkoutEvent(); as event) {
@if (event.image) {
<div class="checkout-page__event-media">
<img [src]="event.image" [alt]="event.title" />
</div>
}
}
} @else if (checkoutRemainingTime(); as remainingTime) {
<div
class="checkout-page__mobile-countdown d-flex d-md-none align-items-baseline justify-content-center gap-3"
role="timer"
aria-label="Tiempo restante de compra"
>
<span class="checkout-page__mobile-countdown-label">Tiempo restante de compra:</span>
<span class="checkout-page__mobile-countdown-value">{{ remainingTime }}</span>
</div>
}
<app-cart
title="COMPRA"
[items]="mappedCartItems()"
[subtotal]="cartSubtotal()"
[discount]="cartDiscount()"
[total]="cartTotal()"
[readonly]="!cartEditingEnabled()"
[allowEditing]="
createdPurchase()?.status === 'created' || createdPurchase()?.status === 'pending_payment'
"
[allowRemove]="false"
[persistQuantityChanges]="false"
[editing]="isEditingItems()"
[editingDisabled]="isUpdatingItem() || isPreparingItemEdit()"
[readonly]="true"
[allowModify]="true"
[showModifyWhenReadonly]="true"
[modifyAsAction]="true"
[editingDisabled]="isPurchaseModificationDisabled()"
backgroundColor="transparent"
(editingChange)="onEditingItemsChange($event)"
(itemQuantityChange)="onPurchaseItemQuantityChange($event)"
(modify)="onModifyPurchase()"
/>
</div>
</div>

View File

@@ -11,8 +11,7 @@
order: 1;
}
.checkout-page__stepper-col,
.checkout-page__editing-notice {
.checkout-page__stepper-col {
order: 2;
}
}
@@ -21,27 +20,6 @@
min-width: 0;
border-radius: 4px;
min-height: 420px;
&--editing {
display: none;
}
}
&__editing-notice {
display: grid;
min-height: 420px;
place-items: center;
padding: 2rem;
border-radius: 4px;
background: #f5f5f5;
color: #666666;
text-align: center;
p {
max-width: 360px;
margin: 0;
font-size: 14px;
}
}
&__cart-col {
@@ -50,6 +28,56 @@
display: flex;
flex-direction: column;
}
&__mobile-countdown {
flex: 0 0 auto;
margin-bottom: 1.5rem;
color: var(--tenant-primary);
font-weight: 700;
white-space: nowrap;
}
&__mobile-countdown-label {
font-size: 13px;
}
&__mobile-countdown-value {
font-size: 20px;
line-height: 1;
font-variant-numeric: tabular-nums;
}
&__summary-countdown {
flex: 0 0 auto;
margin-bottom: 1.5rem;
color: var(--tenant-primary);
font-weight: 700;
white-space: nowrap;
}
&__summary-countdown-label {
font-size: 13px;
}
&__summary-countdown-value {
font-size: 20px;
line-height: 1;
font-variant-numeric: tabular-nums;
}
&__event-media {
flex: 0 0 auto;
width: 100%;
margin-bottom: 1rem;
overflow: hidden;
border-radius: 4px;
img {
display: block;
width: 100%;
height: auto;
}
}
}
.checkout-page__loading {

View File

@@ -1,3 +1,4 @@
import { HttpErrorResponse } from '@angular/common/http';
import { signal } from '@angular/core';
import { getTestBed, TestBed } from '@angular/core/testing';
import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
@@ -6,33 +7,43 @@ import { of } from 'rxjs';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { AuthService } from '../../../../core/services/auth/auth.service';
import { CartService } from '../../../../core/services/cart/cart.service';
import { CheckoutService } from '../../../../core/services/checkout.service';
import { CatalogService } from '../../../../core/services/catalog/catalog.service';
import { GlobalLoadingService } from '../../../../core/services/global-loading/global-loading.service';
import { TenantService } from '../../../../core/services/tenant.service';
import { ToastService } from '../../../../core/services/toast.service';
import { CartService } from '../../../../core/services/cart/cart.service';
import { CartEditingPolicy } from '../../../../core/services/tenant.interface';
import { CheckoutCountdownService } from '../../../../core/services/checkout-countdown.service';
import { CheckoutPageComponent } from './checkout-page.component';
describe('CheckoutPageComponent payment validation', () => {
let checkoutServiceStub: {
startCheckout: ReturnType<typeof vi.fn>;
updateCustomerData: ReturnType<typeof vi.fn>;
updateItemQuantity: ReturnType<typeof vi.fn>;
prepareItemEditing: ReturnType<typeof vi.fn>;
cancelPurchase: ReturnType<typeof vi.fn>;
generatePaymentIntent: ReturnType<typeof vi.fn>;
submitPurchaseForReview: ReturnType<typeof vi.fn>;
getPurchase: ReturnType<typeof vi.fn>;
withCustomLoading: ReturnType<typeof vi.fn>;
};
let cartServiceStub: {
cart: ReturnType<typeof signal>;
isUpdating: ReturnType<typeof signal<boolean>>;
loadCart: ReturnType<typeof vi.fn>;
clearCart: ReturnType<typeof vi.fn>;
};
let routerStub: { navigate: ReturnType<typeof vi.fn> };
let routeQueryParamMap: ReturnType<typeof convertToParamMap>;
let toastServiceStub: { danger: ReturnType<typeof vi.fn> };
let globalLoadingServiceStub: {
start: ReturnType<typeof vi.fn>;
stop: ReturnType<typeof vi.fn>;
};
let cartServiceStub: { loadCart: ReturnType<typeof vi.fn> };
let routeParamMap: ReturnType<typeof convertToParamMap>;
let authUserState: ReturnType<typeof signal>;
let tenantState: ReturnType<typeof signal<{ codigo: string; cart_editing_enabled?: boolean }>>;
let tenantState: ReturnType<
typeof signal<{
codigo: string;
storefront_website_type_code?: string | null;
checkout_summary_type?: 'default' | 'event';
checkout_editing_policy?: CartEditingPolicy;
}>
>;
beforeAll(() => {
try {
@@ -53,56 +64,50 @@ describe('CheckoutPageComponent payment validation', () => {
total: '0.00',
}),
updateCustomerData: vi.fn(),
updateItemQuantity: vi.fn(),
prepareItemEditing: vi.fn(),
cancelPurchase: vi.fn().mockResolvedValue({ status: 'cancelled' }),
generatePaymentIntent: vi.fn().mockResolvedValue({
qr_data: { qr_code: 'qr-value' },
}),
submitPurchaseForReview: vi.fn().mockResolvedValue({ status: 'in_review' }),
getPurchase: vi.fn().mockResolvedValue({ status: 'pending_payment' }),
withCustomLoading: vi.fn(),
};
checkoutServiceStub.withCustomLoading.mockReturnValue(checkoutServiceStub);
cartServiceStub = {
cart: signal({
id: 10,
tenant_codigo: 'tenant-test',
status: 'active',
items: [],
subtotal: '0.00',
}),
isUpdating: signal(false),
loadCart: vi.fn().mockReturnValue(of({})),
clearCart: vi.fn(),
};
cartServiceStub.clearCart.mockImplementation(() => {
cartServiceStub.cart.set({
id: null,
tenant_codigo: 'tenant-test',
status: 'active',
items: [],
subtotal: '0.00',
});
});
routerStub = { navigate: vi.fn() };
routeQueryParamMap = convertToParamMap({});
toastServiceStub = { danger: vi.fn() };
globalLoadingServiceStub = { start: vi.fn(), stop: vi.fn() };
cartServiceStub = {
loadCart: vi.fn().mockReturnValue(of({ id: 30, items: [], subtotal: '0.00' })),
};
routeParamMap = convertToParamMap({});
authUserState = signal(null);
tenantState = signal({ codigo: 'tenant-test' });
tenantState = signal({
codigo: 'tenant-test',
checkout_editing_policy: {
code: 'full',
allow_modify: true,
allow_delete: true,
allow_update_quantity: true,
allow_update_variant: true,
},
});
await TestBed.configureTestingModule({
imports: [CheckoutPageComponent],
providers: [
{ provide: CheckoutService, useValue: checkoutServiceStub },
{ provide: CatalogService, useValue: { getCatalogItem: vi.fn() } },
{ provide: CartService, useValue: cartServiceStub },
{ provide: TenantService, useValue: { tenant: tenantState } },
{ provide: AuthService, useValue: { user: authUserState } },
{ provide: GlobalLoadingService, useValue: globalLoadingServiceStub },
{ provide: ToastService, useValue: toastServiceStub },
{ provide: CartService, useValue: cartServiceStub },
{
provide: ActivatedRoute,
useValue: {
snapshot: {
get queryParamMap() {
return routeQueryParamMap;
get paramMap() {
return routeParamMap;
},
},
},
@@ -127,6 +132,93 @@ describe('CheckoutPageComponent payment validation', () => {
return { fixture, component: fixture.componentInstance as any };
}
it('does not expose an active countdown until the purchase timing is loaded', async () => {
const countdown = TestBed.inject(CheckoutCountdownService);
countdown.synchronize({
expires_at: null,
expires_in_seconds: 600,
server_time: new Date().toISOString(),
});
checkoutServiceStub.getPurchase.mockResolvedValue({
id: 25,
status: 'created',
expires_at: '2026-08-27T15:10:00.000Z',
expires_in_seconds: 600,
server_time: '2026-08-27T15:00:00.000Z',
items: [],
subtotal: '0.00',
total: '0.00',
});
routeParamMap = convertToParamMap({ id: 25 });
const { component } = createComponent();
expect(component.checkoutRemainingTime()).toBeNull();
await Promise.resolve();
expect(countdown.remainingSeconds()).toBe(600);
expect(component.checkoutRemainingTime()).toBe('10:00');
});
it('checks the purchase when the countdown expires and redirects to the expired status', async () => {
const countdown = TestBed.inject(CheckoutCountdownService);
checkoutServiceStub.getPurchase.mockResolvedValue({ status: 'expired' });
const { fixture, component } = createComponent();
component.createdPurchase.set({
id: 25,
status: 'created',
expires_at: new Date(Date.now() + 1_000).toISOString(),
expires_in_seconds: 1,
server_time: new Date().toISOString(),
items: [],
subtotal: '0.00',
total: '0.00',
});
countdown.synchronize(component.createdPurchase());
fixture.detectChanges();
await vi.advanceTimersByTimeAsync(1_000);
fixture.detectChanges();
await Promise.resolve();
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledOnce();
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledWith('tenant-test', 25);
expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25], {
queryParams: { status: 'expired' },
});
});
it('checks the purchase only once when the expired countdown remains at zero', async () => {
const countdown = TestBed.inject(CheckoutCountdownService);
checkoutServiceStub.getPurchase.mockResolvedValue({
status: 'pending_payment',
expires_at: new Date(Date.now() - 1_000).toISOString(),
expires_in_seconds: 0,
server_time: new Date().toISOString(),
});
const { fixture, component } = createComponent();
component.createdPurchase.set({
id: 25,
status: 'pending_payment',
expires_at: new Date(Date.now() - 1_000).toISOString(),
expires_in_seconds: 0,
server_time: new Date().toISOString(),
items: [],
subtotal: '0.00',
total: '0.00',
});
countdown.synchronize(component.createdPurchase());
fixture.detectChanges();
await Promise.resolve();
fixture.detectChanges();
await vi.advanceTimersByTimeAsync(5_000);
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledOnce();
expect(routerStub.navigate).not.toHaveBeenCalled();
});
it('polls QR after five seconds and navigates only when payment is paid', async () => {
checkoutServiceStub.getPurchase
.mockResolvedValueOnce({ status: 'pending_payment' })
@@ -142,7 +234,6 @@ describe('CheckoutPageComponent payment validation', () => {
await vi.advanceTimersByTimeAsync(5_000);
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(2);
expect(cartServiceStub.clearCart).not.toHaveBeenCalled();
expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25]);
expect(checkoutServiceStub.generatePaymentIntent).toHaveBeenCalledTimes(1);
});
@@ -194,19 +285,44 @@ describe('CheckoutPageComponent payment validation', () => {
expect(checkoutServiceStub.getPurchase).not.toHaveBeenCalled();
});
it('checks the purchase detail once when the transfer was made', async () => {
it('polls a transfer every three seconds for one minute', async () => {
checkoutServiceStub.getPurchase.mockResolvedValue({ status: 'pending_payment' });
const { component } = createComponent();
component.selectedPaymentMethod.set('transfer');
await component.onComplete();
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledWith('tenant-test', 25);
expect(component.transferValidationStatus()).toBe('error');
expect(cartServiceStub.clearCart).not.toHaveBeenCalled();
expect(routerStub.navigate).not.toHaveBeenCalled();
expect(component.transferValidationStatus()).toBe('checking');
expect(checkoutServiceStub.submitPurchaseForReview).toHaveBeenCalledWith('tenant-test', 25);
expect(checkoutServiceStub.getPurchase).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(30_000);
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(57_000);
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(19);
expect(component.transferValidationStatus()).toBe('checking');
await vi.advanceTimersByTimeAsync(3_000);
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(20);
expect(checkoutServiceStub.getPurchase).toHaveBeenLastCalledWith('tenant-test', 25);
expect(component.transferValidationStatus()).toBe('error');
expect(routerStub.navigate).not.toHaveBeenCalled();
});
it('prevents modifying the purchase after Ya transferí is clicked', async () => {
const { component } = createComponent();
component.selectedPaymentMethod.set('transfer');
await component.onComplete();
expect(component.hasSubmittedTransfer()).toBe(true);
expect(component.isPurchaseModificationDisabled()).toBe(true);
await component.selectPaymentMethod('qr');
await component.onModifyPurchase();
expect(component.selectedPaymentMethod()).toBe('transfer');
expect(checkoutServiceStub.generatePaymentIntent).not.toHaveBeenCalled();
expect(checkoutServiceStub.cancelPurchase).not.toHaveBeenCalled();
expect(globalLoadingServiceStub.start).not.toHaveBeenCalled();
expect(routerStub.navigate).not.toHaveBeenCalled();
});
it('navigates after a transfer is confirmed as paid', async () => {
@@ -215,23 +331,77 @@ describe('CheckoutPageComponent payment validation', () => {
component.selectedPaymentMethod.set('transfer');
await component.onComplete();
await vi.advanceTimersByTimeAsync(3_000);
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledWith('tenant-test', 25);
expect(cartServiceStub.clearCart).not.toHaveBeenCalled();
expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25]);
});
it('shows a retryable state when transfer validation fails', async () => {
it('keeps polling after transfer validation requests fail', async () => {
vi.spyOn(console, 'error').mockImplementation(() => undefined);
checkoutServiceStub.getPurchase.mockRejectedValue(new Error('network error'));
const { component } = createComponent();
component.selectedPaymentMethod.set('transfer');
await component.onComplete();
await vi.advanceTimersByTimeAsync(60_000);
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(20);
expect(component.transferValidationStatus()).toBe('error');
expect(routerStub.navigate).not.toHaveBeenCalled();
});
it('uses the primary candidate reason when transfer polling times out', async () => {
checkoutServiceStub.getPurchase.mockResolvedValue({
status: 'in_review',
payment_verification: {
status: 'candidate',
candidate_count: 1,
primary: {
reason: 'exact_amount_near_dni',
dni_distance: 1,
payment_amount: '300000.00',
purchase_amount: '300000.00',
amount_difference: '0.00',
confidence: 'medium',
detected_at: '2026-08-27T18:00:00-03:00',
},
reasons: ['exact_amount_near_dni'],
},
});
const { component } = createComponent();
component.selectedPaymentMethod.set('transfer');
await component.onComplete();
await vi.advanceTimersByTimeAsync(60_000);
expect(component.transferValidationStatus()).toBe('error');
expect(cartServiceStub.clearCart).not.toHaveBeenCalled();
expect(routerStub.navigate).not.toHaveBeenCalled();
expect(component.transferVerificationErrorTitle()).toBe(
'El DNI no corresponde con el de la transferencia',
);
});
it('does not poll when submitting a transfer for review fails', async () => {
vi.spyOn(console, 'error').mockImplementation(() => undefined);
checkoutServiceStub.submitPurchaseForReview.mockRejectedValue(new Error('network error'));
const { component } = createComponent();
component.selectedPaymentMethod.set('transfer');
await component.onComplete();
await vi.advanceTimersByTimeAsync(12_000);
expect(checkoutServiceStub.submitPurchaseForReview).toHaveBeenCalledWith('tenant-test', 25);
expect(checkoutServiceStub.getPurchase).not.toHaveBeenCalled();
expect(component.transferValidationStatus()).toBe('error');
});
it('cancels transfer polling when the component is destroyed', async () => {
const checkout = createComponent();
checkout.component.selectedPaymentMethod.set('transfer');
await checkout.component.onComplete();
checkout.fixture.destroy();
await vi.advanceTimersByTimeAsync(12_000);
expect(checkoutServiceStub.getPurchase).not.toHaveBeenCalled();
});
it('loads purchase items but prefills customer data from the user', async () => {
@@ -265,7 +435,7 @@ describe('CheckoutPageComponent payment validation', () => {
subtotal: '2501.00',
total: '2501.00',
};
routeQueryParamMap = convertToParamMap({ purchase: 25 });
routeParamMap = convertToParamMap({ id: 25 });
checkoutServiceStub.getPurchase.mockResolvedValue(purchase);
authUserState.set({
id: 7,
@@ -300,9 +470,52 @@ describe('CheckoutPageComponent payment validation', () => {
});
});
it('uses the event image and removes item images for multi-event checkout', () => {
tenantState.set({
...tenantState(),
checkout_summary_type: 'event',
});
const { component } = createComponent();
component.createdPurchase.set({
id: 25,
status: 'created',
event: {
id: 12,
title: 'Festival de prueba',
image: 'https://example.com/event.jpg',
},
items: [
{
id: 91,
quantity: 1,
unit_price: '10000.00',
line_total: '10000.00',
source_catalog_item_id: 8,
source_variant_id: null,
item_details: {
nombre: 'Entrada general',
descripcion: null,
slug: 'entrada-general',
imagen: 'https://example.com/product.jpg',
attributes: [],
},
},
],
subtotal: '10000.00',
total: '10000.00',
});
expect(component.checkoutEvent()).toEqual({
id: 12,
title: 'Festival de prueba',
image: 'https://example.com/event.jpg',
});
expect(component.mappedCartItems()[0].imageUrl).toBeNull();
});
it('keeps the checkout hidden while the purchase is loading', async () => {
let resolvePurchase!: (purchase: any) => void;
routeQueryParamMap = convertToParamMap({ purchase: 25 });
routeParamMap = convertToParamMap({ id: 25 });
checkoutServiceStub.getPurchase.mockReturnValue(
new Promise((resolve) => {
resolvePurchase = resolve;
@@ -323,12 +536,13 @@ describe('CheckoutPageComponent payment validation', () => {
});
await Promise.resolve();
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledWith('tenant-test', 25);
expect(component.isLoadingPurchase()).toBe(false);
expect(component.checkoutStepIndex()).toBe(0);
});
it('opens a pending purchase on the payment step and restores its payment method', async () => {
routeQueryParamMap = convertToParamMap({ purchase: 25 });
routeParamMap = convertToParamMap({ id: 25 });
checkoutServiceStub.getPurchase.mockResolvedValue({
id: 25,
status: 'pending_payment',
@@ -348,7 +562,7 @@ describe('CheckoutPageComponent payment validation', () => {
});
it('generates a new QR when reopening a pending QR purchase', async () => {
routeQueryParamMap = convertToParamMap({ purchase: 25 });
routeParamMap = convertToParamMap({ id: 25 });
checkoutServiceStub.getPurchase.mockResolvedValue({
id: 25,
status: 'pending_payment',
@@ -372,7 +586,7 @@ describe('CheckoutPageComponent payment validation', () => {
it.each(['paid', 'cancelled', 'rejected', 'expired'])(
'redirects a %s purchase to its status page',
async (status) => {
routeQueryParamMap = convertToParamMap({ purchase: 25 });
routeParamMap = convertToParamMap({ id: 25 });
checkoutServiceStub.getPurchase.mockResolvedValue({
id: 25,
status,
@@ -390,7 +604,7 @@ describe('CheckoutPageComponent payment validation', () => {
);
it('redirects a submitted pending payment purchase to its status page', async () => {
routeQueryParamMap = convertToParamMap({ purchase: 25 });
routeParamMap = convertToParamMap({ id: 25 });
checkoutServiceStub.getPurchase.mockResolvedValue({
id: 25,
status: 'pending_payment',
@@ -408,73 +622,75 @@ describe('CheckoutPageComponent payment validation', () => {
expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25]);
});
it('updates a purchase item while editing and refreshes checkout totals', async () => {
const updatedPurchase = {
id: 25,
items: [],
subtotal: '300.00',
total: '300.00',
};
checkoutServiceStub.updateItemQuantity.mockResolvedValue(updatedPurchase);
const { component } = createComponent();
component.isEditingItems.set(true);
await component.onPurchaseItemQuantityChange({
item: {
cartItemId: 91,
imageUrl: null,
product: 'Remera',
originalPrice: null,
discountedPrice: 100,
discountPercentage: null,
attributes: [],
quantity: 2,
},
quantity: 3,
it('shows the API error and redirects to status when cancellation finds an expired purchase', async () => {
const message = 'La compra venció. Iniciá una nueva compra.';
checkoutServiceStub.cancelPurchase.mockRejectedValue({
error: { code: 'purchase.expired', message },
});
const { component } = createComponent();
expect(checkoutServiceStub.updateItemQuantity).toHaveBeenCalledWith('tenant-test', 25, 91, 3);
expect(component.createdPurchase()).toBe(updatedPurchase);
expect(component.isUpdatingItem()).toBe(false);
await component.onModifyPurchase();
expect(toastServiceStub.danger).toHaveBeenCalledWith(message);
expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25], {
queryParams: { status: 'expired' },
});
expect(globalLoadingServiceStub.start).toHaveBeenCalledOnce();
expect(globalLoadingServiceStub.stop).toHaveBeenCalledOnce();
expect(component.isCancellingPurchase()).toBe(false);
});
it('keeps the payment step selected while editing and regenerates payment afterward', async () => {
const editablePurchase = {
it('cancels the current purchase and opens the active cart when Modificar is clicked', async () => {
let finishNavigation!: (navigated: boolean) => void;
routerStub.navigate.mockReturnValue(
new Promise<boolean>((resolve) => {
finishNavigation = resolve;
}),
);
const { component } = createComponent();
component.createdPurchase.set({
id: 25,
status: 'created',
items: [],
subtotal: '100.00',
total: '100.00',
};
checkoutServiceStub.prepareItemEditing.mockResolvedValue(editablePurchase);
const { component } = createComponent();
component.stepper = { currentStepIndex: signal(1) };
const selectPaymentMethod = vi
.spyOn(component, 'selectPaymentMethod')
.mockResolvedValue(undefined);
items: [
{
id: 91,
quantity: 2,
unit_price: '100.00',
line_total: '200.00',
source_catalog_item_id: 8,
source_variant_id: null,
item_details: {
nombre: 'Remera',
descripcion: null,
slug: 'remera',
imagen: null,
attributes: [],
},
},
],
subtotal: '200.00',
total: '200.00',
});
const modification = component.onModifyPurchase();
await Promise.resolve();
await Promise.resolve();
await component.onEditingItemsChange(true);
expect(checkoutServiceStub.cancelPurchase).toHaveBeenCalledWith('tenant-test', 25);
expect(cartServiceStub.loadCart).toHaveBeenCalledOnce();
expect(globalLoadingServiceStub.start).toHaveBeenCalledOnce();
expect(globalLoadingServiceStub.stop).not.toHaveBeenCalled();
expect(component.createdPurchaseId()).toBeNull();
expect(component.createdPurchase()).toBeNull();
expect(routerStub.navigate).toHaveBeenCalledWith(['/'], {
queryParams: { openCart: true },
});
expect(checkoutServiceStub.prepareItemEditing).toHaveBeenCalledWith('tenant-test', 25);
expect(component.isEditingItems()).toBe(true);
expect(component.createdPurchase()).toBe(editablePurchase);
finishNavigation(true);
await modification;
await component.onEditingItemsChange(false);
expect(globalLoadingServiceStub.stop).toHaveBeenCalledOnce();
expect(component.stepper.currentStepIndex()).toBe(1);
expect(selectPaymentMethod).toHaveBeenCalledWith('qr');
expect(component.isEditingItems()).toBe(false);
});
it('does not allow item editing when the tenant disables cart editing', async () => {
tenantState.set({ codigo: 'tenant-test', cart_editing_enabled: false });
const { component } = createComponent();
await component.onEditingItemsChange(true);
expect(component.cartEditingEnabled()).toBe(false);
expect(component.isEditingItems()).toBe(false);
expect(checkoutServiceStub.prepareItemEditing).not.toHaveBeenCalled();
await component.canDeactivate();
});
it('updates customer data on the existing purchase before payment', async () => {
@@ -511,14 +727,122 @@ describe('CheckoutPageComponent payment validation', () => {
expect(component.stepper.next).toHaveBeenCalledOnce();
});
it('cancels the pending purchase before allowing navigation away', async () => {
it('cancels the current purchase when navigating away from checkout', async () => {
const { component } = createComponent();
await expect(component.canDeactivate()).resolves.toBe(true);
expect(checkoutServiceStub.cancelPurchase).toHaveBeenCalledWith('tenant-test', 25);
expect(cartServiceStub.loadCart).toHaveBeenCalled();
expect(cartServiceStub.clearCart).not.toHaveBeenCalled();
expect(cartServiceStub.loadCart).toHaveBeenCalledOnce();
expect(component.createdPurchaseId()).toBeNull();
expect(globalLoadingServiceStub.start).toHaveBeenCalledOnce();
expect(globalLoadingServiceStub.stop).toHaveBeenCalledOnce();
});
it('prevents leaving checkout when the purchase cannot be cancelled', async () => {
checkoutServiceStub.cancelPurchase.mockRejectedValue({
error: { message: 'No se pudo cancelar la compra.' },
});
const { component } = createComponent();
await expect(component.canDeactivate()).resolves.toBe(false);
expect(toastServiceStub.danger).toHaveBeenCalledWith('No se pudo cancelar la compra.');
expect(component.createdPurchaseId()).toBe(25);
});
it('allows leaving checkout when cancellation finds an expired stock reservation', async () => {
const message = 'La reserva de stock venció. Usá el carrito activo para continuar.';
checkoutServiceStub.cancelPurchase.mockRejectedValue({
error: { code: 'stock_reservation.expired', message },
});
const { component } = createComponent();
await expect(component.canDeactivate()).resolves.toBe(true);
expect(toastServiceStub.danger).toHaveBeenCalledWith(message);
expect(cartServiceStub.loadCart).toHaveBeenCalledOnce();
expect(component.createdPurchaseId()).toBeNull();
expect(component.createdPurchase()).toBeNull();
expect(globalLoadingServiceStub.start).toHaveBeenCalledOnce();
expect(globalLoadingServiceStub.stop).toHaveBeenCalledOnce();
});
it('shows the API message and redirects to status when a checkout operation finds an expired purchase', async () => {
checkoutServiceStub.generatePaymentIntent.mockRejectedValue(
new HttpErrorResponse({
status: 422,
error: {
code: 'purchase.expired',
message: 'La compra venci\u00f3. Inici\u00e1 una nueva compra.',
},
}),
);
const { component } = createComponent();
await component.selectPaymentMethod('qr');
expect(toastServiceStub.danger).toHaveBeenCalledWith(
'La compra venci\u00f3. Inici\u00e1 una nueva compra.',
);
expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25], {
queryParams: { status: 'expired' },
});
expect(component.isGeneratingIntent()).toBe(false);
});
it('redirects to status when QR polling receives a purchase-expired response', async () => {
checkoutServiceStub.getPurchase.mockRejectedValue(
new HttpErrorResponse({
status: 422,
error: {
code: 'purchase.expired',
message: 'La compra venci\u00f3. Inici\u00e1 una nueva compra.',
},
}),
);
const { component } = createComponent();
await component.selectPaymentMethod('qr');
await vi.advanceTimersByTimeAsync(5_000);
expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25], {
queryParams: { status: 'expired' },
});
expect(toastServiceStub.danger).toHaveBeenCalledWith(
'La compra venci\u00f3. Inici\u00e1 una nueva compra.',
);
});
it('treats a generic customer-data error as expired when the local deadline passed', () => {
const { component } = createComponent();
component.createdPurchase.set({
id: 25,
status: 'created',
expires_at: new Date(Date.now() - 1_000).toISOString(),
items: [],
subtotal: '100.00',
total: '100.00',
});
const handled = component.handleCheckoutError(
new HttpErrorResponse({
status: 422,
error: {
errors: {
purchase: ['La compra ya no se puede modificar.'],
},
},
}),
'No se pudieron actualizar los datos de la compra.',
);
expect(handled).toBe(true);
expect(toastServiceStub.danger).toHaveBeenCalledWith(
'La compra venci\u00f3. Inici\u00e1 una nueva compra.',
);
expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25], {
queryParams: { status: 'expired' },
});
});
});

View File

@@ -2,12 +2,14 @@ import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
OnDestroy,
OnInit,
signal,
ViewChild,
} from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http';
import { FormBuilder, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { firstValueFrom, startWith } from 'rxjs';
@@ -15,17 +17,21 @@ import { firstValueFrom, startWith } from 'rxjs';
import { TenantService } from '../../../../core/services/tenant.service';
import {
CheckoutService,
PurchasePaymentCandidateReason,
PurchaseDetailItemResponse,
PurchaseDetailResponse,
PurchaseStatusResponse,
} from '../../../../core/services/checkout.service';
import { AuthService } from '../../../../core/services/auth/auth.service';
import { GlobalLoadingService } from '../../../../core/services/global-loading/global-loading.service';
import { ToastService } from '../../../../core/services/toast.service';
import { CartService } from '../../../../core/services/cart/cart.service';
import { BankAccount } from '../../../../core/services/tenant.interface';
import { CartComponent, CartItemMock } from '../../../../shared/components/cart/cart.component';
import { StepComponent } from '../../../../shared/components/stepper/step.component';
import { StepperComponent } from '../../../../shared/components/stepper/stepper.component';
import { CheckoutDataStepComponent } from './checkout-data-step.component';
import { CheckoutPaymentStepComponent } from './checkout-payment-step.component';
import { CheckoutCountdownService } from '../../../../core/services/checkout-countdown.service';
import {
CheckoutForm,
PaymentMethod,
@@ -36,6 +42,12 @@ import {
TransferValidationStatus,
} from './checkout-page.models';
interface ApiErrorResponse {
code?: string;
message?: string;
errors?: Record<string, string[]>;
}
@Component({
selector: 'app-checkout-page',
standalone: true,
@@ -56,16 +68,26 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
private readonly router = inject(Router);
private readonly tenantService = inject(TenantService);
private readonly checkoutService = inject(CheckoutService);
private readonly checkoutCountdownService = inject(CheckoutCountdownService);
private readonly authService = inject(AuthService);
private readonly globalLoadingService = inject(GlobalLoadingService);
private readonly toastService = inject(ToastService);
private readonly cartService = inject(CartService);
private readonly qrPollingIntervalMs = 5_000;
private readonly qrPollingMaxAttempts = 9;
private readonly qrPollingMaxAttempts = 120;
private readonly transferPollingIntervalMs = 3_000;
private readonly transferPollingMaxAttempts = 20;
private qrPollingTimeoutId: ReturnType<typeof setTimeout> | null = null;
private qrPollingAttempts = 0;
private qrPollingRunId = 0;
private transferPollingTimeoutId: ReturnType<typeof setTimeout> | null = null;
private transferPollingAttempts = 0;
private transferPollingRunId = 0;
private paymentMethodRequestId = 0;
private navigationStarted = false;
private cancelPurchasePromise: Promise<boolean> | null = null;
private expirationCheckPurchaseId: number | null = null;
@ViewChild(StepperComponent) stepper!: StepperComponent;
@@ -79,10 +101,27 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
protected readonly createdPurchase = signal<PurchaseDetailResponse | null>(null);
protected readonly isLoadingPurchase = signal(true);
protected readonly checkoutStepIndex = signal(0);
protected readonly cartEditingEnabled = computed(
() => this.tenantService.tenant()?.cart_editing_enabled ?? true,
);
protected readonly checkoutRemainingTime = computed(() => {
const purchase = this.createdPurchase();
if (
!purchase ||
(typeof purchase.expires_at !== 'string' && typeof purchase.expires_in_seconds !== 'number')
) {
return null;
}
const remainingSeconds = this.checkoutCountdownService.remainingSeconds();
if (remainingSeconds === null) {
return null;
}
const minutes = Math.floor(remainingSeconds / 60);
const seconds = remainingSeconds % 60;
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
});
protected readonly cartSubtotal = computed(() => {
const purchase = this.createdPurchase();
return purchase ? parseFloat(purchase.subtotal) : 0;
@@ -99,6 +138,12 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
const purchase = this.createdPurchase();
return purchase ? purchase.items.map((item) => this.mapPurchaseItemToMock(item)) : [];
});
protected readonly usesEventCheckoutSummary = computed(
() => this.tenantService.tenant()?.checkout_summary_type === 'event',
);
protected readonly checkoutEvent = computed(() =>
this.usesEventCheckoutSummary() ? (this.createdPurchase()?.event ?? null) : null,
);
protected readonly isStep1Valid = signal(this.form.valid);
protected readonly paymentMethods: ReadonlyArray<PaymentMethodOption> = [
@@ -111,9 +156,11 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
protected readonly transferDni = signal<string>('');
protected readonly isUpdatingPurchase = signal(false);
protected readonly isEditingItems = signal(false);
protected readonly isUpdatingItem = signal(false);
protected readonly isPreparingItemEdit = signal(false);
protected readonly isCancellingPurchase = signal(false);
protected readonly hasSubmittedTransfer = signal(false);
protected readonly isPurchaseModificationDisabled = computed(
() => this.isCancellingPurchase() || this.hasSubmittedTransfer(),
);
protected readonly createdPurchaseId = signal<number | null>(null);
protected readonly isGeneratingIntent = signal(false);
protected readonly isPaymentLoading = computed(() => this.isGeneratingIntent());
@@ -121,6 +168,14 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
protected readonly qrPaymentStatus = signal<QrPaymentStatus>('idle');
protected readonly isCheckingQrPayment = signal(false);
protected readonly transferValidationStatus = signal<TransferValidationStatus>('idle');
private readonly transferPrimaryCandidateReason = signal<PurchasePaymentCandidateReason | null>(
null,
);
protected readonly transferVerificationErrorTitle = computed(() =>
this.transferPrimaryCandidateReason() === 'exact_amount_near_dni'
? 'El DNI no corresponde con el de la transferencia'
: null,
);
protected readonly whatsappUrl = computed(
() =>
this.tenantService
@@ -129,6 +184,28 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
);
constructor() {
effect(() => {
const remainingSeconds = this.checkoutCountdownService.remainingSeconds();
const purchaseId = this.createdPurchaseId() ?? this.createdPurchase()?.id ?? null;
if (remainingSeconds !== null && remainingSeconds > 0) {
this.expirationCheckPurchaseId = null;
return;
}
if (
remainingSeconds !== 0 ||
purchaseId === null ||
this.navigationStarted ||
this.expirationCheckPurchaseId === purchaseId
) {
return;
}
this.expirationCheckPurchaseId = purchaseId;
void this.checkPurchaseAfterCountdownExpiration(purchaseId);
});
this.form.statusChanges
.pipe(startWith(this.form.status))
.subscribe(() => this.isStep1Valid.set(this.form.valid));
@@ -147,7 +224,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
}
ngOnInit(): void {
const purchaseId = Number(this.route.snapshot.queryParamMap.get('purchase'));
const purchaseId = Number(this.route.snapshot.paramMap.get('id'));
if (!Number.isInteger(purchaseId) || purchaseId <= 0) {
void this.router.navigate(['/']);
return;
@@ -158,12 +235,13 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
ngOnDestroy(): void {
this.stopQrPolling();
this.stopTransferPolling();
}
private mapPurchaseItemToMock(item: PurchaseDetailItemResponse): CartItemMock {
return {
cartItemId: item.id,
imageUrl: item.item_details.imagen,
imageUrl: this.usesEventCheckoutSummary() ? null : (item.item_details.imagen ?? null),
product: item.item_details.nombre,
originalPrice: null,
discountedPrice: parseFloat(item.unit_price),
@@ -173,92 +251,30 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
value: attribute.value === null ? '' : String(attribute.value),
})),
quantity: item.quantity,
variantId: item.source_variant_id,
};
}
protected async onEditingItemsChange(editing: boolean): Promise<void> {
if (this.isUpdatingItem() || this.isPreparingItemEdit()) {
protected async onModifyPurchase(): Promise<void> {
if (this.isPurchaseModificationDisabled()) {
return;
}
if (editing && !this.cartEditingEnabled()) {
return;
}
if (!editing) {
this.isEditingItems.set(false);
if (this.stepper?.currentStepIndex() === 1) {
void this.selectPaymentMethod(this.selectedPaymentMethod());
}
return;
}
this.isEditingItems.set(true);
this.stopQrPolling();
this.qrData.set(null);
this.qrPaymentStatus.set('idle');
this.transferAccount.set(null);
this.transferValidationStatus.set('idle');
const tenant = this.tenantService.tenant();
const purchaseId = this.createdPurchaseId();
if (!tenant || !purchaseId) {
this.isEditingItems.set(false);
return;
}
this.isPreparingItemEdit.set(true);
this.globalLoadingService.start();
try {
const purchase = await this.checkoutService.prepareItemEditing(tenant.codigo, purchaseId);
this.createdPurchase.set(purchase);
} catch (error) {
console.error('Failed to prepare purchase item editing:', error);
this.isEditingItems.set(false);
const cancelled = await this.cancelCurrentPurchase();
if (!cancelled) return;
await this.router.navigate(['/'], {
queryParams: { openCart: true },
});
} finally {
this.isPreparingItemEdit.set(false);
}
}
protected async onPurchaseItemQuantityChange(event: {
item: CartItemMock;
quantity: number;
}): Promise<void> {
const tenant = this.tenantService.tenant();
const purchaseId = this.createdPurchaseId();
const itemId = event.item.cartItemId;
if (
!this.cartEditingEnabled() ||
!tenant ||
!purchaseId ||
!itemId ||
this.isUpdatingItem() ||
!this.isEditingItems()
) {
return;
}
this.isUpdatingItem.set(true);
try {
const purchase = await this.checkoutService.updateItemQuantity(
tenant.codigo,
purchaseId,
itemId,
event.quantity,
);
this.createdPurchase.set(purchase);
} catch (error) {
console.error('Failed to update purchase item quantity:', error);
} finally {
this.isUpdatingItem.set(false);
this.globalLoadingService.stop();
}
}
protected async onStep1Continue(): Promise<void> {
if (this.form.invalid || this.isUpdatingPurchase() || this.isEditingItems()) return;
if (this.form.invalid || this.isUpdatingPurchase()) return;
const tenant = this.tenantService.tenant();
const purchaseId = this.createdPurchaseId();
@@ -275,6 +291,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
nombre_apellido: formValue.nombre,
});
this.createdPurchase.set(purchase);
this.checkoutCountdownService.synchronize(purchase);
this.stepper.next();
@@ -282,56 +299,96 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
void this.selectPaymentMethod(this.selectedPaymentMethod());
} catch (error) {
console.error('Failed to create purchase:', error);
// Here we could show an alert or toast
this.showRequestError(error, 'No se pudieron actualizar los datos de la compra.');
} finally {
this.isUpdatingPurchase.set(false);
}
}
protected async onCancel(): Promise<void> {
if (await this.canDeactivate()) {
void this.router.navigate(['/']);
}
void this.router.navigate(['/']);
}
public async canDeactivate(): Promise<boolean> {
this.stopQrPolling();
this.stopTransferPolling();
if (this.cancelPurchasePromise) {
return this.cancelPurchasePromise;
}
if (this.navigationStarted) {
return true;
}
const purchaseId = this.createdPurchaseId();
const tenant = this.tenantService.tenant();
this.globalLoadingService.start();
try {
return await this.cancelCurrentPurchase();
} finally {
this.globalLoadingService.stop();
}
}
if (!purchaseId || !tenant) {
private cancelCurrentPurchase(): Promise<boolean> {
if (this.cancelPurchasePromise) {
return this.cancelPurchasePromise;
}
this.cancelPurchasePromise = this.performPurchaseCancellation().finally(() => {
this.cancelPurchasePromise = null;
});
return this.cancelPurchasePromise;
}
private async performPurchaseCancellation(): Promise<boolean> {
const tenant = this.tenantService.tenant();
const purchaseId = this.createdPurchaseId();
if (!tenant || !purchaseId) {
this.checkoutCountdownService.clear();
return true;
}
this.isCancellingPurchase.set(true);
try {
await this.checkoutService.cancelPurchase(tenant.codigo, purchaseId);
await firstValueFrom(this.cartService.loadCart());
this.createdPurchaseId.set(null);
this.createdPurchase.set(null);
this.checkoutCountdownService.clear();
try {
await firstValueFrom(this.cartService.loadCart());
} catch (error) {
console.error('Failed to restore cart after cancelling checkout:', error);
}
this.navigationStarted = true;
return true;
} catch (error) {
console.error('Failed to cancel purchase:', error);
console.error('Failed to cancel the current purchase:', error);
if (this.isStockReservationExpiredError(error)) {
this.showRequestError(error, 'La reserva de stock venció.');
this.cartService.loadCart(true).subscribe({
error: (refreshError) => console.error('Error refreshing expired cart', refreshError),
});
this.createdPurchaseId.set(null);
this.createdPurchase.set(null);
this.checkoutCountdownService.clear();
this.navigationStarted = true;
return true;
}
this.showRequestError(error, 'No se pudo cancelar la compra.');
return false;
} finally {
this.isCancellingPurchase.set(false);
}
}
protected async selectPaymentMethod(method: PaymentMethod): Promise<void> {
if (this.navigationStarted || this.isEditingItems()) {
if (this.navigationStarted || this.hasSubmittedTransfer()) {
return;
}
this.stopQrPolling();
this.stopTransferPolling();
this.qrPaymentStatus.set('idle');
this.transferValidationStatus.set('idle');
this.selectedPaymentMethod.set(method);
@@ -371,13 +428,14 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
}
} catch (error) {
console.error('Failed to generate payment intent:', error);
this.showRequestError(error, 'No se pudo generar el pago.');
} finally {
this.isGeneratingIntent.set(false);
}
}
protected async generateTransferIntent(dni: string): Promise<void> {
if (this.isEditingItems()) {
if (this.hasSubmittedTransfer()) {
return;
}
@@ -389,7 +447,9 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
}
this.transferDni.set(dni);
this.stopTransferPolling();
this.transferValidationStatus.set('idle');
this.transferPrimaryCandidateReason.set(null);
this.isGeneratingIntent.set(true);
try {
const response = await this.checkoutService
@@ -407,6 +467,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
}
} catch (error) {
console.error('Failed to generate transfer payment intent:', error);
this.showRequestError(error, 'No se pudo generar el pago por transferencia.');
} finally {
this.isGeneratingIntent.set(false);
}
@@ -437,28 +498,127 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
!purchaseId ||
!tenant ||
this.navigationStarted ||
this.isEditingItems() ||
this.transferValidationStatus() === 'checking'
) {
return;
}
this.stopTransferPolling();
this.hasSubmittedTransfer.set(true);
this.transferValidationStatus.set('checking');
this.transferPrimaryCandidateReason.set(null);
this.transferPollingAttempts = 0;
const runId = this.transferPollingRunId;
try {
const purchase = await this.checkoutService
.withCustomLoading()
.getPurchase(tenant.codigo, purchaseId);
.submitPurchaseForReview(tenant.codigo, purchaseId);
if (runId !== this.transferPollingRunId) {
return;
}
this.checkoutCountdownService.synchronize(purchase);
this.captureTransferCandidateReason(purchase);
if (purchase.status === 'paid') {
this.navigateToPurchaseStatus(purchaseId);
return;
}
this.transferValidationStatus.set('error');
this.scheduleTransferPoll(runId);
} catch (error) {
const expired = this.handleCheckoutError(
error,
'No se pudo enviar el pago para su validación.',
);
if (!expired && runId === this.transferPollingRunId) {
this.transferValidationStatus.set('error');
}
}
}
private scheduleTransferPoll(runId: number): void {
this.transferPollingTimeoutId = setTimeout(() => {
this.transferPollingTimeoutId = null;
void this.checkTransferPayment(runId);
}, this.transferPollingIntervalMs);
}
private async checkTransferPayment(runId: number): Promise<void> {
if (runId !== this.transferPollingRunId) {
return;
}
const purchaseId = this.createdPurchaseId();
const tenant = this.tenantService.tenant();
if (!purchaseId || !tenant || this.selectedPaymentMethod() !== 'transfer') {
this.stopTransferPolling();
return;
}
this.transferPollingAttempts += 1;
try {
const purchase = await this.checkoutService
.withCustomLoading()
.getPurchase(tenant.codigo, purchaseId);
if (runId !== this.transferPollingRunId) {
return;
}
this.checkoutCountdownService.synchronize(purchase);
this.captureTransferCandidateReason(purchase);
if (purchase.status === 'paid') {
this.navigateToPurchaseStatus(purchaseId);
return;
}
if (purchase.status === 'expired') {
this.navigateToPurchaseStatus(purchaseId);
return;
}
} catch (error) {
console.error('Failed to validate transfer payment:', error);
if (this.isPurchaseExpiredError(error)) {
this.showRequestError(error, 'La compra venció. Iniciá una nueva compra.');
return;
}
}
if (runId !== this.transferPollingRunId) {
return;
}
if (this.transferPollingAttempts >= this.transferPollingMaxAttempts) {
this.stopTransferPolling();
this.transferValidationStatus.set('error');
return;
}
this.scheduleTransferPoll(runId);
}
private captureTransferCandidateReason(purchase: PurchaseStatusResponse): void {
const reason = purchase.payment_verification?.primary?.reason;
if (reason) {
this.transferPrimaryCandidateReason.set(reason);
}
}
private stopTransferPolling(): void {
this.transferPollingRunId += 1;
if (this.transferPollingTimeoutId !== null) {
clearTimeout(this.transferPollingTimeoutId);
this.transferPollingTimeoutId = null;
}
}
@@ -509,6 +669,8 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
return;
}
this.checkoutCountdownService.synchronize(purchase);
if (purchase.status === 'paid') {
this.handleConfirmedPayment(purchaseId);
return;
@@ -519,8 +681,17 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
this.qrPaymentStatus.set('failed');
return;
}
if (purchase.status === 'expired') {
this.navigateToPurchaseStatus(purchaseId);
return;
}
} catch (error) {
console.error('Failed to validate QR payment:', error);
if (this.isPurchaseExpiredError(error)) {
this.showRequestError(error, 'La compra venció. Iniciá una nueva compra.');
return;
}
} finally {
if (runId === this.qrPollingRunId) {
this.isCheckingQrPayment.set(false);
@@ -572,10 +743,41 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
this.navigationStarted = true;
this.stopQrPolling();
this.stopTransferPolling();
this.checkoutCountdownService.clear();
void this.router.navigate(['/checkout/status', purchaseId]);
}
private async checkPurchaseAfterCountdownExpiration(purchaseId: number): Promise<void> {
const tenant = this.tenantService.tenant();
if (!tenant || this.navigationStarted) {
return;
}
try {
const purchase = await this.checkoutService
.withCustomLoading()
.getPurchase(tenant.codigo, purchaseId);
if (this.navigationStarted) {
return;
}
if (purchase.status === 'expired') {
this.navigateToExpiredPurchaseStatus();
return;
}
this.checkoutCountdownService.synchronize(purchase);
} catch (error) {
if (this.isPurchaseExpiredError(error)) {
this.navigateToExpiredPurchaseStatus();
}
}
}
private async loadPurchase(purchaseId: number): Promise<void> {
const tenant = this.tenantService.tenant();
if (!tenant) {
@@ -583,6 +785,8 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
return;
}
this.createdPurchaseId.set(purchaseId);
try {
const purchase = await this.checkoutService
.withCustomLoading()
@@ -590,6 +794,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
if (
(purchase.status === 'pending_payment' && purchase.expires_at === null) ||
purchase.status === 'in_review' ||
purchase.status === 'paid' ||
purchase.status === 'cancelled' ||
purchase.status === 'rejected' ||
@@ -606,6 +811,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
this.createdPurchaseId.set(purchase.id);
this.createdPurchase.set(purchase);
this.checkoutCountdownService.synchronize(purchase);
this.checkoutStepIndex.set(purchase.status === 'pending_payment' ? 1 : 0);
if (
@@ -623,7 +829,113 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
}
} catch (error) {
console.error('Failed to load purchase:', error);
void this.router.navigate(['/']);
const expired = this.showRequestError(error, 'No se pudo cargar la compra.');
if (!expired) {
void this.router.navigate(['/']);
}
}
}
private showRequestError(error: unknown, fallbackMessage: string): boolean {
const payload =
typeof error === 'object' && error !== null && 'error' in error
? (error as { error?: ApiErrorResponse }).error
: undefined;
const message =
typeof payload?.message === 'string' && payload.message.trim()
? payload.message
: fallbackMessage;
this.toastService.danger(message);
if (payload?.code === 'purchase.expired') {
this.navigateToExpiredPurchaseStatus();
return true;
}
return false;
}
private handleCheckoutError(error: unknown, fallbackMessage: string): boolean {
if (!(error instanceof HttpErrorResponse)) {
this.toastService.danger(fallbackMessage);
return false;
}
const response = error.error as ApiErrorResponse | null;
if (response?.code === 'purchase.expired' || this.hasExpiredPurchase()) {
this.stopQrPolling();
this.stopTransferPolling();
this.toastService.danger(
response?.code === 'purchase.expired' && response.message
? response.message
: 'La compra venció. Iniciá una nueva compra.',
);
this.navigateToExpiredPurchaseStatus();
return true;
}
const validationMessage = response?.errors
? Object.values(response.errors).flat().find(Boolean)
: undefined;
this.toastService.danger(validationMessage ?? response?.message ?? fallbackMessage);
return false;
}
private navigateToExpiredPurchaseStatus(): void {
const purchaseId = this.createdPurchaseId() ?? this.createdPurchase()?.id;
if (purchaseId) {
if (this.navigationStarted) {
return;
}
this.navigationStarted = true;
this.stopQrPolling();
this.stopTransferPolling();
this.checkoutCountdownService.clear();
void this.router.navigate(['/checkout/status', purchaseId], {
queryParams: { status: 'expired' },
});
return;
}
void this.router.navigate(['/']);
}
private isPurchaseExpiredError(error: unknown): boolean {
if (typeof error !== 'object' || error === null || !('error' in error)) {
return false;
}
return (error as { error?: ApiErrorResponse }).error?.code === 'purchase.expired';
}
private isStockReservationExpiredError(error: unknown): boolean {
if (typeof error !== 'object' || error === null || !('error' in error)) {
return false;
}
return (error as { error?: ApiErrorResponse }).error?.code === 'stock_reservation.expired';
}
private hasExpiredPurchase(): boolean {
const purchase = this.createdPurchase();
if (!purchase) {
return false;
}
if (purchase.status === 'expired') {
return true;
}
if (!purchase.expires_at) {
return false;
}
return Date.parse(purchase.expires_at) <= Date.now();
}
}

View File

@@ -7,13 +7,18 @@
<div class="payment-methods__list" role="radiogroup" aria-label="Metodo de pago">
@for (method of paymentMethods(); track method.id) {
<label class="payment-method" [class.is-selected]="selectedPaymentMethod() === method.id">
<label
class="payment-method"
[class.is-selected]="selectedPaymentMethod() === method.id"
[class.is-disabled]="paymentMethodDisabled()"
>
<input
class="payment-method__radio"
type="radio"
name="payment-method"
[value]="method.id"
[checked]="selectedPaymentMethod() === method.id"
[disabled]="paymentMethodDisabled()"
(change)="selectPaymentMethod(method.id)"
/>
@@ -48,6 +53,7 @@
[validationStatus]="transferValidationStatus()"
[paymentAmount]="qrPaymentAmount()"
[whatsappUrl]="whatsappUrl()"
[verificationErrorTitle]="transferVerificationErrorTitle()"
(copyTransferValue)="requestCopy($event.field, $event.value)"
(submitDni)="generateTransferIntent.emit($event)"
(completePurchase)="complete.emit()"

View File

@@ -63,10 +63,14 @@
cursor: pointer;
transition: color 0.2s ease;
&:hover {
&:not(.is-disabled):hover {
color: #4f4f4f;
}
&.is-disabled {
cursor: default;
}
&.is-selected {
color: var(--tenant-primary, #6376f3);
}
@@ -79,6 +83,10 @@
cursor: pointer;
}
&.is-disabled &__radio {
cursor: default;
}
&__label {
min-width: 0;
font-size: 13px;
@@ -91,11 +99,13 @@
font-size: 0.95rem;
opacity: 0;
transform: translateX(-4px);
transition: opacity 0.2s ease, transform 0.2s ease;
transition:
opacity 0.2s ease,
transform 0.2s ease;
}
&.is-selected &__chevron,
&:hover &__chevron {
&:not(.is-disabled):hover &__chevron {
opacity: 1;
transform: translateX(0);
}
@@ -140,6 +150,4 @@
font-weight: 700;
line-height: 1.35;
}
}

View File

@@ -17,11 +17,12 @@ import {
imports: [CheckoutPaymentQrComponent, CheckoutPaymentTransferComponent],
templateUrl: './checkout-payment-step.component.html',
styleUrl: './checkout-payment-step.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CheckoutPaymentStepComponent {
readonly paymentMethods = input.required<ReadonlyArray<PaymentMethodOption>>();
readonly selectedPaymentMethod = input.required<PaymentMethod>();
readonly paymentMethodDisabled = input<boolean>(false);
readonly copiedTransferField = input<TransferField | null>(null);
readonly transferAccount = input<TransferAccount | null>(null);
readonly transferDni = input<string>('');
@@ -32,6 +33,7 @@ export class CheckoutPaymentStepComponent {
readonly qrPaymentAmount = input<number>(0);
readonly whatsappUrl = input<string | null>(null);
readonly transferValidationStatus = input<TransferValidationStatus>('idle');
readonly transferVerificationErrorTitle = input<string | null>(null);
readonly paymentMethodChange = output<PaymentMethod>();
readonly copyTransferValue = output<{ field: TransferField; value: string }>();
@@ -40,6 +42,10 @@ export class CheckoutPaymentStepComponent {
readonly generateTransferIntent = output<string>();
protected selectPaymentMethod(method: PaymentMethod): void {
if (this.paymentMethodDisabled()) {
return;
}
this.paymentMethodChange.emit(method);
}

View File

@@ -3,6 +3,7 @@
<app-payment-verification-error
[paymentAmount]="paymentAmount()"
[whatsappUrl]="whatsappUrl()"
[title]="verificationErrorTitle()"
/>
} @else {
<div class="dni-form-container">

View File

@@ -47,4 +47,23 @@ describe('CheckoutPaymentTransferComponent', () => {
expect(whatsapp).toBeDefined();
expect(element.querySelector('.payment-verification')).toBeNull();
});
it('shows a custom validation title for a near DNI candidate', async () => {
await TestBed.configureTestingModule({
imports: [CheckoutPaymentTransferComponent],
}).compileComponents();
const fixture = TestBed.createComponent(CheckoutPaymentTransferComponent);
fixture.componentRef.setInput('validationStatus', 'error');
fixture.componentRef.setInput(
'verificationErrorTitle',
'El DNI no corresponde con el de la transferencia',
);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain(
'El DNI no corresponde con el de la transferencia',
);
expect(fixture.nativeElement.textContent).not.toContain('No pudimos verificar el pago de');
});
});

View File

@@ -41,6 +41,7 @@ export class CheckoutPaymentTransferComponent implements OnInit {
readonly validationStatus = input<TransferValidationStatus>('idle');
readonly paymentAmount = input<number>(0);
readonly whatsappUrl = input<string | null>(null);
readonly verificationErrorTitle = input<string | null>(null);
readonly copyTransferValue = output<{ field: TransferField; value: string }>();
readonly submitDni = output<string>();

View File

@@ -2,7 +2,7 @@
<div class="payment-timeout__icon" aria-hidden="true">
<i class="fa-solid fa-xmark"></i>
</div>
<h4 class="payment-timeout__title">No pudimos verificar el pago de {{ formattedAmount() }}.</h4>
<h4 class="payment-timeout__title">{{ displayTitle() }}</h4>
<p class="payment-timeout__message">Por favor contactate con nosotros para resolverlo.</p>
@if (whatsappUrl()) {
<app-button

View File

@@ -13,6 +13,7 @@ import { ButtonComponent } from '../../../../../../shared/components/button/butt
export class PaymentVerificationErrorComponent {
readonly paymentAmount = input<number>(0);
readonly whatsappUrl = input<string | null>(null);
readonly title = input<string | null>(null);
protected readonly formattedAmount = computed(() =>
new Intl.NumberFormat('es-AR', {
@@ -21,6 +22,9 @@ export class PaymentVerificationErrorComponent {
maximumFractionDigits: 0,
}).format(this.paymentAmount()),
);
protected readonly displayTitle = computed(
() => this.title() ?? `No pudimos verificar el pago de ${this.formattedAmount()}.`,
);
protected openWhatsApp(): void {
const url = this.whatsappUrl();

View File

@@ -0,0 +1,33 @@
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
import { TenantService } from '../../../../core/services/tenant.service';
import { EventListComponent } from '../../components/event-list/event-list.component';
@Component({
selector: 'app-event-category-page',
imports: [EventListComponent],
template: `
@if (category(); as selectedCategory) {
<section aria-labelledby="event-category-title">
<h1 id="event-category-title" class="h3 mb-4">{{ selectedCategory.nombre }}</h1>
<app-event-list [categoryId]="selectedCategory.id" />
</section>
} @else {
<p role="alert">La categoría no existe.</p>
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EventCategoryPageComponent {
private readonly route = inject(ActivatedRoute);
private readonly tenant = inject(TenantService).tenant;
private readonly params = toSignal(this.route.paramMap, {
initialValue: this.route.snapshot.paramMap,
});
protected readonly category = computed(() => {
const id = Number(this.params().get('id'));
return this.tenant()?.event_categories?.find((category) => category.id === id) ?? null;
});
}

View File

@@ -0,0 +1,111 @@
@if (event(); as item) {
<div class="event-detail row gx-sm-4 gx-lg-5">
<aside class="event-detail__sidebar col-12 col-sm-4">
<div class="event-detail__image" [class.event-detail__image--placeholder]="!item.image">
@if (item.image) {
<img [src]="item.image" [alt]="item.title" />
} @else {
<span aria-hidden="true"><i class="fa-regular fa-calendar"></i></span>
}
</div>
@if (item.starts_at) {
<app-counter-to [startsAt]="item.starts_at" />
}
@if (item.description) {
<section class="event-detail__description" aria-labelledby="event-description-title">
<h2 id="event-description-title">Descripción</h2>
<p>{{ item.description }}</p>
</section>
}
@if (item.social_media?.length) {
<section class="event-detail__social" aria-labelledby="event-social-title">
<h2 id="event-social-title">Información</h2>
@for (social of item.social_media; track social.code) {
<a [href]="social.url" target="_blank" rel="noopener noreferrer">
<i [class]="socialIcon(social.code)" aria-hidden="true"></i>
<span>{{ socialLabel(social.code) }}</span>
</a>
}
</section>
}
@if (mapLocations().length) {
<app-location-map
class="event-detail__map"
[locations]="mapLocations()"
ariaLabel="Ubicación del evento"
mapStyle="standard"
/>
}
</aside>
<main class="event-detail__main col-12 col-sm-8">
<header class="event-detail__header">
<h1>{{ item.title }}</h1>
@if (item.subtitle) { <p class="event-detail__subtitle">{{ item.subtitle }}</p> }
<div class="event-detail__meta">
@if (item.date_text) {
<p><i class="fa-regular fa-calendar" aria-hidden="true"></i>
{{ item.date_text }}@if (item.dates?.length === 1 && item.start_time) { · {{ item.start_time.slice(0, 5) }} h }
</p>
}
@if (item.location) {
<p><i class="fa-solid fa-location-dot" aria-hidden="true"></i>{{ item.location }}</p>
}
</div>
</header>
<section class="event-detail__products" aria-labelledby="event-products-title">
<div class="event-detail__section-heading"><h2 id="event-products-title">Productos</h2></div>
@if ((item.dates?.length ?? 0) > 1) {
<label class="event-detail__date-label" for="event-date-select">Seleccioná fecha</label>
<select id="event-date-select" class="event-detail__date-select" [value]="selectedDateId()" (change)="selectDate($event)">
@for (date of item.dates; track date.id) {
<option [value]="date.id">{{ formatDate(date.date) }} · {{ date.time_start.slice(0, 5) }} h</option>
}
</select>
}
@if (item.catalog_items?.length) {
<div class="event-detail__product-list">
@for (product of item.catalog_items; track product.id) {
<div class="event-detail__product row align-items-center gx-3 gy-2">
<h3 class="col-12 col-sm">{{ product.name }}</h3>
<div class="event-detail__product-action col-auto ms-auto ms-sm-0">
@if (needsSelection(product)) {
<a [routerLink]="['/producto', product.id]">Elegir opciones</a>
} @else {
<app-quantity-selector
[quantity]="quantityFor(product.id)"
[min]="0"
[max]="maximumQuantityFor(product)"
size="medium"
(decrease)="changeQuantity(product, -1)"
(increase)="changeQuantity(product, 1)"
/>
}
</div>
<strong class="event-detail__price col-auto">{{ priceFor(product) }}</strong>
</div>
}
</div>
<div class="event-detail__total"><span>Total</span><strong>{{ formatPrice(total()) }}</strong></div>
<div class="event-detail__buttons">
<app-button variant="cancel" [disabled]="!hasSelectedItems()" (click)="cancel()">Cancelar</app-button>
<app-button variant="primary" [disabled]="total() === 0 || submitting()" (click)="buyNow()">
{{ submitting() ? 'Procesando…' : 'Comprar' }}
</app-button>
</div>
} @else {
<p class="event-detail__empty">Por ahora no hay productos disponibles para este evento.</p>
}
</section>
</main>
</div>
} @else if (!loading()) {
<p role="alert">No encontramos este evento.</p>
}

View File

@@ -0,0 +1,43 @@
:host { display: block; color: #666666; }
.event-detail { margin-bottom: 3rem; }
.event-detail__sidebar, .event-detail__main { min-width: 0; }
.event-detail__image { background: #e7e8e9; }
.event-detail__image--placeholder { aspect-ratio: 1.43; display: grid; place-items: center; }
.event-detail__image img { width: 100%; height: auto; display: block; }
.event-detail__image span { font-size: 3rem; color: #aaa; }
.event-detail__description { margin-top: 1.5rem; }
.event-detail__description h2, .event-detail__social h2, .event-detail__section-heading h2 { margin: 0 0 .65rem; color: #666666; font-size: 15px; font-weight: 700; text-transform: uppercase; letter-spacing: .015em; }
.event-detail__description p { margin: 0; white-space: pre-line; line-height: 1.36; font-size: 14px; }
.event-detail__social { display: grid; gap: .35rem; margin-top: 2.3rem; }
.event-detail__social a { color: #666666; font-family: 'Gotham Book', sans-serif; font-size: 14px; font-weight: 400; display: flex; align-items: center; gap: .5rem; text-decoration: none; font-style: italic; }
.event-detail__social a:hover { color: var(--tenant-primary); text-decoration: none; }
.event-detail__social i { width: 18px; height: 18px; font-size: 18px; line-height: 18px; text-align: center; }
.event-detail__map { margin-top: 16px; --location-map-min-height: 180px; --location-map-border-radius: 0; }
.event-detail__header { margin-bottom: 2rem; padding-top: .7rem; }
.event-detail__header h1 { margin: 0; color: #666666; font-size: 24px; line-height: 1.25; font-weight: 700; text-transform: uppercase; letter-spacing: .03em; }
.event-detail__subtitle { margin: .15rem 0 1.2rem; color: #666666; font-size: 18px; font-weight: 400; }
.event-detail__meta { display: grid; gap: .3rem; }
.event-detail__meta p { margin: 0; display: flex; align-items: baseline; gap: .4rem; color: #666666; font-family: 'Gotham Book', sans-serif; font-size: 12px; font-weight: 400; font-style: italic; }
.event-detail__meta i { width: .65rem; flex: none; text-align: center; }
.event-detail__section-heading { border-bottom: 1px solid #e2e4e6; margin-bottom: .9rem; }
.event-detail__section-heading h2 { margin-bottom: .5rem; }
.event-detail__date-label { display: block; margin-bottom: .4rem; color: #6b7175; font-size: .75rem; }
.event-detail__date-select { width: min(100%, 320px); padding: .55rem .75rem; border: 1px solid #d7d9db; border-radius: 4px; background: #fff; color: #555b60; font-size: .82rem; }
.event-detail__product-list { display: grid; gap: 28px; margin-top: 1.1rem; }
.event-detail__product { min-height: 40px; }
.event-detail__product h3 { min-width: 0; margin: 0; color: #838383; font-size: 20px; line-height: 1.2; font-weight: 700; text-transform: uppercase; }
.event-detail__product-action { width: 110px; text-align: right; }
.event-detail__product-action > a { color: var(--tenant-primary); font-size: .62rem; white-space: nowrap; }
.event-detail__price { width: 120px; color: #838383; font-size: 22px; font-weight: 900; text-align: right; white-space: nowrap; }
.event-detail__total { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; padding: .75rem 0 0; border-top: 1px solid #dfe2e4; margin-top: 1.15rem; font-size: 13px; color: #999999; text-transform: uppercase; }
.event-detail__total strong { color: var(--tenant-primary); font-size: 22px; font-weight: 900; }
.event-detail__buttons { display: flex; justify-content: flex-end; gap: .5rem; margin-top: 1.6rem; }
.event-detail__buttons app-button { min-width: 0; flex: 0 0 36%; }
.event-detail__empty { color: #7c8286; font-size: .85rem; }
@media (max-width: 575.98px) {
.event-detail__main { margin-top: 2rem; }
.event-detail__description, .event-detail__social { margin-top: 1.5rem; }
.event-detail__header { min-height: 0; margin-bottom: 2rem; }
.event-detail__buttons { flex-wrap: wrap; }
.event-detail__buttons app-button { flex: 1 1 100%; }
}

View File

@@ -0,0 +1,196 @@
import { HttpErrorResponse } from '@angular/common/http';
import { ChangeDetectionStrategy, Component, DestroyRef, computed, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { catchError, map, of, switchMap } from 'rxjs';
import { AuthService } from '../../../../core/services/auth/auth.service';
import { CheckoutService, DirectCheckoutItem } from '../../../../core/services/checkout.service';
import { StoreEvent } from '../../../../core/services/event/event.interface';
import { EventService } from '../../../../core/services/event/event.service';
import { TenantService } from '../../../../core/services/tenant.service';
import { ToastService } from '../../../../core/services/toast.service';
import { CounterToComponent } from '../../../../shared/components/counter-to/counter-to.component';
import { ButtonComponent } from '../../../../shared/components/button/button.component';
import { LocationMapComponent, MapLocation } from '../../../../shared/components/location-map/location-map.component';
import { QuantitySelectorComponent } from '../../../../shared/components/quantity-selector/quantity-selector.component';
type EventProduct = NonNullable<StoreEvent['catalog_items']>[number];
@Component({
selector: 'app-event-detail-page',
imports: [RouterLink, ButtonComponent, CounterToComponent, LocationMapComponent, QuantitySelectorComponent],
templateUrl: './event-detail-page.component.html',
styleUrl: './event-detail-page.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EventDetailPageComponent {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly events = inject(EventService);
private readonly checkout = inject(CheckoutService);
private readonly auth = inject(AuthService);
private readonly tenant = inject(TenantService);
private readonly toast = inject(ToastService);
private readonly destroyRef = inject(DestroyRef);
protected readonly event = signal<StoreEvent | null>(null);
protected readonly loading = signal(true);
protected readonly selectedDateId = signal<number | null>(null);
protected readonly quantities = signal<Record<number, number>>({});
protected readonly submitting = signal(false);
protected readonly mapLocations = computed<MapLocation[]>(() => {
const event = this.event();
const coordinates = event?.exact_location;
if (!event || !coordinates) return [];
return [{
id: event.id,
label: event.title,
latitude: coordinates.latitude,
longitude: coordinates.longitude,
address: event.location ?? undefined,
}];
});
protected readonly total = computed(() => (this.event()?.catalog_items ?? []).reduce((sum, product) => {
const choice = this.resolveChoice(product);
return sum + (choice ? choice.price * this.quantityFor(product.id) : 0);
}, 0));
protected readonly hasSelectedItems = computed(() =>
Object.values(this.quantities()).some((quantity) => quantity > 0),
);
constructor() {
this.route.paramMap.pipe(
map((params) => Number(params.get('id'))),
switchMap((id) => Number.isInteger(id) && id > 0
? this.events.get(id).pipe(map((response) => response.data), catchError(() => of(null)))
: of(null)),
takeUntilDestroyed(this.destroyRef),
).subscribe((event) => {
this.event.set(event);
this.selectedDateId.set(event?.dates?.[0]?.id ?? null);
this.quantities.set({});
this.loading.set(false);
});
}
protected selectDate(event: Event): void {
const value = Number((event.target as HTMLSelectElement).value);
this.selectedDateId.set(Number.isInteger(value) ? value : null);
this.quantities.set({});
}
protected quantityFor(productId: number): number {
return this.quantities()[productId] ?? 0;
}
protected changeQuantity(product: EventProduct, amount: number): void {
const choice = this.resolveChoice(product);
if (!choice) return;
const maximum = choice.maximumQuantity ?? Number.MAX_SAFE_INTEGER;
const quantity = Math.min(maximum, Math.max(0, this.quantityFor(product.id) + amount));
this.quantities.update((current) => ({ ...current, [product.id]: quantity }));
}
protected maximumQuantityFor(product: EventProduct): number | null {
return this.resolveChoice(product)?.maximumQuantity ?? null;
}
protected needsSelection(product: EventProduct): boolean {
return this.resolveChoice(product) === null;
}
protected priceFor(product: EventProduct): string {
return this.formatPrice(this.resolveChoice(product)?.price ?? Number(product.price));
}
protected formatPrice(price: number): string {
return '$' + price.toLocaleString('es-AR', { maximumFractionDigits: 0 });
}
protected formatDate(date: string): string {
const [year, month, day] = date.split('-').map(Number);
if (!year || !month || !day) return date;
return new Intl.DateTimeFormat('es-AR', { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' })
.format(new Date(Date.UTC(year, month - 1, day)));
}
protected socialLabel(code: string): string {
const labels: Record<string, string> = {
instagram: 'Instagram', facebook: 'Facebook', whatsapp: 'WhatsApp',
youtube: 'YouTube', tiktok: 'TikTok', x: 'X', twitter: 'X',
};
return labels[code.toLowerCase()] ?? code;
}
protected socialIcon(code: string): string {
const brands = ['instagram', 'facebook', 'whatsapp', 'youtube', 'tiktok', 'x-twitter', 'linkedin'];
const normalized = code.toLowerCase() === 'twitter' || code.toLowerCase() === 'x'
? 'x-twitter'
: code.toLowerCase();
return brands.includes(normalized) ? `fa-brands fa-${normalized}` : 'fa-solid fa-link';
}
protected cancel(): void {
this.quantities.set({});
}
protected async buyNow(): Promise<void> {
const event = this.event();
if (!event || this.submitting()) return;
const items: DirectCheckoutItem[] = [];
for (const product of event.catalog_items ?? []) {
const quantity = this.quantityFor(product.id);
if (quantity === 0) continue;
const choice = this.resolveChoice(product);
if (!choice) return;
items.push({ catalog_item_id: product.id, variant_id: choice.variantId, cantidad: quantity });
}
if (items.length === 0) return;
if (!this.auth.user()) {
await this.router.navigate(['/login'], { queryParams: { returnUrl: this.router.url } });
return;
}
const tenant = this.tenant.tenant();
if (!tenant) {
this.toast.danger('No se pudo identificar la tienda.');
return;
}
this.submitting.set(true);
try {
const purchase = await this.checkout.startCheckout(tenant.codigo, { direct_items: items });
await this.router.navigate(['/checkout', purchase.id]);
} catch (error) {
const message = error instanceof HttpErrorResponse && typeof error.error?.message === 'string'
? error.error.message
: 'No se pudo iniciar la compra.';
this.toast.danger(message);
} finally {
this.submitting.set(false);
}
}
private resolveChoice(product: EventProduct): { variantId: number | null; price: number; maximumQuantity: number | null } | null {
if (product.requires_selection) return null;
if (product.variants.length === 0) {
return { variantId: null, price: Number(product.price), maximumQuantity: product.maximum_quantity };
}
const dateId = this.selectedDateId();
const candidates = product.variants.filter((variant) =>
dateId === null || variant.event_date_ids.length === 0 || variant.event_date_ids.includes(dateId));
if (candidates.length !== 1) return null;
return {
variantId: candidates[0].id,
price: Number(candidates[0].price),
maximumQuantity: candidates[0].maximum_quantity,
};
}
}

View File

@@ -0,0 +1,10 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { EventListComponent } from '../../components/event-list/event-list.component';
@Component({
selector: 'app-event-home-page',
imports: [EventListComponent],
template: '<app-event-list />',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EventHomePageComponent {}

View File

@@ -0,0 +1,10 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { EventListComponent } from '../../components/event-list/event-list.component';
@Component({
selector: 'app-event-search-page',
imports: [EventListComponent],
template: '<section aria-labelledby="event-search-title"><h1 id="event-search-title" class="h3 mb-4">Resultados de búsqueda</h1><app-event-list [search]="true" /></section>',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EventSearchPageComponent {}

View File

@@ -45,7 +45,7 @@
class="contact-page__map"
[locations]="contact().mapLocations"
[markerColor]="markerColor()"
mapStyle="minimal-light"
mapStyle="standard"
ariaLabel="Mapa de sucursales y puntos de atención"
/>
}

View File

@@ -128,7 +128,7 @@ describe('ContactPage', () => {
);
expect(map).not.toBeNull();
expect(map?.getAttribute('mapstyle')).toBe('minimal-light');
expect(map?.getAttribute('mapstyle')).toBe('standard');
expect(accessibleLocations).toHaveLength(2);
expect(map?.textContent).toContain('Gigante de Arroyito');
expect(map?.textContent).toContain('Telepagos');

View File

@@ -3,11 +3,21 @@ import { provideRouter, Router } from '@angular/router';
import { of, throwError } from 'rxjs';
import { AuthService } from '../../../../core/services/auth/auth.service';
import { ToastService } from '../../../../core/services/toast.service';
import { LoginPageComponent } from './login-page.component';
describe('LoginPageComponent', () => {
let toastService: {
danger: ReturnType<typeof vi.fn>;
showAfterReload: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
TestBed.resetTestingModule();
toastService = {
danger: vi.fn(),
showAfterReload: vi.fn(),
};
});
it('submits credentials and redirects to home with a full page reload on success', async () => {
@@ -16,14 +26,18 @@ describe('LoginPageComponent', () => {
of({
id: 1,
nombre_apellido: 'Ada Lovelace',
email: 'ada@example.com'
})
)
email: 'ada@example.com',
}),
),
};
await TestBed.configureTestingModule({
imports: [LoginPageComponent],
providers: [provideRouter([]), { provide: AuthService, useValue: authService }]
providers: [
provideRouter([]),
{ provide: AuthService, useValue: authService },
{ provide: ToastService, useValue: toastService },
],
}).compileComponents();
const fixture = TestBed.createComponent(LoginPageComponent);
@@ -34,17 +48,56 @@ describe('LoginPageComponent', () => {
component.form.setValue({
email: 'ada@example.com',
password: 'secret123'
password: 'secret123',
});
component.onSubmit();
expect(authService.login).toHaveBeenCalledWith({
email: 'ada@example.com',
password: 'secret123'
password: 'secret123',
});
expect(redirectSpy).toHaveBeenCalled();
expect(navigateSpy).not.toHaveBeenCalled();
expect(toastService.showAfterReload).toHaveBeenCalledWith(
'Sesión iniciada correctamente.',
'success',
);
});
it('serializes a stored return URL before reloading so tenant base paths are restored', async () => {
await TestBed.configureTestingModule({
imports: [LoginPageComponent],
providers: [
provideRouter([]),
{ provide: AuthService, useValue: {} },
{ provide: ToastService, useValue: toastService },
],
}).compileComponents();
const fixture = TestBed.createComponent(LoginPageComponent);
const component = fixture.componentInstance as any;
const router = TestBed.inject(Router);
const assign = vi.fn();
const parsedUrl = router.parseUrl('/');
component.document = {
defaultView: {
sessionStorage: {
getItem: vi.fn().mockReturnValue('/'),
removeItem: vi.fn(),
},
},
location: { assign },
};
const parseUrlSpy = vi.spyOn(router, 'parseUrl').mockReturnValue(parsedUrl);
const serializeUrlSpy = vi.spyOn(router, 'serializeUrl').mockReturnValue('/sonder');
component.redirectToHome();
expect(parseUrlSpy).toHaveBeenCalledWith('/');
expect(serializeUrlSpy).toHaveBeenCalledWith(parsedUrl);
expect(assign).toHaveBeenCalledWith('/sonder');
});
it('surfaces backend login errors', async () => {
@@ -53,16 +106,20 @@ describe('LoginPageComponent', () => {
throwError(() => ({
error: {
errors: {
email: ['Las credenciales son invalidas.']
}
}
}))
)
email: ['Las credenciales son invalidas.'],
},
},
})),
),
};
await TestBed.configureTestingModule({
imports: [LoginPageComponent],
providers: [provideRouter([]), { provide: AuthService, useValue: authService }]
providers: [
provideRouter([]),
{ provide: AuthService, useValue: authService },
{ provide: ToastService, useValue: toastService },
],
}).compileComponents();
const fixture = TestBed.createComponent(LoginPageComponent);
@@ -70,22 +127,27 @@ describe('LoginPageComponent', () => {
component.form.setValue({
email: 'ada@example.com',
password: 'wrong-password'
password: 'wrong-password',
});
component.onSubmit();
expect(component.serverError()).toBe('Las credenciales son invalidas.');
expect(toastService.danger).toHaveBeenCalledWith('Las credenciales son invalidas.');
});
it('validates email length and password minimum length before submit', async () => {
const authService = {
login: vi.fn()
login: vi.fn(),
};
await TestBed.configureTestingModule({
imports: [LoginPageComponent],
providers: [provideRouter([]), { provide: AuthService, useValue: authService }]
providers: [
provideRouter([]),
{ provide: AuthService, useValue: authService },
{ provide: ToastService, useValue: toastService },
],
}).compileComponents();
const fixture = TestBed.createComponent(LoginPageComponent);
@@ -93,12 +155,15 @@ describe('LoginPageComponent', () => {
component.form.setValue({
email: `${'a'.repeat(250)}@example.com`,
password: '1234567'
password: '1234567',
});
component.onSubmit();
expect(authService.login).not.toHaveBeenCalled();
expect(component.getControlError('email')).toBe('No puede superar los 255 caracteres.');
expect(component.getControlError('password')).toBe('Debe tener al menos 8 caracteres.');
expect(toastService.danger).toHaveBeenCalledWith(
'Revisá los datos ingresados para iniciar sesión.',
);
});
});

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