170 Commits

Author SHA1 Message Date
109a1cab5a refactor: update payment validation logic to handle transfer statuses more effectively 2026-07-29 09:36:22 -03:00
9bb3374ba0 feat: add submitPurchaseForReview method and update checkout flow to handle purchase review status 2026-07-29 09:20:17 -03:00
76327d34ec refactor: update styles for category items page header and title 2026-07-29 08:33:54 -03:00
4cb81ed70d feat: implement payment verification error component and update checkout flow 2026-07-28 17:02:12 -03:00
c233845ce0 feat: add WhatsApp support and improve payment timeout handling in checkout process 2026-07-28 16:57:00 -03:00
15a74bb56e feat: enhance purchase status page with ticket handling and WhatsApp integration 2026-07-28 16:26:45 -03:00
58e9dc06b1 refactor: simplify purchase status page by removing polling logic and updating messages 2026-07-28 16:03:17 -03:00
b96d6f23b6 feat: implement category dropdown and category items page with routing and styling 2026-07-28 12:20:53 -03:00
0726b2219e feat: add loading overlay and disable actions during PDF generation for tickets 2026-07-28 09:53:54 -03:00
7845c8f20e feat: implement loading state for checkout process and enhance stepper component 2026-07-28 09:25:04 -03:00
c41c60f349 feat: add download and share functionality for selected tickets 2026-07-28 09:01:39 -03:00
163444c91f feat: enhance loading skeleton for tickets list with improved styling and structure 2026-07-27 16:29:22 -03:00
4f1405f6c1 feat: refactor services injection in search and store home components, add direct purchase event tests 2026-07-27 16:26:03 -03:00
f063bac527 feat: implement direct purchase functionality and enhance product buy events 2026-07-27 16:25:38 -03:00
902f65d8d0 feat: enhance checkout process with purchase editing and status handling
- Implemented purchase editing functionality in CheckoutPageComponent, allowing users to modify items in their purchase.
- Added a new guard (checkoutPendingPurchaseGuard) to prevent navigation away from the checkout page while a purchase is in progress.
- Updated the login page to handle return URLs after authentication.
- Enhanced product detail page to support direct purchases with a new buyNow method.
- Introduced new UI elements and logic to handle purchase status, including expired and rejected states in PurchaseStatusPageComponent.
- Improved cart component to allow editing of item quantities with a toggle button.
- Added quantity selector enhancements to disable controls when necessary.
- Updated tests to cover new functionalities and ensure proper behavior of components.
2026-07-27 12:49:00 -03:00
9dcedc9382 Forgot password flow 2026-07-27 09:42:22 -03:00
565bc769ed feat(environment): add homo environment configuration for homologation 2026-07-24 16:59:15 -03:00
4e06145f44 feat(search): enforce minimum character limit for search input and display error message 2026-07-24 14:17:41 -03:00
9bb4ff61f7 feat(search): update search functionality to handle button click and Enter key press 2026-07-24 14:10:43 -03:00
79a6edc3f3 feat(search): implement search functionality with results page and routing 2026-07-24 14:10:26 -03:00
5d5146d784 feat(tickets-page): conditionally render header and bulk actions based on available tickets 2026-07-24 10:42:17 -03:00
34d351afc5 feat(store-layout): add tickets menu button and navigation handling in store header 2026-07-24 10:42:09 -03:00
9845a82f4a feat(product-list): center justify content in adaptive layout for improved alignment 2026-07-24 10:31:27 -03:00
f4e854843e feat(product-list): improve adaptive layout by refining grid-template-columns for better responsiveness 2026-07-24 10:30:24 -03:00
752c5fceea feat(menu): refactor menu handling to use labels directly and remove unused functions 2026-07-24 10:12:11 -03:00
974f947fc6 feat(account-page): implement dynamic account navigation menus in sidebar and user dropdown 2026-07-24 09:58:18 -03:00
fa0e9de886 feat(contact-page): implement contact page with dynamic content and map integration 2026-07-24 09:41:09 -03:00
656e41d189 feat(contact-page): add route for contact page with menu guard 2026-07-24 09:13:12 -03:00
63ae02df22 feat(location-map): implement reusable location map component with dynamic markers and directions 2026-07-24 09:05:16 -03:00
1d5c94b9bc feat(help-page): enhance help section with dynamic footer links and add pending help page 2026-07-23 17:13:43 -03:00
afaa761acc feat(help-page): implement help page layout with FAQ section and sidebar navigation 2026-07-23 17:13:33 -03:00
d50bd40602 feat(menu-guard): enhance menu guard logic to support nested menus 2026-07-23 17:10:04 -03:00
aa51742627 feat(accordion): implement reusable accordion component with multiple item support 2026-07-23 16:50:12 -03:00
21119ff303 feat(account-page): implement sidebar layout with account sidebar and routing 2026-07-23 15:40:55 -03:00
44bcb9aeba feat(social-media): implement social media component and integrate into store footer 2026-07-23 15:07:29 -03:00
67702ffdd7 feat(carousel): enhance image loading strategy and improve product display in carousel 2026-07-23 14:44:52 -03:00
b04c33bbff feat(carousel): add main carousel component with image support and integrate into store home page 2026-07-23 14:27:03 -03:00
5d1ed7efa2 feat(carousel): enhance product list layout with adaptive grid and simple vertical options 2026-07-23 13:41:40 -03:00
37bbd65cfe feat(carousel): adjust item width calculation for better distribution and responsiveness 2026-07-23 13:37:10 -03:00
862751fec8 feat(carousel): implement reusable carousel for product list and enhance layout options 2026-07-23 13:27:07 -03:00
251411d200 feat(carousel): introduce new catalog group layout options and update related components for enhanced product display 2026-07-23 13:25:35 -03:00
4f444fd757 feat(carousel): enhance carousel layout and control positioning for improved responsiveness 2026-07-23 12:29:08 -03:00
ca1cacc9ea feat(carousel): add new product images and update carousel item details 2026-07-23 12:23:48 -03:00
ea0bd4a956 feat(carousel): enhance item retrieval logic for improved circular navigation 2026-07-23 12:15:44 -03:00
8963bd5460 feat(carousel): update test description and improve item selection logic in carousel component 2026-07-23 12:09:28 -03:00
a1d5ecb6fa feat(carousel): implement circular layout and enhance navigation logic 2026-07-23 12:09:21 -03:00
fb6ab2fca2 feat(carousel): add circular scrolling support and improve button disable logic 2026-07-23 11:53:56 -03:00
326f2d9bcd feat(carousel): add viewport adjustment test and refine gap handling in layout calculations 2026-07-23 11:49:34 -03:00
8268e58547 feat(carousel): enhance carousel component with gap support and layout adjustments 2026-07-23 11:49:23 -03:00
3e98d08d59 feat(carousel): implement product carousel component with mock data and styles 2026-07-23 11:47:10 -03:00
20c8b04638 feat(auth): implement Google OAuth login flow and handle session clearing on 401 2026-07-22 17:01:11 -03:00
c662c27fc7 feat(auth): integrate TenantService to include tenant code in registration payload 2026-07-22 11:58:31 -03:00
3cefaf85d9 feat(qr-modal): implement QR modal component and integrate with tickets page 2026-07-21 14:55:46 -03:00
472dead0ff feat(tickets): separate active and inactive tickets, update selection logic and styles 2026-07-21 14:35:38 -03:00
fa8032c557 feat(ticket): enhance ticket component with disabled and expired states, update styles and tests 2026-07-21 14:25:52 -03:00
6c5e10feb2 feat(ticket): update ticket title styles and adjust QR button dimensions 2026-07-21 14:14:35 -03:00
63541a3593 feat(ticket): adjust QR button styles for better responsiveness 2026-07-21 14:12:50 -03:00
bc37a91c7f feat(tickets): implement tickets page with ticket component and service 2026-07-21 14:12:23 -03:00
54826a7f8a feat(ticket): add reusable ticket component with actions and styles 2026-07-21 13:37:31 -03:00
8f57af3cf5 feat(purchase-item): enhance layout for items without images and add description handling 2026-07-21 12:21:26 -03:00
da6614a9f8 feat(auth): update session handling on 401 responses and refactor error propagation 2026-07-21 10:36:48 -03:00
8e44fa5a46 feat(purchase-detail): update item details structure and handle null values in mapping 2026-07-21 10:22:32 -03:00
938d15bf83 feat(checkout): implement QR payment polling and validation logic 2026-07-21 09:52:19 -03:00
dafaa85480 feat(cart): implement add to cart functionality and display empty cart message 2026-07-20 17:11:06 -03:00
98e899c5bf feat(product-list): enhance layout handling with responsive design and computed properties 2026-07-20 16:59:57 -03:00
0c0d2b559d feat(catalog): refactor product and variant interfaces for improved structure and clarity 2026-07-20 16:46:17 -03:00
a233a93d16 feat(store-home-page): add margin styling for adjacent store sections 2026-07-20 16:24:24 -03:00
b0e97fc577 feat(store-home-page): refactor to use catalog structure and update product loading logic 2026-07-20 16:08:18 -03:00
5165ea0716 feat(product-list): implement pagination and update items to use paginated response 2026-07-20 14:39:12 -03:00
c0eb3369c5 feat(product-list): replace ProductCard with ProductColumnWithImage and update related components 2026-07-20 14:27:24 -03:00
625990f594 feat(product-vertical-with-cart-card): add reusable vertical product card component with cart functionality 2026-07-17 14:14:05 -03:00
d4c671dc7a refactor(product-row-card): move variant selection logic into constructor for better clarity 2026-07-17 13:47:40 -03:00
944c12a7f3 feat(product-row-card): update variant selection logic to default to first variant if none selected 2026-07-17 13:45:36 -03:00
9a4e2a03e9 feat(product-card): update title color for better visibility and readability
feat(quantity-selector): change background to inherit for consistent styling
2026-07-17 13:45:16 -03:00
40cc2b840d feat(product-row-card): add reusable product row card component with styling and functionality 2026-07-17 11:54:12 -03:00
45c2ee7a2f feat(store-header): update brand slot dimensions for improved layout 2026-07-16 16:07:41 -03:00
b64c627702 feat(checkout): update payment intent payload to use 'transfer_payer_dni' and remove placeholder from DNI input 2026-07-16 11:38:10 -03:00
fc425543ee feat(checkout): add DNI input handling for transfer payments and update related components 2026-07-16 11:28:33 -03:00
36045656e7 feat(checkout): enhance transfer payment flow with DNI input handling and completion action 2026-07-16 11:18:26 -03:00
ada832cbba feat(checkout): update button width and background color for payment components 2026-07-16 11:02:30 -03:00
524eea38f4 feat(checkout): enhance transfer payment flow with DNI input and validation 2026-07-16 10:55:28 -03:00
f163e8a353 feat(checkout): refactor payment step components for QR, transfer, and TelePagos methods 2026-07-16 10:21:59 -03:00
8b785fd631 feat(cart): refactor cart item structure and update related components for consistency 2026-07-16 10:02:24 -03:00
e4195a8567 feat(product-detail): enhance product variant handling with inventory policies and update UI components 2026-07-15 14:33:39 -03:00
25085f6f85 feat(hero-banner): update event icons and add gradient overlay to hero banner 2026-07-14 14:30:01 -03:00
257e4fbeb8 feat(hero-banner): add HeroBanner component with dynamic hero and event configurations 2026-07-14 14:03:51 -03:00
cc87169c1c feat(menu-guard): implement hasMenuGuard for route access control based on tenant menu permissions 2026-07-14 12:22:26 -03:00
e383a8f159 feat(tenant): add HeroConfig, EventConfig, and Menu interfaces to enhance tenant structure 2026-07-14 12:17:40 -03:00
993aa5823a feat(product-detail): implement resolver for product details and update component logic for improved error handling
feat(product-carousel): enhance image loading with optimized attributes and improve layout consistency
style: refactor HTML structure for better readability and maintainability
test(product-detail): add unit tests for product detail resolver and component behavior
2026-07-08 16:12:35 -03:00
6c00a3e2bb feat(store-home): implement resolver for product data and enhance component logic for improved error handling and loading states 2026-07-08 16:04:11 -03:00
efa0d48a40 feat(purchase-item): enhance attributes display for quantity-only items 2026-07-08 15:42:09 -03:00
9fcbf34573 feat(purchase): enhance purchase detail and summary interfaces, update purchase item component for improved data handling 2026-07-08 15:31:37 -03:00
451dc58cd7 style: update cart item layout and typography for improved consistency and readability 2026-07-08 14:27:01 -03:00
26466f57db style: update page title color to #A0A0A0 for consistency across components 2026-07-08 14:16:06 -03:00
a9dc55c274 feat(purchase-detail): implement purchase item component with dynamic attributes and styling 2026-07-08 14:09:51 -03:00
5e9a5917fd feat(purchase-detail): restructure cart item layout for improved readability and alignment 2026-07-08 13:47:23 -03:00
e06edaaa42 feat(purchase-detail): add purchase detail page with routing and mock data 2026-07-08 13:40:29 -03:00
1f2ca09b81 style: update font sizes for labels and page titles across multiple components 2026-07-08 13:16:50 -03:00
56355569f2 fix: correct spelling of "contraseña" in various components and templates 2026-07-08 12:26:46 -03:00
036b2a950f feat(profile): enhance profile form with validation messages and submission logic 2026-07-08 12:12:22 -03:00
7507580c2c feat(profile): add updateProfile method and enhance password validation in register form 2026-07-08 12:06:36 -03:00
b016569b10 feat(purchase-list): implement loading skeleton and enhance purchase list fetching logic 2026-07-08 11:31:52 -03:00
23f48ccec0 style(account-sidebar): update active nav link color to use CSS variable for consistency 2026-07-08 11:20:17 -03:00
6a5683f6b7 feat(purchase-list): refactor purchase list item structure and styles for improved layout 2026-07-08 10:12:20 -03:00
b7ed16966c feat(profile): update profile form layout and functionality, enhance styles for better responsiveness 2026-07-08 09:34:24 -03:00
baf5e2d4e7 feat(layout): enhance layout structure by adding padding and container classes 2026-07-08 09:14:33 -03:00
64ade4d595 feat(profile-form): refactor form-group styles and update error handling in checkout form 2026-07-08 09:05:15 -03:00
b9b8a70ca1 style(profile-form): adjust label styles and remove gap in form group 2026-07-08 08:52:04 -03:00
28ffe3c788 feat(user-dropdown): convert buttons to links for account and purchases navigation 2026-07-08 08:44:07 -03:00
4969026fe9 feat(purchase-list-item): create purchase list item component with styles and template 2026-07-08 08:39:57 -03:00
09d88e6324 feat(account-page): implement account layout with sidebar, profile form, and purchase list components 2026-07-08 08:39:52 -03:00
1a2d303b4e feat(account-page): implement account layout, profile, and purchases pages with corresponding components 2026-07-08 08:36:17 -03:00
3579abdecb refactor(user-dropdown): remove registration functionality and simplify user dropdown structure 2026-07-08 03:03:56 +00:00
f6ac1b8727 feat(user-dropdown): implement user dropdown component with authentication actions and integrate with store header 2026-07-08 02:52:46 +00:00
eb73398397 refactor(qrcode): replace angularx-qrcode with custom QRCodeComponent and update dependencies 2026-07-08 02:21:17 +00:00
1af478d65e refactor(button): update click handling and aria attributes for better accessibility 2026-07-07 17:04:07 -03:00
7e8a638d01 refactor(checkout): update button sizes for consistent layout across checkout steps 2026-07-07 17:00:26 -03:00
639004c8c6 refactor(checkout): update layout and styles for checkout actions and payment panel 2026-07-07 16:57:40 -03:00
17ef39e9a8 refactor(checkout): remove border and padding from cart column for cleaner layout 2026-07-07 16:44:07 -03:00
fff18e05c0 refactor(cart): remove CartCopiaComponent and related references from test page 2026-07-07 16:42:27 -03:00
c9d0e5ad01 refactor(cart): remove cart showcase component and related functionality 2026-07-07 16:40:04 -03:00
4885d39284 feat(cart): enhance cart item layout and styles for improved design consistency 2026-07-07 16:37:06 -03:00
9d42de9fa8 feat(cart-copia): enhance cart design with improved button styles and layout 2026-07-07 16:06:10 -03:00
bdb7f5e907 feat(cart-copia): implement cart showcase component for isolated cart redesign 2026-07-07 16:03:22 -03:00
e8d71bfd03 feat(checkout): refactor purchase completion flow and clear cart state 2026-07-07 15:36:01 -03:00
05ada5f613 feat(purchase-status): update warning and error styles for improved visibility 2026-07-07 12:51:37 -03:00
5969818478 feat(login): implement full page reload on successful login and refactor navigation logic 2026-07-07 12:51:17 -03:00
c54eb105cb feat(purchase-status): enhance purchase status handling with polling and improved UI feedback 2026-07-07 12:51:01 -03:00
e2605116c5 feat(purchase-status): remove mock data and implement actual purchase status fetching 2026-07-07 12:16:41 -03:00
d1e20460ac feat(purchase-status): enhance purchase status page with improved layout and loading states
feat(styles): unify color variables for better theming consistency
style(simple-layout): adjust card border radius for a cleaner design
2026-07-07 11:55:43 -03:00
910f367851 feat(purchase-status): implement purchase status page with loading and error handling 2026-07-07 11:21:27 -03:00
cb0d1e4d77 feat(layout): replace AuthLayout with SimpleLayout for login and registration routes 2026-07-07 10:58:51 -03:00
850f93e2ba feat(header): update brand slot to use anchor tag for navigation and include RouterLink 2026-07-07 08:21:42 -03:00
53278719a8 feat(cart): add loading state management for cart item operations and update payment intent handling 2026-07-06 15:28:39 -03:00
8ceef31785 feat(checkout): implement ngOnInit lifecycle hook to load cart data 2026-07-06 15:04:09 -03:00
a642ae84d5 feat(checkout): enhance checkout form with user data pre-filling and disable fields 2026-07-06 14:37:20 -03:00
09fa876da3 feat(checkout): define CreatePurchasePayload interface and update createPurchase method to use it 2026-07-06 14:22:12 -03:00
69fee4e20a feat(checkout): implement purchase creation and payment intent generation with QR code support 2026-07-06 11:26:39 -03:00
1df6964859 feat(auth): refactor authentication to use cookies for token management and add CookieService 2026-07-03 14:16:22 -03:00
3971970975 feat(checkout): implement authentication guard for checkout route and add bank account service 2026-07-03 14:07:57 -03:00
0a2d999c00 feat(checkout): adjust button styles and layout for improved usability 2026-07-03 12:03:57 -03:00
37d94159aa feat(checkout): update styles for title and labels in payment step for improved readability 2026-07-03 11:52:11 -03:00
361596bbbc feat(checkout): update styles for payment card and text elements for improved readability 2026-07-03 11:44:29 -03:00
97a359d65c feat(store-layout): add checkout redirection on buy button click and update button functionality 2026-07-03 11:33:37 -03:00
811f127deb feat: implement checkout data and payment steps as standalone components
- Add `CheckoutDataStepComponent` for handling user data input with reactive forms.
- Create `CheckoutPaymentStepComponent` for managing payment method selection and display.
- Refactor `CheckoutPageComponent` to utilize the new components, improving modularity and readability.
- Introduce SCSS styles for both new components, ensuring consistent design with existing styles.
- Update HTML structure in `checkout-page.component.html` to integrate the new components.
- Define models for checkout forms and payment methods in `checkout-page.models.ts`.
2026-07-03 11:31:09 -03:00
bfbbfb9c09 feat(checkout): enhance checkout page with payment methods and form validation 2026-07-03 11:27:53 -03:00
41bb5e7c9e feat(search): update search component width for better responsiveness 2026-07-03 11:07:45 -03:00
be041f6e88 feat(search): update search input styles for improved focus visibility 2026-07-03 10:57:59 -03:00
45f0618ae9 feat(checkout): implement checkout page with form validation and routing 2026-07-03 10:54:43 -03:00
2b3713f6a8 feat(stepper): refactor circle indicator to improve accessibility and styling 2026-07-03 10:24:54 -03:00
510c72624b feat(stepper): implement reusable stepper component with navigation and validation 2026-07-03 10:10:53 -03:00
625e2fea6c feat(register): integrate modal and toast services for success and error handling 2026-07-03 09:05:56 -03:00
3697c4b97c feat(modal): add simple modal component with open and close functionality 2026-07-03 09:00:49 -03:00
bedb74ebd5 feat(cart): implement optimistic quantity updates with rollback on error 2026-07-03 08:56:54 -03:00
6bab2e9ab2 feat(store-header, store-layout): add user click event handling and navigation to login 2026-07-02 17:03:07 -03:00
bd4b86fe85 refactor(login, register): adjust header margins and improve error message display 2026-07-02 16:51:41 -03:00
6ffffc6ea8 feat(auth): implement authentication service, guards, and interceptors
- Add AuthService for handling login, registration, and session management.
- Create auth guards to protect routes based on authentication status.
- Implement auth interceptor to attach JWT token to HTTP requests and handle 401 errors.
- Add unit tests for AuthService, guards, and interceptor.
- Create login and registration components with form validation and error handling.
- Update routing to include guards for login and registration pages.
2026-07-02 16:37:06 -03:00
57f58c92be feat(register): add register page with routing and form implementation 2026-07-02 15:57:54 -03:00
5365811a50 refactor(login): unify title styling by replacing class with shared auth-title 2026-07-02 15:50:26 -03:00
3c98600193 feat(login): implement auth layout and restructure login page with improved styling and structure 2026-07-02 15:45:26 -03:00
ff3c3233c1 fix(login): correct spelling of "Contraseña" in login page 2026-07-02 15:36:14 -03:00
20e8578440 feat(login): add disabled state to continue with Google button 2026-07-02 15:33:42 -03:00
193ba7472b fix(login): correct spelling errors in login page text 2026-07-02 15:33:04 -03:00
bc9390394a feat(login): replace Google icon with SVG for improved scalability and styling 2026-07-02 15:32:26 -03:00
4393280fd9 feat(login): enhance login page layout with improved divider styling 2026-07-02 15:28:24 -03:00
956411254e feat(login): update button styles and improve layout on login page 2026-07-02 15:28:19 -03:00
4cbf87feb1 feat(login): update login page styles and integrate neutral button variant 2026-07-02 15:25:43 -03:00
93d49fc7e6 feat(login): redesign login page layout and integrate button component 2026-07-02 15:20:07 -03:00
85465ce7e1 feat(login): add login page component with template and styles 2026-07-02 15:14:08 -03:00
ee83e7ab32 feat(modal): refactor confirm delete modal handling and improve test coverage 2026-07-02 12:19:18 -03:00
264 changed files with 19473 additions and 1445 deletions

View File

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

323
package-lock.json generated
View File

@@ -19,6 +19,8 @@
"@fortawesome/fontawesome-free": "^7.2.0",
"bootstrap": "^5.3.8",
"express": "^5.1.0",
"leaflet": "^1.9.4",
"qrcode": "1.5.4",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
},
@@ -27,6 +29,7 @@
"@angular/cli": "^22.0.4",
"@angular/compiler-cli": "^22.0.0",
"@types/express": "^5.0.1",
"@types/leaflet": "^1.9.21",
"@types/node": "^20.17.19",
"jsdom": "^28.0.0",
"prettier": "^3.8.1",
@@ -3815,6 +3818,13 @@
"@types/send": "*"
}
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/http-errors": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
@@ -3822,6 +3832,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/leaflet": {
"version": "1.9.21",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/node": {
"version": "20.19.43",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
@@ -4394,6 +4414,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001799",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz",
@@ -4578,6 +4607,24 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/content-disposition": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
@@ -4759,6 +4806,15 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
@@ -4786,6 +4842,12 @@
"node": ">=8"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
@@ -5237,6 +5299,19 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -5306,7 +5381,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
@@ -5881,6 +5955,12 @@
],
"license": "MIT"
},
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
},
"node_modules/listr2": {
"version": "10.2.1",
"resolved": "https://registry.npmjs.org/listr2/-/listr2-10.2.1.tgz",
@@ -5927,6 +6007,18 @@
"@lmdb/lmdb-win32-x64": "3.5.4"
}
},
"node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/log-symbols": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz",
@@ -6703,6 +6795,33 @@
"license": "MIT",
"optional": true
},
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-map": {
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz",
@@ -6716,6 +6835,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/pacote": {
"version": "21.5.1",
"resolved": "https://registry.npmjs.org/pacote/-/pacote-21.5.1.tgz",
@@ -6824,6 +6952,15 @@
"node": ">= 0.8"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -6921,6 +7058,15 @@
"node": ">=16.20.0"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/postcss": {
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
@@ -7033,6 +7179,154 @@
"node": ">=6"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qrcode/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/qrcode/node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/qrcode/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/qrcode/node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/qrcode/node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/qs": {
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
@@ -7093,6 +7387,15 @@
"dev": true,
"license": "Apache-2.0"
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -7103,6 +7406,12 @@
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/restore-cursor": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz",
@@ -7325,6 +7634,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -8690,6 +9005,12 @@
"node": ">= 8"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

View File

@@ -1,17 +1,36 @@
import { provideHttpClient, withFetch } from '@angular/common/http';
import { ApplicationConfig, provideAppInitializer, provideBrowserGlobalErrorListeners } from '@angular/core';
import { HttpRequest, provideHttpClient, withFetch, withInterceptors } from '@angular/common/http';
import {
ApplicationConfig,
provideAppInitializer,
provideBrowserGlobalErrorListeners,
} from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideClientHydration } from '@angular/platform-browser';
import { provideClientHydration, withHttpTransferCacheOptions } from '@angular/platform-browser';
import { routes } from './app.routes';
import { authBootstrap } from './core/services/auth/auth-bootstrap';
import { authInterceptor } from './core/services/auth/auth.interceptor';
import { tenantBootstrap } from './core/services/tenant-bootstrap';
function isStoreCatalogRequest(request: HttpRequest<unknown>): boolean {
return (
request.method === 'GET' &&
/\/api\/tenants\/[^/]+\/productos(?:\/\d+)?(?:\?|$)/.test(request.urlWithParams)
);
}
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
provideClientHydration(),
provideHttpClient(withFetch()),
provideAppInitializer(tenantBootstrap)
]
provideClientHydration(
withHttpTransferCacheOptions({
includeRequestsWithAuthHeaders: true,
filter: isStoreCatalogRequest,
}),
),
provideHttpClient(withFetch(), withInterceptors([authInterceptor])),
provideAppInitializer(authBootstrap),
provideAppInitializer(tenantBootstrap),
],
};

View File

@@ -1,14 +1,15 @@
import { signal } from '@angular/core';
import { computed, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { provideRouter, Router } from '@angular/router';
import { of } from 'rxjs';
import { App } from './app';
import { routes } from './app.routes';
import { AuthService } from './core/services/auth/auth.service';
import { CartService } from './core/services/cart/cart.service';
import { Tenant } from './core/services/tenant.interface';
import { TenantService } from './core/services/tenant.service';
import { CartService } from './core/services/cart/cart.service';
import { routes } from './app.routes';
const tenant: Tenant = {
id: 1,
@@ -22,7 +23,8 @@ const tenant: Tenant = {
header_bg_color: '#313131',
footer_bg_color: '#313131',
header_logo: 'https://example.com/header.png',
footer_logo: 'https://example.com/footer.png'
footer_logo: 'https://example.com/footer.png',
categories: []
};
function createTenantServiceStub(
@@ -47,9 +49,33 @@ function createTenantServiceStub(
};
}
function createAuthServiceStub(isAuthenticated = false) {
const tokenState = signal<string | null>(isAuthenticated ? 'test-token' : null);
const userState = signal(
isAuthenticated ? { id: 1, nombre_apellido: 'Ada Lovelace', email: 'ada@example.com' } : null
);
return {
user: userState.asReadonly(),
token: tokenState.asReadonly(),
isAuthenticated: computed(() => tokenState() !== null),
login: vi.fn(),
register: vi.fn(),
logout: vi.fn(),
loadCurrentUser: vi.fn(),
hydrateSession: vi.fn(),
bootstrap: vi.fn().mockResolvedValue(undefined),
clearSession: vi.fn(() => {
userState.set(null);
tokenState.set(null);
})
};
}
async function renderAppAt(
url: string,
tenantService: ReturnType<typeof createTenantServiceStub> = createTenantServiceStub()
tenantService: ReturnType<typeof createTenantServiceStub> = createTenantServiceStub(),
authService: ReturnType<typeof createAuthServiceStub> = createAuthServiceStub()
) {
TestBed.resetTestingModule();
@@ -67,6 +93,10 @@ async function renderAppAt(
cart: signal(null).asReadonly(),
loadCart: () => of({ id: 1, items: [], subtotal: '0' })
}
},
{
provide: AuthService,
useValue: authService
}
]
}).compileComponents();
@@ -106,14 +136,112 @@ describe('app routes', () => {
expect(compiled.textContent).toContain('Componentes reutilizables');
});
it('loads the login page at /login and shows the create account CTA', async () => {
const { fixture, router } = await renderAppAt('/login');
const compiled = fixture.nativeElement as HTMLElement;
expect(router.url).toBe('/login');
expect(compiled.querySelector('app-login-page')).not.toBeNull();
expect(compiled.textContent).toContain('Iniciar sesion');
expect(compiled.textContent).toContain('Crear cuenta');
});
it('loads the register page at /register and renders the shared auth fields', async () => {
const { fixture, router } = await renderAppAt('/register');
const compiled = fixture.nativeElement as HTMLElement;
const registerPage = compiled.querySelector('app-register-page');
const placeholders = Array.from(registerPage?.querySelectorAll('input') ?? []).map((input) =>
input.getAttribute('placeholder')
);
expect(router.url).toBe('/register');
expect(registerPage).not.toBeNull();
expect(compiled.textContent).toContain('CREAR CUENTA');
expect(compiled.textContent).toContain('Volver');
expect(placeholders).toEqual([
'Nombre y Apellido',
'Email',
'Contraseña',
'Repetir Contraseña'
]);
});
it('loads the recover password page inside the simple layout', async () => {
const { fixture, router } = await renderAppAt('/recuperar-contrasena');
const compiled = fixture.nativeElement as HTMLElement;
const recoverPasswordPage = compiled.querySelector('app-recover-password-page');
expect(router.url).toBe('/recuperar-contrasena');
expect(compiled.querySelector('.simple-layout')).not.toBeNull();
expect(recoverPasswordPage).not.toBeNull();
expect(recoverPasswordPage?.textContent).toContain('Recuperar contraseña');
expect(recoverPasswordPage?.querySelector('input')?.getAttribute('placeholder')).toBe('Email');
expect(recoverPasswordPage?.textContent).toContain('Recuperar acceso');
expect(recoverPasswordPage?.textContent).toContain('Volver');
});
it('loads the recovery code page and shows the entered email', async () => {
const { fixture, router } = await renderAppAt(
'/recuperar-contrasena/codigo?email=ada%40example.com'
);
const compiled = fixture.nativeElement as HTMLElement;
const codePage = compiled.querySelector('app-recover-password-code-page');
expect(router.url).toBe('/recuperar-contrasena/codigo?email=ada%40example.com');
expect(compiled.querySelector('.simple-layout')).not.toBeNull();
expect(codePage).not.toBeNull();
expect(codePage?.textContent).toContain('ada@example.com');
expect(codePage?.querySelectorAll('app-input')).toHaveLength(4);
expect(codePage?.textContent).toContain('Validar');
});
it('loads the reset password page with the shared password inputs', async () => {
const { fixture, router } = await renderAppAt(
'/recuperar-contrasena/restablecer?email=ada%40example.com&code=1234'
);
const compiled = fixture.nativeElement as HTMLElement;
const resetPage = compiled.querySelector('app-reset-password-page');
const placeholders = Array.from(resetPage?.querySelectorAll('input') ?? []).map((input) =>
input.getAttribute('placeholder')
);
expect(router.url).toBe(
'/recuperar-contrasena/restablecer?email=ada%40example.com&code=1234'
);
expect(compiled.querySelector('.simple-layout')).not.toBeNull();
expect(resetPage).not.toBeNull();
expect(resetPage?.textContent).toContain('Restablecer contraseña');
expect(placeholders).toEqual(['Nueva Contraseña', 'Repetir Nueva Contraseña']);
expect(resetPage?.textContent).toContain('Guardar');
});
it('redirects authenticated users away from /login', async () => {
const { router } = await renderAppAt('/login', createTenantServiceStub(), createAuthServiceStub(true));
expect(router.url).toBe('/');
});
it('renders the tenant not found screen for any route when the tenant is missing', async () => {
const { fixture } = await renderAppAt(
'/componentes-test/reutilizables',
createTenantServiceStub('not-found', null)
createTenantServiceStub('not-found', null),
createAuthServiceStub()
);
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('.tenant-status')).not.toBeNull();
expect(compiled.textContent).toContain('No encontramos una tienda para este dominio');
});
it('redirects unauthenticated users from /checkout to /login', async () => {
const { router } = await renderAppAt('/checkout', createTenantServiceStub(), createAuthServiceStub(false));
expect(router.url).toBe('/login?returnUrl=%2Fcheckout');
});
it('allows authenticated users to access /checkout', async () => {
const { router } = await renderAppAt('/checkout', createTenantServiceStub(), createAuthServiceStub(true));
expect(router.url).toBe('/checkout');
});
});

View File

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

View File

@@ -31,12 +31,22 @@ function hexToRgb(hex: string): string {
'[style.--tenant-secondary]': 'tenantSecondaryColor()',
'[style.--tenant-danger]': 'tenantDangerColor()',
'[style.--tenant-success]': 'tenantSuccessColor()',
'[style.--color-primary]': 'tenantPrimaryColor()',
'[style.--color-secondary]': 'tenantSecondaryColor()',
'[style.--color-danger]': 'tenantDangerColor()',
'[style.--color-success]': 'tenantSuccessColor()',
'[style.--tenant-primary-rgb]': 'tenantPrimaryColorRgb()',
'[style.--tenant-secondary-rgb]': 'tenantSecondaryColorRgb()',
'[style.--tenant-danger-rgb]': 'tenantDangerColorRgb()',
'[style.--tenant-success-rgb]': 'tenantSuccessColorRgb()',
'[style.--color-primary-rgb]': 'tenantPrimaryColorRgb()',
'[style.--color-secondary-rgb]': 'tenantSecondaryColorRgb()',
'[style.--color-danger-rgb]': 'tenantDangerColorRgb()',
'[style.--color-success-rgb]': 'tenantSuccessColorRgb()',
'[style.--tenant-header-bg]': 'tenantHeaderBgColor()',
'[style.--tenant-footer-bg]': 'tenantFooterBgColor()'
'[style.--tenant-footer-bg]': 'tenantFooterBgColor()',
'[style.--color-header-bg]': 'tenantHeaderBgColor()',
'[style.--color-footer-bg]': 'tenantFooterBgColor()'
}
})
export class App {
@@ -78,5 +88,4 @@ export class App {
this.tenantService.tenant()?.footer_bg_color ??
DEFAULT_TENANT_BRANDING.footerBgColor
);
}
}

View File

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

View File

@@ -0,0 +1,13 @@
<section class="simple-layout">
<div class="simple-layout__container container-xl">
<div class="simple-layout__row row justify-content-center">
<div class="simple-layout__column col-12 col-md-9 col-lg-7 col-xl-5">
<article class="simple-layout__card card border-0 shadow-lg">
<div class="simple-layout__body card-body">
<router-outlet />
</div>
</article>
</div>
</div>
</div>
</section>

View File

@@ -0,0 +1,50 @@
:host {
display: block;
}
.simple-layout {
padding: 1.5rem 0 2rem;
}
.simple-layout__container {
padding-inline: 1rem;
}
.simple-layout__row {
padding-block: 1rem;
}
.simple-layout__card {
border-radius: 5px;
overflow: hidden;
}
.simple-layout__body {
padding: 2rem 1.5rem;
}
:host ::ng-deep .auth-title {
margin: 0;
color: #666666;
font-size: 17px;
font-weight: 700;
text-transform: uppercase;
}
@media (min-width: 768px) {
.simple-layout {
padding: 3rem 0;
}
.simple-layout__container {
padding-inline: 1.5rem;
}
.simple-layout__row {
padding-block: 1.5rem;
}
.simple-layout__body {
padding: 3rem;
}
}

View File

@@ -0,0 +1,11 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
@Component({
selector: 'app-simple-layout',
imports: [RouterOutlet],
templateUrl: './simple-layout.component.html',
styleUrl: './simple-layout.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class SimpleLayoutComponent {}

View File

@@ -1,7 +1,7 @@
<footer class="store-layout__footer mt-auto text-light">
<div class="container-xl px-3 px-md-4 py-4 py-md-5">
<div class="row g-4">
<div class="col-12 col-md-6 col-xl-5 d-grid gap-3 align-content-start">
<div class="col-12">
<div
class="store-layout__brand-slot d-inline-flex align-items-center gap-3"
data-testid="store-footer-brand-slot"
@@ -15,13 +15,15 @@
</div>
}
</div>
</div>
<div class="col-12 col-md-6 col-xl-5 d-grid gap-3 align-content-start">
<div class="d-grid gap-2">
<div class="d-flex align-items-center gap-2 text-light-emphasis">
<div class="d-flex align-items-center gap-2 ">
<i class="fa-solid fa-location-dot" aria-hidden="true"></i>
<span>Av. San Lorenzo 1542, Rosario</span>
</div>
<div class="d-flex align-items-center gap-2 text-light-emphasis">
<div class="d-flex align-items-center gap-2 ">
<i class="fa-solid fa-phone" aria-hidden="true"></i>
<span>54 9 (0341) 6658247</span>
</div>
@@ -31,24 +33,42 @@
</div>
@for (section of footerSections; track section.heading) {
<div class="col-12 col-md-6 col-xl-2 d-grid gap-3 align-content-start">
<h2 class="store-layout__footer-heading">{{ section.heading }}</h2>
<div class="col-12 col-md-6 col-xl-2 d-grid align-content-start">
<div class="d-grid gap-2">
@for (link of section.links; track link) {
<span class="store-layout__muted-text">{{ link }}</span>
@for (link of section.links; track link.label) {
@if (link.routerLink) {
<a
class="store-layout__footer-link"
[routerLink]="link.routerLink"
>
{{ link.label }}
</a>
} @else if (link.action === 'logout') {
<button
type="button"
class="store-layout__footer-link store-layout__footer-action"
(click)="logoutClick.emit()"
>
{{ link.label }}
</button>
} @else {
<span class="store-layout__muted-text">{{ link.label }}</span>
}
}
</div>
</div>
}
<div class="col-12 col-md-6 col-xl-3 d-grid gap-3 align-content-start">
<h2 class="store-layout__footer-heading">Seguinos:</h2>
<div class="col-12 col-md-6 col-xl-3 d-grid align-content-start">
<div class="d-flex align-items-center gap-3">
@for (social of socialLinks; track social.label) {
<span class="fs-5 text-white">
<i [class]="social.iconClass" aria-hidden="true"></i>
<span class="visually-hidden">{{ social.label }}</span>
</span>
@for (social of socialMedia; track social.code) {
<app-social-media
class="text-white"
[code]="social.code"
[icon]="social.icon"
[name]="social.name"
[url]="social.url"
/>
}
</div>
</div>

View File

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

View File

@@ -1,24 +1,29 @@
import { Component, Input } from '@angular/core';
import { Component, Input, output } from '@angular/core';
import { RouterLink } from '@angular/router';
import { SocialMedia } from '../../../services/tenant.interface';
import { SocialMediaComponent } from '../../../../shared/components/social-media/social-media.component';
export type StoreFooterLink = {
label: string;
routerLink?: string;
action?: 'logout';
};
export type StoreFooterSection = {
heading: string;
links: string[];
};
export type StoreSocialLink = {
label: string;
iconClass: string;
links: StoreFooterLink[];
};
@Component({
selector: 'app-store-footer',
imports: [],
imports: [RouterLink, SocialMediaComponent],
templateUrl: './store-footer.component.html',
styleUrl: './store-footer.component.scss'
styleUrl: './store-footer.component.scss',
})
export class StoreFooterComponent {
@Input({ required: true }) currentYear = new Date().getFullYear();
@Input({ required: true }) footerSections: StoreFooterSection[] = [];
@Input({ required: true }) socialLinks: StoreSocialLink[] = [];
@Input({ required: true }) socialMedia: SocialMedia[] = [];
@Input() logoUrl: string | null = null;
readonly logoutClick = output<void>();
}

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,11 @@
<header class="store-layout__header">
<div class="store-layout__header-main">
<div class="container-xl px-3 px-md-4 py-3 py-lg-4 d-flex flex-row flex-wrap align-items-center justify-content-between gap-3">
<div
class="store-layout__brand-slot d-inline-flex align-items-center gap-3 flex-shrink-0"
<div
class="container-xl px-3 px-md-4 py-3 py-lg-4 d-flex flex-row flex-wrap align-items-center justify-content-between gap-3"
>
<a
routerLink="/"
class="store-layout__brand-slot d-inline-flex align-items-center gap-3 flex-shrink-0 text-decoration-none"
data-testid="store-header-brand-slot"
aria-label="Logo del comercio"
>
@@ -13,53 +16,111 @@
<span class="store-layout__brand-logo-skeleton"></span>
</div>
}
</div>
</a>
<div class="d-flex flex-row align-items-center gap-3 ms-auto flex-nowrap w-100 justify-content-between justify-content-md-end">
<div class="input-group store-layout__search flex-grow-1" 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"
/>
<button
type="button"
class="btn store-layout__search-button border-start-0 rounded-end px-3"
aria-label="Buscar"
>
<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
</button>
<div
class="d-flex flex-row align-items-center gap-3 ms-auto flex-nowrap flex-grow-1 justify-content-between justify-content-md-end"
>
<div class="store-layout__search-wrapper">
<div class="input-group store-layout__search" role="search">
<label class="visually-hidden" for="store-search-input">Buscar productos</label>
<input
id="store-search-input"
type="search"
class="form-control store-layout__search-input border-end-0 rounded-start"
placeholder="Buscar productos"
autocomplete="off"
[attr.aria-describedby]="showSearchError() ? 'store-search-error' : null"
[attr.aria-invalid]="showSearchError()"
[minlength]="minSearchLength"
[formControl]="searchControl"
(input)="clearSearchError()"
(keydown.enter)="submitSearch($event)"
/>
<button
type="button"
class="btn store-layout__search-button border-start-0 rounded-end px-3"
aria-label="Buscar"
(click)="submitSearch()"
>
<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
</button>
</div>
@if (showSearchError()) {
<p id="store-search-error" class="store-layout__search-error mb-0" role="alert">
Ingresá al menos {{ minSearchLength }} caracteres para buscar.
</p>
}
</div>
<div class="d-flex align-items-center justify-content-end gap-1" aria-label="Acciones de usuario">
@if (ticketsMenu(); as menu) {
<app-button
variant="primary"
hostClass="flex-shrink-0"
data-testid="store-header-tickets"
(click)="ticketsClick.emit()"
>
{{ menu.label }}
</app-button>
}
<div
class="d-flex align-items-center justify-content-end gap-1"
aria-label="Acciones de usuario"
>
<app-cart-icon
[quantity]="cartQuantity()"
ariaLabel="Carrito de compras"
title="Carrito"
(click)="cartClick.emit()"
/>
<app-icon-button
variant="user"
ariaLabel="Mi cuenta"
title="Mi cuenta"
/>
<div class="store-layout__user-menu">
<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>
</div>
</div>
</div>
<div>
<div class="store-layout__categories">
<div class="container-xl px-3 px-md-4">
<button
type="button"
class="btn btn-link d-inline-flex align-items-center gap-2 px-0 py-2 text-uppercase fw-bold text-decoration-none store-layout__category-trigger"
aria-label="Categorias"
>
<span>Categorias</span>
<i class="fa-solid fa-chevron-down" aria-hidden="true"></i>
</button>
<div class="store-layout__category-menu">
<button
type="button"
class="btn btn-link d-inline-flex align-items-center gap-2 px-0 py-2 text-uppercase fw-bold text-decoration-none store-layout__category-trigger"
aria-label="Categorias"
aria-controls="store-category-dropdown"
[attr.aria-expanded]="isCategoryDropdownOpen()"
(click)="toggleCategoryDropdown()"
>
<span>Categorias</span>
<i class="fa-solid fa-chevron-down" aria-hidden="true"></i>
</button>
<app-category-dropdown
id="store-category-dropdown"
[categories]="categories()"
[open]="isCategoryDropdownOpen()"
(categorySelect)="onCategorySelect($event)"
/>
</div>
</div>
</div>
</header>

View File

@@ -9,7 +9,8 @@
}
.store-layout__brand-slot {
min-width: 10rem;
min-width: 150px;
max-width:40px;
}
.store-layout__brand-logo-shell {
@@ -40,11 +41,22 @@
animation: store-header-logo-skeleton 1.2s ease-in-out infinite;
}
.store-layout__search {
width: min(100%, 24rem);
.store-layout__search-wrapper {
width: min(100%, 260px);
min-width: 0;
}
.store-layout__search {
width: 100%;
}
.store-layout__search-error {
margin-top: 0.25rem;
font-size: 0.75rem;
line-height: 1.2;
color: var(--tenant-danger, #dc3545);
}
.store-layout__search-input {
color: #666666;
border-color: #cccccc;
@@ -56,8 +68,9 @@
}
.store-layout__search-input:focus {
border-color: var(--tenant-primary);
box-shadow: 0 0 0 0.25rem color-mix(in srgb, white 70%, var(--tenant-primary));
border-color: #cccccc;
box-shadow: none;
outline: none;
}
.store-layout__search-button {
@@ -82,6 +95,11 @@
color: #8a8a8a;
}
.store-layout__user-menu {
position: relative;
display: inline-flex;
}
.store-layout__category-trigger {
font-size: 0.8rem;
letter-spacing: 0.02em;
@@ -93,6 +111,19 @@
color: #8a8a8a;
}
.store-layout__category-menu {
position: relative;
display: inline-flex;
flex-direction: column;
}
.store-layout__category-menu app-category-dropdown {
position: absolute;
z-index: 1050;
top: 100%;
left: 0;
}
@keyframes store-header-logo-skeleton {
from {
background-position: 200% 0;

View File

@@ -1,15 +1,114 @@
import { Component, input, output } from '@angular/core';
import { Component, ElementRef, HostListener, inject, input, output, signal } from '@angular/core';
import { RouterLink } from '@angular/router';
import { AuthUser } from '../../../services/auth/auth.interfaces';
import { CartIconComponent } from '../../../../shared/components/cart-icon/cart-icon.component';
import { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.component';
import { UserDropdownComponent } from './user-dropdown/user-dropdown.component';
import { Menu } from '../../../services/tenant.interface';
import { ButtonComponent } from '../../../../shared/components/button/button.component';
import { ReactiveFormsModule, FormControl } from '@angular/forms';
import { Category } from '../../../services/tenant.interface';
import { CategoryDropdownComponent } from './category-dropdown/category-dropdown.component';
@Component({
selector: 'app-store-header',
imports: [CartIconComponent, IconButtonComponent],
imports: [
ButtonComponent,
CartIconComponent,
IconButtonComponent,
RouterLink,
ReactiveFormsModule,
UserDropdownComponent,
CategoryDropdownComponent,
],
templateUrl: './store-header.component.html',
styleUrl: './store-header.component.scss'
styleUrl: './store-header.component.scss',
})
export class StoreHeaderComponent {
private readonly elementRef = inject(ElementRef<HTMLElement>);
readonly logoUrl = input<string | null>(null);
readonly cartQuantity = input<number>(0);
readonly user = input<AuthUser | null>(null);
readonly isAuthenticated = input<boolean>(false);
readonly accountNavigationMenus = input<readonly Menu[]>([]);
readonly ticketsMenu = input<Menu | null>(null);
readonly categories = input<readonly Category[]>([]);
readonly cartClick = output<void>();
readonly ticketsClick = output<void>();
readonly loginClick = output<void>();
readonly logoutClick = output<void>();
readonly searchSubmit = output<string>();
readonly categorySelect = output<Category>();
protected readonly isUserDropdownOpen = signal(false);
protected readonly isCategoryDropdownOpen = signal(false);
protected readonly 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()) {
return;
}
const target = event.target;
if (target instanceof Node && !this.elementRef.nativeElement.contains(target)) {
this.isUserDropdownOpen.set(false);
this.isCategoryDropdownOpen.set(false);
}
}
@HostListener('document:keydown.escape')
protected onEscapeKey(): void {
this.isUserDropdownOpen.set(false);
this.isCategoryDropdownOpen.set(false);
}
protected toggleUserDropdown(): void {
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.categorySelect.emit(category);
}
}

View File

@@ -0,0 +1,27 @@
<section class="user-dropdown" role="menu" aria-label="Menu de usuario">
<div class="user-dropdown__profile">
<span class="user-dropdown__name">{{ displayName() }}</span>
</div>
<div class="user-dropdown__divider"></div>
@for (menu of accountNavigationMenus(); track menu.code) {
<a
[routerLink]="menu.route"
class="user-dropdown__item"
role="menuitem"
[attr.data-testid]="'user-dropdown-' + menu.code"
>
{{ menu.label }}
</a>
}
<button
type="button"
class="user-dropdown__item"
role="menuitem"
data-testid="user-dropdown-logout"
(click)="logoutClick.emit()"
>
Cerrar sesion
</button>
</section>

View File

@@ -0,0 +1,76 @@
:host {
position: absolute;
top: calc(100% + 0.75rem);
right: 0;
z-index: 1060;
display: block;
width: min(18rem, calc(100vw - 2rem));
}
.user-dropdown {
display: grid;
background: #ffffff;
border: 1px solid rgba(32, 32, 32, 0.1);
border-radius: 8px;
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.16);
padding: 0.75rem;
animation: user-dropdown-enter 0.16s ease-out;
}
.user-dropdown__profile {
padding: 0.25rem;
}
.user-dropdown__name {
color: #a0a0a0;
font-size: 13px;
font-weight: 325;
line-height: 1.2;
}
.user-dropdown__divider {
height: 1px;
margin: 0.6rem 0;
background: rgba(32, 32, 32, 0.08);
}
.user-dropdown__item {
display: block;
width: 100%;
border: 0;
border-radius: 6px;
padding: 0.65rem 0.6rem;
color: #8a8a8a;
font-size: 14px;
font-weight: 700;
text-align: left;
background: transparent;
cursor: pointer;
transition: color 0.15s ease;
text-decoration: none;
}
.user-dropdown__item:active,
.user-dropdown__item:hover,
.user-dropdown__item:focus-visible {
color: var(--tenant-primary);
outline: none;
}
@keyframes user-dropdown-enter {
from {
opacity: 0;
transform: translateY(-0.35rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 575.98px) {
:host {
right: -0.5rem;
}
}

View File

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

View File

@@ -2,11 +2,24 @@
<app-store-header
[cartQuantity]="cartQuantity()"
[logoUrl]="tenant()?.header_logo ?? null"
[user]="user()"
[isAuthenticated]="isAuthenticated()"
[accountNavigationMenus]="accountNavigationMenus()"
[ticketsMenu]="ticketsMenu() ?? null"
[categories]="tenant()?.categories ?? []"
(cartClick)="isCartOpen.set(!isCartOpen())"
(ticketsClick)="onTicketsClick()"
(loginClick)="onLoginClick()"
(logoutClick)="onLogoutClick()"
(searchSubmit)="onSearch($event)"
(categorySelect)="onCategorySelect($event)"
/>
@if (isCartOpen()) {
<div class="store-layout__cart-overlay" (click)="isCartOpen.set(false)"></div>
<div
class="store-layout__cart-overlay"
(click)="isCartOpen.set(false)"
></div>
<div class="store-layout__cart-dropdown card border-0 shadow-lg">
<app-cart
[showClose]="true"
@@ -17,20 +30,37 @@
[backgroundColor]="'#ffffff'"
(closed)="isCartOpen.set(false)"
>
<app-button variant="secondary" class="flex-grow-1">Seguir comprando</app-button>
<app-button variant="primary" class="flex-grow-1">Comprar</app-button>
<app-button
variant="secondary"
class="flex-grow-1"
(click)="isCartOpen.set(false)"
>Seguir comprando</app-button
>
<app-button
variant="primary"
class="flex-grow-1"
[disabled]="isCreatingPurchase()"
(click)="onCheckoutClick()"
>
Comprar
</app-button>
</app-cart>
</div>
}
<main class="flex-grow-1 d-block">
<router-outlet />
<section class="py-5">
<div class="container-xl px-3 px-md-4">
<router-outlet />
</div>
</section>
</main>
<app-store-footer
[currentYear]="currentYear"
[footerSections]="footerSections"
[socialLinks]="socialLinks"
[footerSections]="footerSections()"
[socialMedia]="tenant()?.social_media ?? []"
[logoUrl]="tenant()?.footer_logo ?? null"
(logoutClick)="onLogoutClick()"
/>
</div>

View File

@@ -1,13 +1,7 @@
:host {
display: flex;
min-height: 100dvh;
background:
radial-gradient(
circle at top,
color-mix(in srgb, white 88%, var(--tenant-primary)) 0%,
transparent 58%
),
#f6f6f6;
background: #f5f5f5;
color: #202020;
}
@@ -30,7 +24,6 @@
top: 76px;
right: calc((100% - 1320px) / 2 + 1.5rem);
height: 520px;
max-height: calc(100vh - 120px);
z-index: 1050;
background-color: #ffffff;

View File

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

View File

@@ -1,24 +1,40 @@
import { Component, computed, inject, OnInit, signal } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { Router, RouterOutlet } from '@angular/router';
import { TenantService } from '../../services/tenant.service';
import { CartService } from '../../services/cart/cart.service';
import { StoreFooterComponent, StoreFooterSection, StoreSocialLink } from './store-footer/store-footer.component';
import { StoreFooterComponent, StoreFooterSection } from './store-footer/store-footer.component';
import { StoreHeaderComponent } from './store-header/store-header.component';
import { CartComponent, CartItemMock } from '../../../shared/components/cart/cart.component';
import { ButtonComponent } from '../../../shared/components/button/button.component';
import { CartItem } from '../../services/cart/cart.interface';
import { AuthService } from '../../services/auth/auth.service';
import { findMenu } from '../../services/menu.utils';
import { CheckoutService } from '../../services/checkout.service';
import { ToastService } from '../../services/toast.service';
import { Category } from '../../services/tenant.interface';
@Component({
selector: 'app-store-layout',
imports: [RouterOutlet, StoreHeaderComponent, StoreFooterComponent, CartComponent, ButtonComponent],
imports: [
RouterOutlet,
StoreHeaderComponent,
StoreFooterComponent,
CartComponent,
ButtonComponent,
],
templateUrl: './store-layout.component.html',
styleUrl: './store-layout.component.scss'
styleUrl: './store-layout.component.scss',
})
export class StoreLayoutComponent implements OnInit {
private readonly tenantService = inject(TenantService);
private readonly cartService = inject(CartService);
private readonly authService = inject(AuthService);
private readonly checkoutService = inject(CheckoutService);
private readonly toastService = inject(ToastService);
private readonly router = inject(Router);
protected readonly isCartOpen = signal(false);
protected readonly isCreatingPurchase = signal(false);
protected readonly cartSubtotal = computed(() => {
const cart = this.cartService.cart();
@@ -54,19 +70,39 @@ export class StoreLayoutComponent implements OnInit {
}
return {
productVariantId: item.product_variant_id,
cartItemId: item.id,
imageUrl: item.product?.imagen ?? null,
product,
originalPrice: null,
discountedPrice: parseFloat(item.precio_unitario),
discountPercentage: null,
attributes,
quantity: item.cantidad
quantity: item.cantidad,
};
}
protected readonly currentYear = new Date().getFullYear();
protected readonly tenant = this.tenantService.tenant;
protected readonly user = this.authService.user;
protected readonly isAuthenticated = this.authService.isAuthenticated;
protected readonly ticketsMenu = computed(() =>
findMenu(this.tenant()?.menues ?? [], 'account.tickets'),
);
protected readonly accountNavigationMenus = computed(() => {
const accountMenu = findMenu(this.tenant()?.menues ?? [], 'account');
if (!accountMenu) {
return [];
}
const profile = accountMenu.submenues.find((menu) => menu.code === 'account.profile');
const purchases = accountMenu.submenues.find((menu) => menu.code === 'account.purchases');
return [
...(profile ? [{ ...accountMenu, route: profile.route }] : []),
...(purchases ? [purchases] : []),
];
});
protected readonly cartQuantity = computed(() => {
const cart = this.cartService.cart();
@@ -76,38 +112,120 @@ export class StoreLayoutComponent implements OnInit {
ngOnInit(): void {
this.cartService.loadCart().subscribe({
error: (err) => console.error('Error loading cart', err)
error: (err) => console.error('Error loading cart', err),
});
}
protected onLoginClick(): void {
void this.router.navigate(['/login']);
}
protected readonly footerSections: StoreFooterSection[] = [
{
heading: 'Cuenta',
links: ['Mi cuenta', 'Mis compras', 'Cerrar sesion']
},
{
heading: 'Ayuda',
links: ['Contacto', 'Ayuda', 'Preguntas frecuentes']
}
];
protected onSearch(term: string): void {
void this.router.navigate(['/buscar'], {
queryParams: { q: term, page: 1 },
});
}
protected readonly socialLinks: StoreSocialLink[] = [
{
label: 'Instagram',
iconClass: 'fa-brands fa-instagram'
},
{
label: 'WhatsApp',
iconClass: 'fa-brands fa-whatsapp'
},
{
label: 'Facebook',
iconClass: 'fa-brands fa-facebook'
},
{
label: 'LinkedIn',
iconClass: 'fa-brands fa-linkedin-in'
protected onCategorySelect(category: Category): void {
void this.router.navigate(['/categoria', category.id], {
queryParams: { page: 1 },
});
}
protected onTicketsClick(): void {
const route = this.ticketsMenu()?.route;
if (route) {
void this.router.navigate([route]);
}
];
}
protected async onLogoutClick(): Promise<void> {
const isLeavingCheckout = this.router.url.startsWith('/checkout');
// Checkout must be left while the authenticated session is still valid so
// its CanDeactivate guard can cancel the pending purchase.
if (isLeavingCheckout) {
const navigationSucceeded = await this.router.navigate(['/']);
if (!navigationSucceeded) {
return;
}
}
this.authService.logout().subscribe({
next: () => {
this.cartService.clearCart();
this.isCartOpen.set(false);
if (!isLeavingCheckout) {
void this.router.navigate(['/']);
}
},
error: (err) => console.error('Error logging out', err),
});
}
protected async onCheckoutClick(): Promise<void> {
if (this.isCreatingPurchase()) {
return;
}
const cart = this.cartService.cart();
const tenant = this.tenantService.tenant();
const user = this.authService.user();
if (!user) {
this.isCartOpen.set(false);
void this.router.navigate(['/login'], { queryParams: { returnUrl: '/' } });
return;
}
if (!cart?.id || !tenant) {
this.toastService.danger('No hay un carrito activo para iniciar la compra.');
return;
}
this.isCreatingPurchase.set(true);
try {
const purchase = await this.checkoutService.startCheckout(tenant.codigo, {
cart_id: cart.id,
});
this.cartService.clearCart();
this.isCartOpen.set(false);
await this.router.navigate(['/checkout'], {
queryParams: { purchase: purchase.id },
});
} catch (error) {
console.error('Failed to create cart purchase:', error);
this.toastService.danger('No se pudo iniciar la compra.');
} finally {
this.isCreatingPurchase.set(false);
}
}
protected readonly footerSections = computed<StoreFooterSection[]>(() => {
const helpSubmenues = findMenu(this.tenant()?.menues ?? [], 'help')?.submenues ?? [];
return [
{
heading: 'Cuenta',
links: [
...this.accountNavigationMenus().map((menu) => ({
label: menu.label,
routerLink: menu.route,
})),
{ label: 'Cerrar sesion', action: 'logout' },
],
},
{
heading: 'Ayuda',
links: helpSubmenues.map((menu) => ({
label: menu.label,
routerLink: menu.route,
})),
},
];
});
}

View File

@@ -0,0 +1,9 @@
import { inject } from '@angular/core';
import { AuthService } from './auth.service';
export function authBootstrap(): Promise<void> {
const authService = inject(AuthService);
return authService.bootstrap();
}

View File

@@ -0,0 +1,68 @@
import { computed, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { provideRouter, Router, UrlTree } from '@angular/router';
import { AuthService } from './auth.service';
import { authGuard, guestOnlyGuard } from './auth.guards';
function createAuthServiceStub(isAuthenticated = false) {
const tokenState = signal<string | null>(isAuthenticated ? 'jwt-token' : null);
return {
isAuthenticated: computed(() => tokenState() !== null)
};
}
describe('auth guards', () => {
beforeEach(() => {
TestBed.resetTestingModule();
});
it('redirects unauthenticated users to /login', () => {
TestBed.configureTestingModule({
providers: [provideRouter([]), { provide: AuthService, useValue: createAuthServiceStub() }]
});
const result = TestBed.runInInjectionContext(() =>
authGuard(null as never, { url: '/checkout?mode=direct' } as never),
);
expect(result instanceof UrlTree).toBe(true);
expect(TestBed.inject(Router).serializeUrl(result as UrlTree)).toBe(
'/login?returnUrl=%2Fcheckout%3Fmode%3Ddirect',
);
});
it('allows authenticated users through authGuard', () => {
TestBed.configureTestingModule({
providers: [provideRouter([]), { provide: AuthService, useValue: createAuthServiceStub(true) }]
});
const result = TestBed.runInInjectionContext(() =>
authGuard(null as never, { url: '/checkout' } as never),
);
expect(result).toBe(true);
});
it('redirects authenticated users away from guest routes', () => {
TestBed.configureTestingModule({
providers: [provideRouter([]), { provide: AuthService, useValue: createAuthServiceStub(true) }]
});
const result = TestBed.runInInjectionContext(() => guestOnlyGuard(null as never, null as never));
expect(result instanceof UrlTree).toBe(true);
expect(TestBed.inject(Router).serializeUrl(result as UrlTree)).toBe('/');
});
it('allows guests into login/register routes', () => {
TestBed.configureTestingModule({
providers: [provideRouter([]), { provide: AuthService, useValue: createAuthServiceStub() }]
});
const result = TestBed.runInInjectionContext(() => guestOnlyGuard(null as never, null as never));
expect(result).toBe(true);
});
});

View File

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

View File

@@ -0,0 +1,103 @@
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { computed, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { environment } from '../../../../environments/environment';
import { AuthService } from './auth.service';
import { authInterceptor } from './auth.interceptor';
function createAuthServiceStub(token: string | null = null) {
const tokenState = signal(token);
return {
token: tokenState.asReadonly(),
isAuthenticated: computed(() => tokenState() !== null),
clearSession: vi.fn(() => tokenState.set(null))
};
}
describe('authInterceptor', () => {
beforeEach(() => {
TestBed.resetTestingModule();
});
it('adds the bearer token when a session exists', () => {
const authService = createAuthServiceStub('jwt-token');
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptors([authInterceptor])),
provideHttpClientTesting(),
{ provide: AuthService, useValue: authService }
]
});
const client = TestBed.inject(HttpClient);
const httpController = TestBed.inject(HttpTestingController);
client.get(`${environment.url}me`).subscribe();
const request = httpController.expectOne(`${environment.url}me`);
expect(request.request.headers.get('Authorization')).toBe('Bearer jwt-token');
request.flush({ id: 1 });
httpController.verify();
});
it('does not add the authorization header when no token exists', () => {
const authService = createAuthServiceStub();
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptors([authInterceptor])),
provideHttpClientTesting(),
{ provide: AuthService, useValue: authService }
]
});
const client = TestBed.inject(HttpClient);
const httpController = TestBed.inject(HttpTestingController);
client.get(`${environment.url}productos`).subscribe();
const request = httpController.expectOne(`${environment.url}productos`);
expect(request.request.headers.has('Authorization')).toBe(false);
request.flush([]);
httpController.verify();
});
it('propagates a 401 without clearing the local session', () => {
const authService = createAuthServiceStub('expired-token');
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptors([authInterceptor])),
provideHttpClientTesting(),
{ provide: AuthService, useValue: authService }
]
});
const client = TestBed.inject(HttpClient);
const httpController = TestBed.inject(HttpTestingController);
let receivedError: unknown;
let receivedStatus: number | null = null;
client.get(`${environment.url}me`).subscribe({
error: (error: HttpErrorResponse) => {
receivedError = error;
receivedStatus = error.status;
}
});
const request = httpController.expectOne(`${environment.url}me`);
request.flush({ message: 'Unauthorized' }, { status: 401, statusText: 'Unauthorized' });
expect(receivedError).toBeInstanceOf(HttpErrorResponse);
expect(receivedStatus).toBe(401);
expect(authService.clearSession).not.toHaveBeenCalled();
httpController.verify();
});
});

View File

@@ -0,0 +1,23 @@
import { HttpEvent, HttpHandlerFn, HttpInterceptorFn, HttpRequest } from '@angular/common/http';
import { inject } from '@angular/core';
import { Observable } from 'rxjs';
import { AuthService } from './auth.service';
export const authInterceptor: HttpInterceptorFn = (
request: HttpRequest<unknown>,
next: HttpHandlerFn
): Observable<HttpEvent<unknown>> => {
const authService = inject(AuthService);
const token = authService.token();
const authenticatedRequest = token
? request.clone({
setHeaders: {
Authorization: `Bearer ${token}`
}
})
: request;
return next(authenticatedRequest);
};

View File

@@ -0,0 +1,61 @@
export interface AuthUser {
id: number;
nombre_apellido: string;
email: string;
dni?: string;
telefono?: string;
}
export interface LoginPayload {
email: string;
password: string;
}
export interface RegisterPayload {
nombre_apellido: string;
email: string;
password: string;
password_confirmation: string;
}
export interface UpdateProfilePayload {
nombre_apellido: string;
email: string;
dni?: string;
telefono?: string;
password?: string;
}
export interface LoginResponse {
message: string;
token: string;
token_type: 'Bearer';
user: AuthUser;
}
export interface RegisterResponse {
message: string;
data: AuthUser;
}
export interface ResetPasswordAttemptResponse {
message: string;
status: 'pending';
}
export interface ValidateResetPasswordAttemptResponse {
message: string;
status: 'validated';
}
export interface ResetPasswordPayload {
email: string;
codigo: string;
password: string;
password_confirmation: string;
}
export interface ResetPasswordResponse {
message: string;
status: 'used';
}

View File

@@ -0,0 +1,391 @@
import { PLATFORM_ID, TransferState } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { environment } from '../../../../environments/environment';
import { AuthService } from './auth.service';
import { CookieService } from '../cookie/cookie.service';
import { TenantService } from '../tenant.service';
describe('AuthService', () => {
let cookieStore: Record<string, string> = {};
beforeEach(() => {
cookieStore = {};
TestBed.resetTestingModule();
});
function createCookieServiceStub() {
return {
get: (name: string) => cookieStore[name] || null,
set: (name: string, value: string) => {
cookieStore[name] = value;
},
delete: (name: string) => {
delete cookieStore[name];
}
};
}
it('stores token and user on successful login', () => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
AuthService,
{ provide: CookieService, useValue: createCookieServiceStub() },
{
provide: TenantService,
useValue: { getTenant: () => ({ codigo: 'tenant-test' }) }
},
TransferState
]
});
const service = TestBed.inject(AuthService);
const httpController = TestBed.inject(HttpTestingController);
service.login({ email: 'ada@example.com', password: 'secret123' }).subscribe((user) => {
expect(user.email).toBe('ada@example.com');
});
const request = httpController.expectOne(`${environment.url}login`);
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual({
email: 'ada@example.com',
password: 'secret123',
tenant_codigo: 'tenant-test'
});
expect(request.request.withCredentials).toBe(true);
request.flush({
message: 'Sesion iniciada correctamente.',
token: 'plain-text-token',
token_type: 'Bearer',
user: {
id: 1,
nombre_apellido: 'Ada Lovelace',
email: 'ada@example.com'
}
});
expect(service.token()).toBe('plain-text-token');
expect(service.user()?.email).toBe('ada@example.com');
expect(service.isAuthenticated()).toBe(true);
expect(cookieStore['shopit.auth.token']).toBe('plain-text-token');
httpController.verify();
});
it('hydrates token from cookies and loads the current user during bootstrap', async () => {
cookieStore['shopit.auth.token'] = 'persisted-token';
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
AuthService,
{ provide: CookieService, useValue: createCookieServiceStub() },
TransferState
]
});
const service = TestBed.inject(AuthService);
const httpController = TestBed.inject(HttpTestingController);
const bootstrapPromise = service.bootstrap();
const request = httpController.expectOne(`${environment.url}me`);
expect(request.request.method).toBe('GET');
request.flush({
id: 9,
nombre_apellido: 'Grace Hopper',
email: 'grace@example.com'
});
await expect(bootstrapPromise).resolves.toBeUndefined();
expect(service.user()?.email).toBe('grace@example.com');
expect(service.isAuthenticated()).toBe(true);
httpController.verify();
});
it('sends tenant and credentials when completing Google login', () => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
AuthService,
{ provide: CookieService, useValue: createCookieServiceStub() },
{
provide: TenantService,
useValue: { getTenant: () => ({ codigo: 'tenant-test' }) }
},
TransferState
]
});
const service = TestBed.inject(AuthService);
const httpController = TestBed.inject(HttpTestingController);
service.completeGoogleLogin('oauth-code').subscribe();
const request = httpController.expectOne(`${environment.url}auth/google/exchange`);
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual({
oauth_code: 'oauth-code',
tenant_codigo: 'tenant-test'
});
expect(request.request.withCredentials).toBe(true);
request.flush({
message: 'Sesion iniciada correctamente.',
token: 'google-token',
token_type: 'Bearer',
user: {
id: 1,
nombre_apellido: 'Ada Lovelace',
email: 'ada@example.com'
}
});
httpController.verify();
});
it('clears an expired session when /me returns 401', async () => {
cookieStore['shopit.auth.token'] = 'expired-token';
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
AuthService,
{ provide: CookieService, useValue: createCookieServiceStub() },
TransferState
]
});
const service = TestBed.inject(AuthService);
const httpController = TestBed.inject(HttpTestingController);
const bootstrapPromise = service.bootstrap();
const request = httpController.expectOne(`${environment.url}me`);
request.flush({ message: 'Unauthorized' }, { status: 401, statusText: 'Unauthorized' });
await expect(bootstrapPromise).resolves.toBeUndefined();
expect(service.user()).toBeNull();
expect(service.token()).toBeNull();
expect(service.isAuthenticated()).toBe(false);
expect(cookieStore['shopit.auth.token']).toBeUndefined();
httpController.verify();
});
it('registers without creating a session', () => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
AuthService,
{ provide: CookieService, useValue: createCookieServiceStub() },
{
provide: TenantService,
useValue: { getTenant: () => ({ codigo: 'tenant-test' }) }
},
TransferState
]
});
const service = TestBed.inject(AuthService);
const httpController = TestBed.inject(HttpTestingController);
service
.register({
nombre_apellido: 'Ada Lovelace',
email: 'ada@example.com',
password: 'secret123',
password_confirmation: 'secret123'
})
.subscribe((response) => {
expect(response.data.email).toBe('ada@example.com');
});
const request = httpController.expectOne(`${environment.url}register`);
expect(request.request.method).toBe('POST');
expect(request.request.body.tenant_codigo).toBe('tenant-test');
request.flush({
message: 'Usuario registrado correctamente.',
data: {
id: 1,
nombre_apellido: 'Ada Lovelace',
email: 'ada@example.com'
}
});
expect(service.token()).toBeNull();
expect(service.user()).toBeNull();
expect(service.isAuthenticated()).toBe(false);
httpController.verify();
});
it('requests a password reset and exposes the final HTTP status', () => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
AuthService,
{ provide: CookieService, useValue: createCookieServiceStub() },
{
provide: TenantService,
useValue: { getTenant: () => ({ codigo: 'tenant-test' }) }
},
TransferState
]
});
const service = TestBed.inject(AuthService);
const httpController = TestBed.inject(HttpTestingController);
service.requestPasswordReset('ada@example.com').subscribe((response) => {
expect(response.status).toBe(202);
expect(response.body?.status).toBe('pending');
});
const request = httpController.expectOne(`${environment.url}password/reset-attempts`);
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual({
email: 'ada@example.com',
tenant_codigo: 'tenant-test'
});
request.flush(
{
message: 'Si el email está registrado, recibirás un código.',
status: 'pending'
},
{ status: 202, statusText: 'Accepted' }
);
httpController.verify();
});
it('validates a password reset code and exposes its final status', () => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
AuthService,
{ provide: CookieService, useValue: createCookieServiceStub() },
TransferState
]
});
const service = TestBed.inject(AuthService);
const httpController = TestBed.inject(HttpTestingController);
service.validatePasswordResetCode('ada@example.com', '0123').subscribe((response) => {
expect(response.status).toBe(200);
expect(response.body?.status).toBe('validated');
});
const request = httpController.expectOne(`${environment.url}password/reset-attempts/validate`);
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual({
email: 'ada@example.com',
codigo: '0123'
});
request.flush(
{
message: 'Código validado correctamente.',
status: 'validated'
},
{ status: 200, statusText: 'OK' }
);
httpController.verify();
});
it('resets the password and exposes the consumed attempt status', () => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
AuthService,
{ provide: CookieService, useValue: createCookieServiceStub() },
TransferState
]
});
const service = TestBed.inject(AuthService);
const httpController = TestBed.inject(HttpTestingController);
const payload = {
email: 'ada@example.com',
codigo: '0123',
password: 'NewSecret!456',
password_confirmation: 'NewSecret!456'
};
service.resetPassword(payload).subscribe((response) => {
expect(response.status).toBe(200);
expect(response.body?.status).toBe('used');
});
const request = httpController.expectOne(`${environment.url}password/reset`);
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual(payload);
request.flush(
{
message: 'Contraseña modificada correctamente.',
status: 'used'
},
{ status: 200, statusText: 'OK' }
);
httpController.verify();
});
it('propagates logout errors without clearing the local session', () => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
AuthService,
{ provide: CookieService, useValue: createCookieServiceStub() },
{
provide: TenantService,
useValue: { getTenant: () => ({ codigo: 'tenant-test' }) }
},
TransferState
]
});
const service = TestBed.inject(AuthService);
const httpController = TestBed.inject(HttpTestingController);
service.login({ email: 'ada@example.com', password: 'secret123' }).subscribe();
httpController.expectOne(`${environment.url}login`).flush({
message: 'Sesion iniciada correctamente.',
token: 'plain-text-token',
token_type: 'Bearer',
user: {
id: 1,
nombre_apellido: 'Ada Lovelace',
email: 'ada@example.com'
}
});
service.logout().subscribe({
error: () => undefined
});
const logoutRequest = httpController.expectOne(`${environment.url}logout`);
expect(logoutRequest.request.method).toBe('POST');
logoutRequest.flush({ message: 'Unauthorized' }, { status: 401, statusText: 'Unauthorized' });
expect(service.token()).toBe('plain-text-token');
expect(service.user()?.email).toBe('ada@example.com');
expect(cookieStore['shopit.auth.token']).toBe('plain-text-token');
httpController.verify();
});
});

View File

@@ -0,0 +1,201 @@
import { DOCUMENT } from '@angular/common';
import { HttpClient, HttpErrorResponse, HttpResponse } from '@angular/common/http';
import { isPlatformBrowser, isPlatformServer } from '@angular/common';
import { computed, inject, Injectable, PLATFORM_ID, signal, TransferState, makeStateKey } from '@angular/core';
import { firstValueFrom, map, Observable, of, tap } from 'rxjs';
import { environment } from '../../../../environments/environment';
import {
AuthUser,
LoginPayload,
LoginResponse,
RegisterPayload,
RegisterResponse,
ResetPasswordPayload,
ResetPasswordAttemptResponse,
ResetPasswordResponse,
UpdateProfilePayload,
ValidateResetPasswordAttemptResponse
} from './auth.interfaces';
import { CookieService } from '../cookie/cookie.service';
import { TenantService } from '../tenant.service';
const AUTH_TOKEN_COOKIE_KEY = 'shopit.auth.token';
const AUTH_USER_SSR_STATE_KEY = makeStateKey<AuthUser>('shopit.auth.user');
@Injectable({
providedIn: 'root'
})
export class AuthService {
private readonly http = inject(HttpClient);
private readonly document = inject(DOCUMENT);
private readonly platformId = inject(PLATFORM_ID);
private readonly cookieService = inject(CookieService);
private readonly transferState = inject(TransferState);
private readonly tenantService = inject(TenantService);
private readonly userState = signal<AuthUser | null>(null);
private readonly tokenState = signal<string | null>(null);
readonly user = this.userState.asReadonly();
readonly token = this.tokenState.asReadonly();
readonly isAuthenticated = computed(() => this.tokenState() !== null);
login(payload: LoginPayload): Observable<AuthUser> {
const tenant = this.tenantService.getTenant();
if (!tenant) {
throw new Error('No se pudo resolver el tenant activo.');
}
return this.http
.post<LoginResponse>(
`${environment.url}login`,
{ ...payload, tenant_codigo: tenant.codigo },
{ withCredentials: true }
)
.pipe(
tap((response) => this.applyAuthenticatedState(response.token, response.user)),
map((response) => response.user)
);
}
register(payload: RegisterPayload): Observable<RegisterResponse> {
const tenantCode = this.tenantService.getTenant()?.codigo;
return this.http.post<RegisterResponse>(`${environment.url}register`, {
...payload,
...(tenantCode ? { tenant_codigo: tenantCode } : {})
});
}
requestPasswordReset(email: string): Observable<HttpResponse<ResetPasswordAttemptResponse>> {
const tenantCode = this.tenantService.getTenant()?.codigo;
return this.http.post<ResetPasswordAttemptResponse>(
`${environment.url}password/reset-attempts`,
{
email,
...(tenantCode ? { tenant_codigo: tenantCode } : {})
},
{ observe: 'response' }
);
}
validatePasswordResetCode(
email: string,
codigo: string
): Observable<HttpResponse<ValidateResetPasswordAttemptResponse>> {
return this.http.post<ValidateResetPasswordAttemptResponse>(
`${environment.url}password/reset-attempts/validate`,
{ email, codigo },
{ observe: 'response' }
);
}
resetPassword(payload: ResetPasswordPayload): Observable<HttpResponse<ResetPasswordResponse>> {
return this.http.post<ResetPasswordResponse>(`${environment.url}password/reset`, payload, {
observe: 'response'
});
}
loginWithGoogle(): void {
const tenant = this.tenantService.getTenant();
if (!tenant) {
throw new Error('No se pudo resolver el tenant activo.');
}
const apiUrl = new URL(environment.url);
const authorizationUrl = new URL('/auth/google/redirect', apiUrl.origin);
authorizationUrl.searchParams.set('tenant', tenant.codigo);
authorizationUrl.searchParams.set('return_url', this.document.location.origin);
this.document.location.assign(authorizationUrl.toString());
}
completeGoogleLogin(oauthCode: string): Observable<AuthUser> {
const tenant = this.tenantService.getTenant();
if (!tenant) {
throw new Error('No se pudo resolver el tenant activo.');
}
return this.http
.post<LoginResponse>(
`${environment.url}auth/google/exchange`,
{ oauth_code: oauthCode, tenant_codigo: tenant.codigo },
{ withCredentials: true }
)
.pipe(
tap((response) => this.applyAuthenticatedState(response.token, response.user)),
map((response) => response.user)
);
}
updateProfile(payload: UpdateProfilePayload): Observable<AuthUser> {
return this.http.put<AuthUser>(`${environment.url}me`, payload).pipe(tap((user) => this.userState.set(user)));
}
logout(): Observable<void> {
if (!this.tokenState()) {
this.clearSession();
return of(void 0);
}
return this.http.post<void>(`${environment.url}logout`, {}).pipe(tap(() => this.clearSession()));
}
async bootstrap(): Promise<void> {
this.hydrateSession();
if (!this.tokenState() || this.userState()) {
return;
}
const transferredUser = this.transferState.get(AUTH_USER_SSR_STATE_KEY, null);
if (transferredUser) {
this.transferState.remove(AUTH_USER_SSR_STATE_KEY);
this.userState.set(transferredUser);
return;
}
try {
const user = await firstValueFrom(this.loadCurrentUser());
if (isPlatformServer(this.platformId)) {
this.transferState.set(AUTH_USER_SSR_STATE_KEY, user);
}
} catch (error) {
// An expired token is an expected state, including while rendering on the
// server. Do not let it reject the application initializer: Angular's SSR
// pipeline can otherwise attempt to serialize HttpErrorResponse internals
// as HTTP headers.
if (error instanceof HttpErrorResponse && error.status === 401) {
this.clearSession();
return;
}
throw error;
}
}
loadCurrentUser(): Observable<AuthUser> {
return this.http.get<AuthUser>(`${environment.url}me`).pipe(tap((user) => this.userState.set(user)));
}
hydrateSession(): void {
const token = this.cookieService.get(AUTH_TOKEN_COOKIE_KEY);
this.tokenState.set(token);
}
clearSession(): void {
this.userState.set(null);
this.tokenState.set(null);
this.cookieService.delete(AUTH_TOKEN_COOKIE_KEY);
}
private applyAuthenticatedState(token: string, user: AuthUser): void {
this.tokenState.set(token);
this.userState.set(user);
this.cookieService.set(AUTH_TOKEN_COOKIE_KEY, token);
}
}

View File

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

View File

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

View File

@@ -1,13 +1,13 @@
import { inject, Injectable, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { map, Observable, tap } from 'rxjs';
import { catchError, map, Observable, tap } from 'rxjs';
import { ApiResponse } from '../api-response.interface';
import { TenantService } from '../tenant.service';
import { Cart } from './cart.interface';
@Injectable({
providedIn: 'root'
providedIn: 'root',
})
export class CartService {
private readonly http = inject(HttpClient);
@@ -16,53 +16,89 @@ export class CartService {
private readonly cartState = signal<Cart | null>(null);
readonly cart = this.cartState.asReadonly();
private readonly isUpdatingState = signal<boolean>(false);
readonly isUpdating = this.isUpdatingState.asReadonly();
private get tenantApiUrl(): string {
return this.tenantService.getTenantApiUrl();
}
clearCart(): void {
this.cartState.set({
id: null,
tenant_codigo: this.tenantService.tenant()?.codigo ?? '',
status: 'active',
items: [],
subtotal: '0.00',
});
}
loadCart(): Observable<Cart> {
return this.http
.get<ApiResponse<Cart>>(`${this.tenantApiUrl}/cart`, {
withCredentials: true
withCredentials: true,
})
.pipe(
map((response) => response.data),
tap((cart) => this.cartState.set(cart))
tap((cart) => this.cartState.set(cart)),
);
}
addItem(productVariantId: number, cantidad: number): Observable<ApiResponse<Cart>> {
addItem(
catalogItemId: number,
variantId: number | null,
cantidad: number,
): Observable<ApiResponse<Cart>> {
this.isUpdatingState.set(true);
return this.http
.post<ApiResponse<Cart>>(
`${this.tenantApiUrl}/cart/items`,
{ product_variant_id: productVariantId, cantidad },
{ withCredentials: true }
)
.post<
ApiResponse<Cart>
>(`${this.tenantApiUrl}/cart/items`, { catalog_item_id: catalogItemId, variant_id: variantId, cantidad }, { withCredentials: true })
.pipe(
tap((response) => this.cartState.set(response.data))
tap((response) => {
this.cartState.set(response.data);
this.isUpdatingState.set(false);
}),
catchError((error) => {
this.isUpdatingState.set(false);
throw error;
}),
);
}
updateItemQuantity(productVariantId: number, cantidad: number): Observable<ApiResponse<Cart>> {
updateItemQuantity(cartItemId: number, cantidad: number): Observable<ApiResponse<Cart>> {
this.isUpdatingState.set(true);
return this.http
.patch<ApiResponse<Cart>>(
`${this.tenantApiUrl}/cart/items/${productVariantId}`,
{ cantidad },
{ withCredentials: true }
)
.patch<
ApiResponse<Cart>
>(`${this.tenantApiUrl}/cart/items/${cartItemId}`, { cantidad }, { withCredentials: true })
.pipe(
tap((response) => this.cartState.set(response.data))
tap((response) => {
this.cartState.set(response.data);
this.isUpdatingState.set(false);
}),
catchError((error) => {
this.isUpdatingState.set(false);
throw error;
}),
);
}
removeItem(productVariantId: number): Observable<ApiResponse<Cart>> {
removeItem(cartItemId: number): Observable<ApiResponse<Cart>> {
this.isUpdatingState.set(true);
return this.http
.delete<ApiResponse<Cart>>(
`${this.tenantApiUrl}/cart/items/${productVariantId}`,
{ withCredentials: true }
)
.delete<
ApiResponse<Cart>
>(`${this.tenantApiUrl}/cart/items/${cartItemId}`, { withCredentials: true })
.pipe(
tap((response) => this.cartState.set(response.data))
tap((response) => {
this.cartState.set(response.data);
this.isUpdatingState.set(false);
}),
catchError((error) => {
this.isUpdatingState.set(false);
throw error;
}),
);
}
}

View File

@@ -1,3 +1,5 @@
import { ApiPaginatedResponse } from '../api-paginated-response.interface';
export interface Product {
id: number;
category_id: number;
@@ -29,22 +31,83 @@ export interface ProductAttribute {
options: ProductAttributeOption[];
}
export interface ProductVariant {
export type InventoryPolicy = 'tracked' | 'unlimited';
export interface CatalogItemVariant {
id: number;
cantidad_maxima: number;
definitions: Record<string, string>;
stock_tecnico: number | null;
minimum_use_date?: string | null;
maximum_use_date?: string | null;
effective_minimum_use_date?: string | null;
effective_maximum_use_date?: string | null;
values: Record<string, string>;
}
export interface SelectedCatalogItemVariant extends CatalogItemVariant {
images: string[];
}
export interface ProductVariantMap {
variant_id: number;
cantidad_maxima: number;
attributes: Record<string, string>;
}
export interface ProductDetail extends Product {
export interface CatalogItemDetail {
id: number;
category_id: number | null;
brand_id: number | null;
slug: string;
nombre: string;
descripcion: string | null;
precio: string;
category: string | null;
brand: string | null;
inventory_policy: InventoryPolicy;
has_tickets: boolean;
minimum_use_date: string | null;
maximum_use_date: string | null;
attributes: ProductAttribute[];
variants_map: ProductVariantMap[];
variant: ProductVariant | null;
variants: CatalogItemVariant[];
selected_variant?: SelectedCatalogItemVariant;
stock_tecnico?: number | null;
images?: string[];
}
export type CatalogProductLayout = 'row' | 'column_with_image' | 'column_with_cart';
export type CatalogGroupLayout = 'paginated' | 'simple' | 'simple_vertical' | 'carousel';
export interface CatalogFeaturedItemVariant {
id: number;
stock_tecnico: number | null;
values: Record<string, string>;
}
export interface CatalogFeaturedItem {
id: number;
nombre: string;
descripcion?: string | null;
precio: number | string;
image?: string | null;
stock_tecnico?: number | null;
variants?: CatalogFeaturedItemVariant[];
}
export type CatalogFeaturedItems =
| ApiPaginatedResponse<CatalogFeaturedItem[]>
| CatalogFeaturedItem[];
export interface CatalogFeaturedGroup {
id: number;
title: string;
layout: CatalogProductLayout;
group_layout: CatalogGroupLayout;
group_order: number;
items: CatalogFeaturedItems;
}
export interface CatalogCategory {
id: number;
nombre: string;
categoria_id: number | null;
}
export interface CategoryItemsResponse extends ApiPaginatedResponse<CatalogFeaturedItem[]> {
category: CatalogCategory;
layout: CatalogProductLayout;
group_layout: CatalogGroupLayout;
}

View File

@@ -6,16 +6,23 @@ import { ApiPaginationQueryParams } from '../api-pagination-query-params.interfa
import { ApiPaginatedResponse } from '../api-paginated-response.interface';
import { ApiResponse } from '../api-response.interface';
import { TenantService } from '../tenant.service';
import { Product, ProductDetail } from './catalog.interface';
import {
CatalogFeaturedGroup,
CatalogFeaturedItem,
CatalogFeaturedItems,
CatalogItemDetail,
CategoryItemsResponse,
Product,
} from './catalog.interface';
type HttpParamValue =
| string
| number
| boolean
| readonly (string | number | boolean)[];
type HttpParamValue = string | number | boolean | readonly (string | number | boolean)[];
export interface CatalogSearchQueryParams extends ApiPaginationQueryParams {
q: string;
}
@Injectable({
providedIn: 'root'
providedIn: 'root',
})
export class CatalogService {
private readonly http = inject(HttpClient);
@@ -25,24 +32,52 @@ export class CatalogService {
return this.tenantService.getTenantApiUrl();
}
getProductos(params?: ApiPaginationQueryParams): Observable<ApiPaginatedResponse<Product[]>> {
return this.http.get<ApiPaginatedResponse<Product[]>>(`${this.tenantApiUrl}/productos`, {
params: this.buildHttpParams(params),
});
}
getProductos(
params?: ApiPaginationQueryParams
): Observable<ApiPaginatedResponse<Product[]>> {
return this.http.get<ApiPaginatedResponse<Product[]>>(
`${this.tenantApiUrl}/productos`,
{ params: this.buildHttpParams(params) }
getCatalog(): Observable<CatalogFeaturedGroup[]> {
return this.http.get<CatalogFeaturedGroup[]>(`${this.tenantApiUrl}/catalog`);
}
searchCatalog(
params: CatalogSearchQueryParams,
): Observable<ApiPaginatedResponse<CatalogFeaturedItem[]>> {
return this.http.get<ApiPaginatedResponse<CatalogFeaturedItem[]>>(
`${this.tenantApiUrl}/catalog-items`,
{ params: this.buildHttpParams(params) },
);
}
getProducto(id: number, variantId?: number): Observable<ProductDetail> {
getCategoryItems(
categoryId: number,
params?: ApiPaginationQueryParams,
): Observable<CategoryItemsResponse> {
return this.http.get<CategoryItemsResponse>(`${this.tenantApiUrl}/categories/${categoryId}`, {
params: this.buildHttpParams(params),
});
}
getFeaturedGroupItems(
featuredGroupId: number,
params?: ApiPaginationQueryParams,
): Observable<CatalogFeaturedItems> {
return this.http.get<CatalogFeaturedItems>(
`${this.tenantApiUrl}/catalog/featured-groups/${featuredGroupId}/items`,
{ params: this.buildHttpParams(params) },
);
}
getCatalogItem(id: number, variantId?: number): Observable<CatalogItemDetail> {
let params = new HttpParams();
if (variantId) {
params = params.set('variant_id', variantId);
}
return this.http
.get<ApiResponse<ProductDetail>>(`${this.tenantApiUrl}/productos/${id}`, { params })
.get<ApiResponse<CatalogItemDetail>>(`${this.tenantApiUrl}/catalog-items/${id}`, { params })
.pipe(map((response) => response.data));
}
@@ -59,7 +94,7 @@ export class CatalogService {
return acc;
},
{}
{},
);
return new HttpParams({ fromObject });

View File

@@ -0,0 +1,284 @@
import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { environment } from '../../../environments/environment';
export interface UpdatePurchaseCustomerPayload {
dni: string;
telefono: string;
nombre_apellido: string;
email: string;
}
export type StartCheckoutPayload =
| {
cart_id: number;
}
| {
direct_item: {
catalog_item_id: number;
variant_id: number | null;
cantidad: number;
};
};
export interface PurchaseStatusResponse {
status: string | null;
}
export interface PurchaseSummaryResponse extends PurchaseStatusResponse {
id: number;
created_at: string | null;
total: string;
}
export interface PurchaseDetailAttributeResponse {
name: string;
value: string | number | boolean | null;
}
export interface PurchaseDetailItemResponse {
id: number;
quantity: number;
unit_price: string;
line_total: string;
source_catalog_item_id: number | null;
source_variant_id: number | null;
item_details: {
nombre: string;
descripcion: string | null;
slug: string;
imagen: string | null;
attributes: PurchaseDetailAttributeResponse[];
};
}
export interface PurchaseDetailResponse extends PurchaseStatusResponse {
id: number;
cart_id: number | null;
tenant_codigo: string;
user_id: number;
created_at: string | null;
payment_method: string | null;
dni: string | null;
transfer_payer_dni: string | null;
telefono: string | null;
nombre_apellido: string | null;
email: string | null;
items_source: 'purchase';
items: PurchaseDetailItemResponse[];
tickets_count?: number;
has_generated_tickets?: boolean;
subtotal: string;
total: string;
}
@Injectable({
providedIn: 'root',
})
export class CheckoutService {
private readonly http = inject(HttpClient);
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,
),
);
const purchase = this.extractResponseData<PurchaseDetailResponse>(response);
if (!purchase?.id) {
throw new Error('Error al crear la compra.');
}
return purchase;
}
async generatePaymentIntent(
tenantCode: string,
purchaseId: number,
method: 'qr' | 'transfer' | 'telepagos',
payerDni?: string,
): Promise<any> {
const payload: any = { method };
if (payerDni) {
payload.transfer_payer_dni = payerDni;
}
const response = await firstValueFrom(
this.http.post<any>(
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/payment-intent`,
payload,
),
);
if (!response) {
throw new Error('Error al generar la intención de pago.');
}
return response;
}
async updateCustomerData(
tenantCode: string,
purchaseId: number,
payload: UpdatePurchaseCustomerPayload,
): Promise<PurchaseDetailResponse> {
const response = await firstValueFrom(
this.http.patch<{ data?: PurchaseDetailResponse } | PurchaseDetailResponse>(
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/customer-data`,
payload,
),
);
const purchase = this.extractResponseData<PurchaseDetailResponse>(response);
if (!purchase) {
throw new Error('Error al actualizar los datos de la compra.');
}
return purchase;
}
async updateItemQuantity(
tenantCode: string,
purchaseId: number,
itemId: number,
quantity: number,
): Promise<PurchaseDetailResponse> {
const response = await firstValueFrom(
this.http.patch<{ data?: PurchaseDetailResponse } | PurchaseDetailResponse>(
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/items/${itemId}`,
{ quantity },
),
);
const purchase = this.extractResponseData<PurchaseDetailResponse>(response);
if (!purchase) {
throw new Error('Error al actualizar la cantidad del producto.');
}
return purchase;
}
async prepareItemEditing(
tenantCode: string,
purchaseId: number,
): Promise<PurchaseDetailResponse> {
const response = await firstValueFrom(
this.http.post<{ data?: PurchaseDetailResponse } | PurchaseDetailResponse>(
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/edit-items`,
{},
),
);
const purchase = this.extractResponseData<PurchaseDetailResponse>(response);
if (!purchase) {
throw new Error('Error al preparar la compra para editarla.');
}
return purchase;
}
async completePurchase(tenantCode: string, purchaseId: number): Promise<PurchaseStatusResponse> {
const response = await firstValueFrom(
this.http.post<{ data?: PurchaseStatusResponse } | PurchaseStatusResponse>(
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/complete`,
{},
),
);
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,
): Promise<PurchaseStatusResponse> {
const response = await firstValueFrom(
this.http.post<{ data?: PurchaseStatusResponse } | PurchaseStatusResponse>(
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/review`,
{},
),
);
const purchase = this.extractResponseData<PurchaseStatusResponse>(response);
if (!purchase) {
throw new Error('Error al enviar la compra a revisi\u00f3n.');
}
return { status: purchase.status ?? null };
}
async cancelPurchase(tenantCode: string, purchaseId: number): Promise<PurchaseStatusResponse> {
const response = await firstValueFrom(
this.http.post<{ data?: PurchaseStatusResponse } | PurchaseStatusResponse>(
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/cancel`,
{},
),
);
const purchase = this.extractResponseData<PurchaseStatusResponse>(response);
if (!purchase) {
throw new Error('Error al cancelar la compra.');
}
return { status: purchase.status ?? null };
}
async getPurchases(
tenantCode: string,
status?: string,
): Promise<{ data: PurchaseSummaryResponse[] }> {
let url = `${environment.url}tenants/${tenantCode}/compras`;
if (status) {
url += `?status=${status}`;
}
const response = await firstValueFrom(this.http.get<{ data: PurchaseSummaryResponse[] }>(url));
if (!response) {
throw new Error('Error al obtener las compras.');
}
return response;
}
async getPurchase(
tenantCode: string,
purchaseId: string | number,
): Promise<PurchaseDetailResponse> {
const response = await firstValueFrom(
this.http.get<{ data?: PurchaseDetailResponse } | PurchaseDetailResponse>(
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}`,
),
);
const purchase = this.extractResponseData<PurchaseDetailResponse>(response);
if (!purchase) {
throw new Error('Error al obtener la compra.');
}
return purchase;
}
private extractResponseData<T>(response: T | { data?: T } | null | undefined): T | null {
if (!response) {
return null;
}
if (typeof response === 'object' && 'data' in response && response.data) {
return response.data;
}
return response as T;
}
}

View File

@@ -0,0 +1,56 @@
import { isPlatformBrowser } from '@angular/common';
import { inject, Injectable, PLATFORM_ID, REQUEST } from '@angular/core';
import { DOCUMENT } from '@angular/common';
@Injectable({
providedIn: 'root'
})
export class CookieService {
private readonly platformId = inject(PLATFORM_ID);
private readonly document = inject(DOCUMENT);
private readonly request = inject(REQUEST, { optional: true });
get(name: string): string | null {
if (isPlatformBrowser(this.platformId)) {
return this.getCookieFromString(name, this.document.cookie);
}
if (this.request) {
const cookieHeader = this.request.headers.get('cookie') || '';
return this.getCookieFromString(name, cookieHeader);
}
return null;
}
set(name: string, value: string): void {
if (isPlatformBrowser(this.platformId)) {
this.document.cookie = `${name}=${value};path=/;SameSite=Lax`;
}
}
delete(name: string): void {
if (isPlatformBrowser(this.platformId)) {
this.document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/;SameSite=Lax`;
}
}
private getCookieFromString(name: string, cookieString: string): string | null {
if (!cookieString) {
return null;
}
const nameEQ = `${name}=`;
const ca = cookieString.split(';');
for (let i = 0; i < ca.length; i++) {
let c = ca[i];
while (c.charAt(0) === ' ') {
c = c.substring(1, c.length);
}
if (c.indexOf(nameEQ) === 0) {
return c.substring(nameEQ.length, c.length);
}
}
return null;
}
}

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,52 @@
import { ApiResponse } from './api-response.interface';
export interface BankAccount {
id: number;
tenant_code: string;
titular: string;
entidad: string;
alias: string;
cvu: string;
}
export interface HeroConfig {
background_image?: string | null;
title_html?: string | null;
description_html?: string | null;
button_text?: string | null;
button_href?: string | null;
}
export interface EventConfig {
title?: string | null;
location?: string | null;
dates?: string[] | null;
}
export interface Menu {
id: number;
code: string;
label: string;
parent_menu_code: string | null;
content_type: 'static' | 'dynamic';
route: string;
static_content?: unknown;
submenues: Menu[];
}
export interface SocialMedia {
code: string;
icon: string;
name: string;
url: string;
}
export interface Category {
id: number;
nombre: string;
subcategories: Category[];
}
export interface Tenant {
id: number;
codigo: string;
@@ -13,7 +60,17 @@ export interface Tenant {
footer_bg_color: string;
header_logo: string;
footer_logo: string;
selected_bank_account_id?: number | null;
selected_bank_account?: BankAccount | null;
hero_config?: HeroConfig | null;
event_config?: EventConfig | null;
search_product_layout?: 'row' | 'column_with_image' | 'column_with_cart';
search_group_layout?: 'paginated' | 'simple' | 'simple_vertical' | 'carousel';
search_items_per_page?: number;
main_carousel_images?: string[];
social_media?: SocialMedia[];
menues?: Menu[];
categories: Category[];
}
export type TenantBootstrapResponse = ApiResponse<Tenant>;

View File

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

View File

@@ -3,9 +3,7 @@
<div class="card-body">
<span class="eyebrow">Componentes reutilizables</span>
<h1>Button + Input</h1>
<p class="lead mb-4">
Pagina dedicada a probar variantes de los componentes compartidos.
</p>
<p class="lead mb-4">Pagina dedicada a probar variantes de los componentes compartidos.</p>
<div class="button-showcase">
<div class="button-group">
@@ -26,9 +24,7 @@
<app-button variant="secondary" [disabled]="true">Secondary</app-button>
<app-button variant="danger" [disabled]="true">Danger</app-button>
<app-button variant="danger-secondary" [disabled]="true">
Danger secondary
</app-button>
<app-button variant="danger-secondary" [disabled]="true"> Danger secondary </app-button>
<app-button variant="cancel" [disabled]="true">Cancel</app-button>
</div>
</div>
@@ -60,13 +56,8 @@
<div class="button-group">
<h2>Toasts Persistentes (No se van)</h2>
<div class="button-grid">
<app-button (click)="triggerPersistentToast('info')">
Info Persistente
</app-button>
<app-button
variant="secondary"
(click)="triggerPersistentToast('success')"
>
<app-button (click)="triggerPersistentToast('info')"> Info Persistente </app-button>
<app-button variant="secondary" (click)="triggerPersistentToast('success')">
Success Persistente
</app-button>
<app-button variant="danger" (click)="triggerPersistentToast('danger')">
@@ -91,6 +82,7 @@
<app-button variant="danger-secondary" (click)="openWideModal()">
Abrir modal ancho
</app-button>
<app-button (click)="openSimpleModal()"> Abrir simple modal </app-button>
</div>
<div class="modal-showcase__result" data-testid="modal-last-result">
{{ lastModalResult }}
@@ -103,9 +95,9 @@
<div class="icon-button-showcase">
<h2>Icon Buttons</h2>
<p class="text-muted mb-4">
Botones de icono pequenos con color base <code>#666666</code>,
deshabilitados con <code>#A0A0A0</code> y estados hover/active con color
de marca (a excepcion de trash que usa danger).
Botones de icono pequenos con color base <code>#666666</code>, deshabilitados con
<code>#A0A0A0</code> y estados hover/active con color de marca (a excepcion de trash que
usa danger).
</p>
<div class="icon-button-showcase__grid">
@@ -152,9 +144,7 @@
<div class="icon-button-showcase mt-4">
<h2>Cart Icons</h2>
<p class="text-muted mb-4">
Icono de carrito con badge opcional indicando cantidad.
</p>
<p class="text-muted mb-4">Icono de carrito con badge opcional indicando cantidad.</p>
<div class="icon-button-showcase__grid">
<div class="icon-button-showcase__row">
@@ -197,12 +187,7 @@
<div class="input-field">
<label for="number-input">Numero</label>
<app-input
id="number-input"
type="number"
placeholder="0"
[(value)]="numberValue"
/>
<app-input id="number-input" type="number" placeholder="0" [(value)]="numberValue" />
</div>
<div class="input-field">
@@ -217,12 +202,7 @@
<div class="input-field">
<label for="user-input">Usuario</label>
<app-input
id="user-input"
type="user"
placeholder="Nombre de usuario"
[(value)]="user"
/>
<app-input id="user-input" type="user" placeholder="Nombre de usuario" [(value)]="user" />
</div>
<div class="input-field">
@@ -247,22 +227,12 @@
<div class="input-field">
<label for="amount-input">Moneda</label>
<app-input
id="amount-input"
type="currency"
placeholder="0"
[(value)]="amount"
/>
<app-input id="amount-input" type="currency" placeholder="0" [(value)]="amount" />
</div>
<div class="input-field">
<label for="percent-input">Porcentaje</label>
<app-input
id="percent-input"
type="percent"
placeholder="0"
[(value)]="percent"
/>
<app-input id="percent-input" type="percent" placeholder="0" [(value)]="percent" />
</div>
<div class="input-field">
@@ -310,9 +280,7 @@
/>
</div>
<app-button variant="secondary" (click)="clearFile()">
Limpiar archivo
</app-button>
<app-button variant="secondary" (click)="clearFile()"> Limpiar archivo </app-button>
</div>
</div>
@@ -321,8 +289,7 @@
<div class="paginator-showcase">
<h2>Paginator</h2>
<p class="text-muted mb-0">
Componente shared para navegar por paginas con primera, anterior,
siguiente y ultima.
Componente shared para navegar por paginas con primera, anterior, siguiente y ultima.
</p>
<app-paginator
[page]="currentPage"
@@ -333,6 +300,139 @@
<hr class="section-divider" />
<div class="stepper-showcase">
<h2>Form Stepper</h2>
<p class="text-muted mb-4">
Stepper vertical reutilizable. Cada paso maneja sus propios botones de navegacion. No se
puede avanzar si el paso no es valido.
</p>
<app-stepper #demoStepper>
<!-- Paso 1: Siempre valido -->
<app-step label="Informacion Personal" [isValid]="true">
<div class="row g-3">
<div class="col-md-6 input-field">
<label for="stepper-name">Nombre</label>
<app-input id="stepper-name" placeholder="Juan Perez" [(value)]="user" />
</div>
<div class="col-md-6 input-field">
<label for="stepper-email">Email</label>
<app-input
id="stepper-email"
type="email"
placeholder="mail@empresa.com"
[(value)]="email"
/>
</div>
</div>
<div class="d-flex justify-content-end mt-4">
<app-button (click)="demoStepper.next()">
Siguiente <i class="fa-solid fa-arrow-right ms-1"></i>
</app-button>
</div>
</app-step>
<!-- Paso 2: Valido solo si testStep2Valid es true -->
<app-step label="Configuracion de Cuenta" [isValid]="testStep2Valid">
<div class="row g-3">
<div class="col-md-6 input-field">
<label for="stepper-password">Contraseña</label>
<app-input
id="stepper-password"
type="password-toggle"
placeholder="********"
[(value)]="password"
/>
</div>
<div class="col-md-6 input-field">
<label for="stepper-amount">Presupuesto mensual</label>
<app-input id="stepper-amount" type="currency" placeholder="0" [(value)]="amount" />
</div>
</div>
<div class="stepper-validity-control mt-3 d-flex align-items-center gap-3">
<span class="text-muted small"> Simular validez del paso: </span>
<app-button
[variant]="testStep2Valid ? 'secondary' : 'primary'"
(click)="testStep2Valid = !testStep2Valid"
>
@if (testStep2Valid) {
<i class="fa-solid fa-check me-1"></i> Valido
} @else {
<i class="fa-solid fa-xmark me-1"></i> Invalido — Siguiente bloqueado
}
</app-button>
</div>
<div class="d-flex justify-content-between mt-4">
<app-button variant="secondary" (click)="demoStepper.previous()">
<i class="fa-solid fa-arrow-left me-1"></i> Volver
</app-button>
<app-button (click)="demoStepper.next()" [disabled]="!testStep2Valid">
Siguiente <i class="fa-solid fa-arrow-right ms-1"></i>
</app-button>
</div>
</app-step>
<!-- Paso 3: Revision final, siempre valido -->
<app-step label="Revision y Confirmacion" [isValid]="true">
<div class="p-3 rounded bg-light">
<p class="mb-1"><strong>Nombre:</strong> {{ user }}</p>
<p class="mb-1"><strong>Email:</strong> {{ email }}</p>
<p class="mb-0"><strong>Presupuesto:</strong> {{ amount }}</p>
</div>
<div class="d-flex justify-content-between mt-4">
<app-button variant="secondary" (click)="demoStepper.previous()">
<i class="fa-solid fa-arrow-left me-1"></i> Volver
</app-button>
<app-button (click)="triggerToast('success')">
<i class="fa-solid fa-check me-1"></i> Confirmar
</app-button>
</div>
</app-step>
</app-stepper>
</div>
<hr class="section-divider" />
<div class="accordion-showcase">
<h2>Accordion</h2>
<p class="text-muted mb-4">
Accordion reutilizable basado en las clases globales de Bootstrap. Por defecto mantiene un
solo item abierto.
</p>
<app-accordion>
<app-accordion-item title="¿Cómo realizo una compra?" [open]="true">
Elegí el producto, seleccioná sus variantes y agregalo al carrito. Desde allí podés
revisar el pedido y continuar al checkout.
</app-accordion-item>
<app-accordion-item title="¿Qué medios de pago están disponibles?">
Los medios de pago dependen de la configuración de cada tienda y se muestran durante el
checkout.
</app-accordion-item>
<app-accordion-item title="Item deshabilitado" [disabled]="true">
Este contenido no puede abrirse.
</app-accordion-item>
</app-accordion>
<h3 class="h5 mt-5 mb-3">Variante múltiple y flush</h3>
<app-accordion [allowMultiple]="true" [flush]="true">
<app-accordion-item title="Envíos">
La tienda informa los métodos y costos de envío disponibles para cada pedido.
</app-accordion-item>
<app-accordion-item title="Cambios y devoluciones">
Las condiciones de cambio y devolución son definidas por cada comercio.
</app-accordion-item>
</app-accordion>
</div>
<hr class="section-divider" />
<div class="cart-showcase">
<div class="cart-showcase__header">
<div>
@@ -512,20 +612,73 @@
</div>
</section>
<section class="component-demo card shadow-sm mt-4" data-testid="main-carousel">
<div class="card-body">
<span class="eyebrow">Reusable Component</span>
<h2 class="mb-3">Main Carousel</h2>
<p class="text-muted mb-4">
Carrusel panorámico compuesto únicamente por imágenes, con autoslide e indicadores.
</p>
<app-main-carousel
[images]="mainCarouselImages"
[imageAlts]="mainCarouselImageAlts"
[autoSlideInterval]="4000"
ariaLabel="Promociones de la tienda"
/>
</div>
</section>
<section class="component-demo card shadow-sm mt-4" data-testid="products-carousel">
<div class="card-body">
<span class="eyebrow">Reusable Component</span>
<h2 class="mb-3">Carousel de productos</h2>
<p class="text-muted mb-4">
El carousel controla únicamente el desplazamiento. Cada ítem se define externamente con un
template y en este ejemplo renderiza productos con imágenes mockeadas.
</p>
<ng-template #productCarouselItem let-product let-index="index">
<div class="carousel-product" [attr.data-carousel-index]="index">
<span class="carousel-product__mock-id">ITEM {{ product.id }}</span>
<app-product-column-with-image
[imageUrl]="product.imageUrl"
[title]="product.title"
[originalPrice]="product.originalPrice"
[discount]="product.discount"
[transferPrice]="product.transferPrice"
(buy)="onProductBuy(product.title)"
/>
</div>
</ng-template>
<app-carousel
[items]="carouselProducts"
[itemTemplate]="productCarouselItem"
[trackBy]="trackCarouselProduct"
[gap]="16"
[circular]="true"
ariaLabel="Productos con imágenes de prueba"
/>
</div>
</section>
<section class="component-demo card shadow-sm mt-4">
<div class="card-body">
<span class="eyebrow">Reusable Component</span>
<h2 class="mb-4">Tarjetas de Producto (ProductCard)</h2>
<h2 class="mb-4">Tarjetas de Producto (Column With Image)</h2>
<p class="text-muted mb-4">
Las tarjetas se adaptan al tamano del contenedor padre. A continuacion se
presentan dentro de una grilla de Bootstrap.
Las tarjetas se adaptan al tamano del contenedor padre. A continuacion se presentan dentro
de una grilla de Bootstrap.
</p>
<div class="row">
@for (product of testProducts; track product.title) {
<div class="col-12 col-md-6 col-lg-4 mb-4 d-flex align-items-stretch">
<app-product-card
<app-product-column-with-image
[imageUrl]="product.imageUrl"
[title]="product.title"
[originalPrice]="product.originalPrice"
@@ -539,16 +692,75 @@
</div>
</section>
<app-store-section
title="PRODUCTOS"
class="component-demo store-section-demo mt-5"
>
<section class="component-demo card shadow-sm mt-4" data-testid="location-map-demo">
<div class="card-body">
<span class="eyebrow">Reusable Component</span>
<h2 class="mb-2">Mapa de ubicaciones</h2>
<p class="text-muted mb-4">
Prueba de Leaflet con múltiples puntos, encuadre automático y acceso a indicaciones.
</p>
<app-location-map
[locations]="mapLocations"
[markerColor]="tenant()?.primary_color ?? '#24a9e0'"
mapStyle="minimal-light"
ariaLabel="Mapa de puntos de atención en Rosario"
/>
</div>
</section>
<section class="component-demo card shadow-sm mt-4">
<div class="card-body">
<span class="eyebrow">Reusable Component</span>
<h2 class="mb-4">Tarjetas de Producto (Row Layout)</h2>
<p class="text-muted mb-4">Diseño horizontal que ocupa el 100% del ancho disponible.</p>
<div class="row">
<div class="col-12 mb-4 d-flex align-items-stretch">
<app-product-row-card
[title]="testRowProduct.title"
[description]="testRowProduct.description"
[price]="testRowProduct.price"
[variants]="testRowProduct.variants"
(buy)="onProductBuy(testRowProduct.title)"
(addToCart)="triggerToast('success')"
/>
</div>
</div>
</div>
</section>
<section class="component-demo card shadow-sm mt-4">
<div class="card-body">
<span class="eyebrow">Reusable Component</span>
<h2 class="mb-4">Tarjetas de Producto (Vertical With Cart Layout)</h2>
<p class="text-muted mb-4">
Diseño vertical con selector de cantidad y acciones de compra y carrito.
</p>
<div class="row justify-content-center justify-content-md-start">
<div class="col-12 col-sm-9 col-md-6 col-lg-5 col-xl-4 mb-4 d-flex align-items-stretch">
<app-product-vertical-with-cart-card
[title]="testVerticalWithCartProduct.title"
[description]="testVerticalWithCartProduct.description"
[price]="testVerticalWithCartProduct.price"
(buy)="onProductBuy(testVerticalWithCartProduct.title)"
(addToCart)="triggerToast('success')"
/>
</div>
</div>
</div>
</section>
<app-store-section title="PRODUCTOS" class="component-demo store-section-demo mt-5">
<div class="row">
@for (product of testProducts; track product.title) {
<div
class="col-12 col-md-6 col-lg-4 mb-4 d-flex align-items-stretch store-section-demo__item"
>
<app-product-card
<app-product-column-with-image
[imageUrl]="product.imageUrl"
[title]="product.title"
[originalPrice]="product.originalPrice"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,33 @@
<div class="page-container">
<div class="d-flex align-items-center mb-4">
<a routerLink="../" class="text-decoration-none " >
<i class="bi bi-arrow-left fs-5"></i>
</a>
<h2 class="page-title m-0">MIS COMPRAS</h2>
</div>
@if (isLoading()) {
<p class="purchase-loading">Cargando detalle de compra...</p>
} @else if (purchase(); as purchase) {
<div class="d-flex justify-content-between align-items-center mb-4 pb-3" style="border-bottom: 1px solid #dddddd;">
<div class="purchase-info">
<span class="purchase-id">Compra {{ purchase.id }}.</span>
<span class="purchase-date">Fecha de compra: {{ purchase.date }}</span>
</div>
<div class="purchase-total ">
<span class="purchase-total-label">Total:</span>
<span class="purchase-total-value">${{ purchase.total }}</span>
</div>
</div>
<p style="font-size: 12px; color: #666666; margin:0;">Productos:</p>
<div class="d-flex flex-column">
@for (item of purchase.items; track item.id) {
<app-purchase-item [item]="item"></app-purchase-item>
} @empty {
<p class="purchase-empty">No hay productos para mostrar en esta compra.</p>
}
</div>
}
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,43 @@
import { HttpClient } from '@angular/common/http';
import { inject, Injectable } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { TenantService } from '../../../../../../core/services/tenant.service';
export interface TicketResponse {
id: number;
tenant_code: string;
ticket: string;
name: string;
description: string | null;
source_catalog_item_id: number | null;
source_variant_id: number | null;
starts_at: string | null;
expires_at: string | null;
used_at: string | null;
is_valid: boolean;
is_expired: boolean;
is_used: boolean;
}
@Injectable()
export class TicketService {
private readonly http = inject(HttpClient);
private readonly tenantService = inject(TenantService);
getTickets(): Promise<TicketResponse[]> {
return firstValueFrom(
this.http.get<{ data: TicketResponse[] }>(`${this.tenantService.getTenantApiUrl()}/tickets`),
).then((response) => response.data ?? []);
}
downloadPdf(ticketIds: number[]): Promise<Blob> {
return firstValueFrom(
this.http.post(
`${this.tenantService.getTenantApiUrl()}/tickets/pdf`,
{ ticket_ids: ticketIds },
{ responseType: 'blob' },
),
);
}
}

View File

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

View File

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

View File

@@ -0,0 +1,261 @@
import { By } from '@angular/platform-browser';
import { TestBed } from '@angular/core/testing';
import { describe, expect, it, vi } from 'vitest';
import { ToastService } from '../../../../../../core/services/toast.service';
import { ModalService } from '../../../../../../core/services/modal.service';
import { TicketComponent } from './components/ticket/ticket.component';
import { TicketResponse, TicketService } from './ticket.service';
import { TicketsPage } from './tickets-page';
const ticket = (overrides: Partial<TicketResponse>): TicketResponse => ({
id: 1,
tenant_code: 'tenant',
ticket: 'ticket-1',
name: 'Entrada general',
description: null,
source_catalog_item_id: null,
source_variant_id: null,
starts_at: '2026-10-09T00:00:00.000Z',
expires_at: null,
used_at: null,
is_valid: true,
is_expired: false,
is_used: false,
...overrides,
});
describe('TicketsPage', () => {
it('shares the generated PDF as a file without text that Windows could prioritize', async () => {
const share = vi.fn().mockResolvedValue(undefined);
const canShare = vi.fn().mockReturnValue(true);
vi.stubGlobal('navigator', { canShare, share });
await TestBed.configureTestingModule({
imports: [TicketsPage],
providers: [{ provide: ToastService, useValue: { danger: vi.fn() } }],
})
.overrideComponent(TicketsPage, {
set: {
providers: [
{
provide: TicketService,
useValue: {
getTickets: () => Promise.resolve([ticket({})]),
downloadPdf: () => Promise.resolve(new Blob(['pdf-content'])),
},
},
],
},
})
.compileComponents();
const fixture = TestBed.createComponent(TicketsPage);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const page = fixture.nativeElement as HTMLElement;
page.querySelector<HTMLButtonElement>('[aria-label="Compartir ticket"]')?.click();
await vi.waitFor(() => expect(share).toHaveBeenCalledOnce());
const shareData = share.mock.calls[0][0] as ShareData;
const [sharedFile] = shareData.files ?? [];
expect(Object.keys(shareData)).toEqual(['files']);
expect(canShare).toHaveBeenCalledWith({ files: [sharedFile] });
expect(sharedFile).toBeInstanceOf(File);
expect(sharedFile.name).toBe('tickets_1.pdf');
expect(sharedFile.type).toBe('application/pdf');
expect(sharedFile.size).toBeGreaterThan(0);
vi.unstubAllGlobals();
});
it('shows a loading overlay and disables PDF actions while the file is generated', async () => {
let resolvePdf!: (pdf: Blob) => void;
const downloadPdf = vi.fn(
() =>
new Promise<Blob>((resolve) => {
resolvePdf = resolve;
}),
);
const createObjectUrl = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:ticket');
const revokeObjectUrl = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined);
let downloadedFilename = '';
const linkClick = vi
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(function (this: HTMLAnchorElement) {
downloadedFilename = this.download;
});
await TestBed.configureTestingModule({
imports: [TicketsPage],
providers: [{ provide: ToastService, useValue: { danger: vi.fn() } }],
})
.overrideComponent(TicketsPage, {
set: {
providers: [
{
provide: TicketService,
useValue: {
getTickets: () => Promise.resolve([ticket({})]),
downloadPdf,
},
},
],
},
})
.compileComponents();
const fixture = TestBed.createComponent(TicketsPage);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const page = fixture.nativeElement as HTMLElement;
page.querySelector<HTMLButtonElement>('[aria-label="Descargar ticket"]')?.click();
fixture.detectChanges();
expect(page.querySelector('.tickets-list__generating')?.textContent).toContain(
'Generando PDF...',
);
expect(page.querySelector('.tickets-list')?.getAttribute('aria-busy')).toBe('true');
expect(page.querySelector<HTMLButtonElement>('[aria-label="Descargar ticket"]')?.disabled).toBe(
true,
);
expect(page.querySelector<HTMLButtonElement>('[aria-label="Compartir ticket"]')?.disabled).toBe(
true,
);
resolvePdf(new Blob(['pdf'], { type: 'application/pdf' }));
await fixture.whenStable();
await vi.waitFor(() => expect(downloadedFilename).toBe('tickets_1.pdf'));
fixture.detectChanges();
expect(page.querySelector('.tickets-list__generating')).toBeNull();
expect(page.querySelector('.tickets-list')?.getAttribute('aria-busy')).toBe('false');
createObjectUrl.mockRestore();
revokeObjectUrl.mockRestore();
linkClick.mockRestore();
});
it('hides the active tickets header when there are no available tickets', async () => {
await TestBed.configureTestingModule({
imports: [TicketsPage],
providers: [{ provide: ToastService, useValue: { danger: vi.fn() } }],
})
.overrideComponent(TicketsPage, {
set: {
providers: [
{ provide: TicketService, useValue: { getTickets: () => Promise.resolve([]) } },
],
},
})
.compileComponents();
const fixture = TestBed.createComponent(TicketsPage);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const page = fixture.nativeElement as HTMLElement;
expect(page.querySelector('.tickets-list__select-all')).toBeNull();
expect(page.querySelector('.tickets-list__bulk-actions')).toBeNull();
expect(page.textContent).not.toContain('TICKETS ACTIVOS');
expect(page.textContent).toContain('Aún no tenés tickets disponibles.');
});
it('separates expired and used tickets into the disabled inactive list', async () => {
const tickets = [
ticket({ id: 1 }),
ticket({ id: 2, is_valid: false, is_expired: true }),
ticket({ id: 3, is_valid: false, is_used: true, used_at: '2026-10-10T00:00:00.000Z' }),
];
await TestBed.configureTestingModule({
imports: [TicketsPage],
providers: [{ provide: ToastService, useValue: { danger: vi.fn() } }],
})
.overrideComponent(TicketsPage, {
set: {
providers: [
{ provide: TicketService, useValue: { getTickets: () => Promise.resolve(tickets) } },
],
},
})
.compileComponents();
const fixture = TestBed.createComponent(TicketsPage);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const ticketComponents = fixture.debugElement.queryAll(By.directive(TicketComponent));
const [activeTicket, expiredTicket, usedTicket] = ticketComponents.map(
(element) => element.componentInstance as TicketComponent,
);
expect(ticketComponents).toHaveLength(3);
expect(activeTicket.disabled()).toBe(false);
expect(expiredTicket.disabled()).toBe(true);
expect(expiredTicket.expired()).toBe(true);
expect(usedTicket.disabled()).toBe(true);
expect(usedTicket.expired()).toBe(false);
expect(fixture.nativeElement.textContent).toContain('TICKETS INACTIVOS');
const page = fixture.nativeElement as HTMLElement;
const inactiveElements = Array.from(
page.querySelectorAll<HTMLElement>('.tickets-list__inactive app-ticket'),
);
expect(inactiveElements).toHaveLength(2);
inactiveElements.forEach((element) => {
expect(element.querySelector('.ticket--disabled')).not.toBeNull();
expect(element.querySelector<HTMLButtonElement>('app-button button')?.disabled).toBe(true);
expect(element.querySelector('input[type="checkbox"]')).toBeNull();
expect(element.querySelector('[aria-label="Descargar ticket"]')).toBeNull();
expect(element.querySelector('[aria-label="Compartir ticket"]')).toBeNull();
});
});
it('opens the QR modal with the selected ticket data', async () => {
const modalService = { openQr: vi.fn() };
await TestBed.configureTestingModule({
imports: [TicketsPage],
providers: [
{ provide: ToastService, useValue: { danger: vi.fn() } },
{ provide: ModalService, useValue: modalService },
],
})
.overrideComponent(TicketsPage, {
set: {
providers: [
{
provide: TicketService,
useValue: { getTickets: () => Promise.resolve([ticket({})]) },
},
],
},
})
.compileComponents();
const fixture = TestBed.createComponent(TicketsPage);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const page = fixture.nativeElement as HTMLElement;
page.querySelector<HTMLButtonElement>('app-ticket app-button button')?.click();
expect(modalService.openQr).toHaveBeenCalledWith({
title: 'Entrada general',
id: 1,
date: '09-octubre',
ticket: 'ticket-1',
});
});
});

View File

@@ -0,0 +1,177 @@
import { Component, computed, inject, OnInit, signal } from '@angular/core';
import { ModalService } from '../../../../../../core/services/modal.service';
import { ToastService } from '../../../../../../core/services/toast.service';
import { IconButtonComponent } from '../../../../../../shared/components/icon-button/icon-button.component';
import { TicketComponent } from './components/ticket/ticket.component';
import { TicketResponse, TicketService } from './ticket.service';
@Component({
selector: 'app-tickets-page',
imports: [IconButtonComponent, TicketComponent],
providers: [TicketService],
templateUrl: './tickets-page.html',
styleUrl: './tickets-page.scss',
})
export class TicketsPage implements OnInit {
private readonly ticketService = inject(TicketService);
private readonly toastService = inject(ToastService);
private readonly modalService = inject(ModalService);
protected readonly tickets = signal<TicketResponse[]>([]);
protected readonly activeTickets = computed(() =>
this.tickets().filter((ticket) => !this.isInactive(ticket)),
);
protected readonly inactiveTickets = computed(() =>
this.tickets().filter((ticket) => this.isInactive(ticket)),
);
protected readonly selectedIds = signal<Set<number>>(new Set());
protected readonly isLoading = signal(true);
protected readonly isGeneratingPdf = signal(false);
protected readonly allSelected = computed(
() =>
this.activeTickets().length > 0 && this.selectedIds().size === this.activeTickets().length,
);
async ngOnInit(): Promise<void> {
try {
this.tickets.set(await this.ticketService.getTickets());
} catch {
this.toastService.danger('Hubo un error al cargar los tickets');
} finally {
this.isLoading.set(false);
}
}
protected toggleAll(selected: boolean): void {
this.selectedIds.set(
selected ? new Set(this.activeTickets().map((ticket) => ticket.id)) : new Set(),
);
}
protected toggleTicket(ticketId: number, selected: boolean): void {
this.selectedIds.update((current) => {
const next = new Set(current);
selected ? next.add(ticketId) : next.delete(ticketId);
return next;
});
}
protected isSelected(ticketId: number): boolean {
return this.selectedIds().has(ticketId);
}
protected isInactive(ticket: TicketResponse): boolean {
return ticket.is_expired || ticket.is_used;
}
protected formatDate(ticket: TicketResponse): string | null {
const value = ticket.starts_at ?? ticket.expires_at;
if (!value) return null;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return null;
return new Intl.DateTimeFormat('es-AR', {
day: '2-digit',
month: 'long',
timeZone: 'UTC',
}).format(date);
}
protected viewQr(ticket: TicketResponse): void {
this.modalService.openQr({
title: ticket.name,
id: ticket.id,
date: this.formatDate(ticket) ?? '',
ticket: ticket.ticket,
});
}
protected async downloadTicket(ticket: TicketResponse): Promise<void> {
await this.downloadTickets([ticket]);
}
protected async downloadSelected(): Promise<void> {
const tickets = this.activeTickets().filter((ticket) => this.selectedIds().has(ticket.id));
if (!tickets.length) {
this.toastService.danger('Seleccioná al menos un ticket para descargar.');
return;
}
await this.downloadTickets(tickets);
}
protected async shareTicket(ticket: TicketResponse): Promise<void> {
await this.shareTickets([ticket]);
}
protected async shareSelected(): Promise<void> {
const tickets = this.activeTickets().filter((ticket) => this.selectedIds().has(ticket.id));
if (!tickets.length) {
this.toastService.danger('Seleccioná al menos un ticket para compartir.');
return;
}
await this.shareTickets(tickets);
}
private async downloadTickets(tickets: TicketResponse[]): Promise<void> {
const pdf = await this.generateTicketsPdf(tickets);
if (pdf) this.downloadPdf(pdf, this.pdfFilename(tickets));
}
private async shareTickets(tickets: TicketResponse[]): Promise<void> {
const pdf = await this.generateTicketsPdf(tickets);
if (!pdf) return;
try {
const filename = this.pdfFilename(tickets);
const file = new File([pdf], filename, { type: 'application/pdf' });
if (navigator.canShare?.({ files: [file] })) {
await navigator.share({ files: [file] });
return;
}
this.downloadPdf(pdf, filename);
this.toastService.success('Tu navegador no permite compartir archivos; descargamos el PDF.');
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') return;
this.toastService.danger('No se pudo compartir el PDF de los tickets.');
}
}
private async generateTicketsPdf(tickets: TicketResponse[]): Promise<Blob | null> {
if (this.isGeneratingPdf()) return null;
this.isGeneratingPdf.set(true);
try {
return await this.ticketService.downloadPdf(tickets.map((ticket) => ticket.id));
} catch {
this.toastService.danger('No se pudo generar el PDF de los tickets.');
return null;
} finally {
this.isGeneratingPdf.set(false);
}
}
private pdfFilename(tickets: TicketResponse[]): string {
return `tickets_${tickets.map((ticket) => ticket.id).join('_')}.pdf`;
}
private downloadPdf(pdf: Blob, filename: string): void {
const url = URL.createObjectURL(pdf);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 0);
}
}

View File

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

View File

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

View File

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

View File

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

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