Compare commits
370 Commits
7ee103b77b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d8d489e846 | |||
| 4183d6386d | |||
| 8089298383 | |||
| d51db00247 | |||
| 88c3a08a23 | |||
| 21e89805d0 | |||
| 195ac73aed | |||
| c24a8b946d | |||
| 70060f65ec | |||
| 02ff829c06 | |||
| 69c836a578 | |||
| 87cc430f05 | |||
| 3e9e35c681 | |||
| 0bd6dc3b22 | |||
| f056c32f49 | |||
| 11df4dbe72 | |||
| 9b69a1d387 | |||
| 4251ca8a8a | |||
| b14f34d3e8 | |||
| d636b2b82b | |||
| cfa1091116 | |||
| d0b1607ff4 | |||
| 92546a28c0 | |||
| ce26fb5d1a | |||
| 38a57adf55 | |||
| e1d9590905 | |||
| 00a01e1a8e | |||
| 56e011bc7a | |||
| 5c6a03b10d | |||
| cc446433f6 | |||
| 2d0664a7c7 | |||
| 2192eea09f | |||
| 8659708e1a | |||
| dce54ceb67 | |||
| cab33d4842 | |||
| f318f614af | |||
| 581696ffab | |||
| 53cee7ecdd | |||
| 757f362918 | |||
| a8e73af806 | |||
| 61789d9238 | |||
| dd2082f40e | |||
| 755d6a9903 | |||
| c486131905 | |||
| 9ba2041f7b | |||
| 92ad7340fe | |||
| 6b2f8de210 | |||
| 69f3569ccf | |||
| 9937c1b484 | |||
| 43e497cf97 | |||
| 207c979f71 | |||
| 30bfd9eff8 | |||
| 3f8b5bf206 | |||
| d92b5a7623 | |||
| e4670491c7 | |||
| 1b94b2ca13 | |||
| 46dcb84985 | |||
| f7983b109f | |||
| 8ff9ed216c | |||
| f80d146012 | |||
| 1cb2cf3d54 | |||
| 97250d10b7 | |||
| 15979874ce | |||
| 0defe75adb | |||
| 34afb5142d | |||
| 8eba15155e | |||
| 85c31579e2 | |||
| 159ec36cb2 | |||
| 32b1eb5639 | |||
| 0f9d3e79d4 | |||
| b97753f825 | |||
| f1b724ed63 | |||
| 571cae0f98 | |||
| 30b8527c9a | |||
| 0e5f6dceb0 | |||
| cd0a6e9b5e | |||
| 1bbb3d0914 | |||
| e4157a4005 | |||
| c9f8a1c4a6 | |||
| 308af52ad5 | |||
| ae6728998d | |||
| f1b829264f | |||
| fccb7676bf | |||
| 3adc04b68d | |||
| 12e2870d40 | |||
| 0b53253461 | |||
| 6cc0fcd9c0 | |||
| dc6668df72 | |||
| f428a88ba8 | |||
| e21c5e9e9f | |||
| f7a8b42892 | |||
| 9f8e42c30b | |||
| d45199a0ef | |||
| c1da31711f | |||
| cf61611d76 | |||
| 2c4d22f0b9 | |||
| caa1fddcc1 | |||
| 5f0c99c395 | |||
| 832fad19bc | |||
| 28ed5d71b2 | |||
| 23a5ac32cc | |||
| 1b19d36e26 | |||
| 86533b9ab4 | |||
| 369cfb0a40 | |||
| 7b0c9831f1 | |||
| ede2173a5a | |||
| 5d760bfd80 | |||
| e3599a48e4 | |||
| 5d629e27b1 | |||
| 563e4634d1 | |||
| c75787a59c | |||
| f6f3a997f7 | |||
| 019c9df23b | |||
| 9ea85b7924 | |||
| 0769bb8683 | |||
| c645feba80 | |||
| 56f602d876 | |||
| d6bab24e1d | |||
| dd828d9a4e | |||
| 7c2a098696 | |||
| 1977df6765 | |||
| c6ac974459 | |||
| 8d88e1bb13 | |||
| 50f986dd2f | |||
| 393aeec7a5 | |||
| a012f94198 | |||
| 6b9087262a | |||
| 4b98df4058 | |||
| 2b3e054ccf | |||
| 1b238cb1bc | |||
| c9f9e6cc3e | |||
| 5da341dde2 | |||
| 21786d7b34 | |||
| 8aea54959f | |||
| 12ca45bdd8 | |||
| fa6fc53f8d | |||
| 78970573a8 | |||
| f289e9e2e4 | |||
| 2487546a75 | |||
| cc884e3cc0 | |||
| ffb586c348 | |||
| ec18aaf0c5 | |||
| 941cb5c73c | |||
| 934bd07105 | |||
| 8c2f00bcbc | |||
| da61067c6a | |||
| 125d9137fb | |||
| 1fb9789abf | |||
| 7ebf383c89 | |||
| c14f740dbb | |||
| 109a1cab5a | |||
| 3ab73a395a | |||
| 9bb3374ba0 | |||
| 76327d34ec | |||
| 4cb81ed70d | |||
| c233845ce0 | |||
| 15a74bb56e | |||
| 58e9dc06b1 | |||
| b96d6f23b6 | |||
| 0726b2219e | |||
| 7845c8f20e | |||
| c41c60f349 | |||
| 163444c91f | |||
| 4f1405f6c1 | |||
| f063bac527 | |||
| 902f65d8d0 | |||
| 9dcedc9382 | |||
| 565bc769ed | |||
| 4e06145f44 | |||
| 9bb4ff61f7 | |||
| 79a6edc3f3 | |||
| 5d5146d784 | |||
| 34d351afc5 | |||
| 9845a82f4a | |||
| f4e854843e | |||
| 752c5fceea | |||
| 974f947fc6 | |||
| fa0e9de886 | |||
| 656e41d189 | |||
| 63ae02df22 | |||
| 1d5c94b9bc | |||
| afaa761acc | |||
| d50bd40602 | |||
| aa51742627 | |||
| 21119ff303 | |||
| 44bcb9aeba | |||
| 67702ffdd7 | |||
| b04c33bbff | |||
| 5d1ed7efa2 | |||
| 37bbd65cfe | |||
| 862751fec8 | |||
| 251411d200 | |||
| 4f444fd757 | |||
| ca1cacc9ea | |||
| ea0bd4a956 | |||
| 8963bd5460 | |||
| a1d5ecb6fa | |||
| fb6ab2fca2 | |||
| 326f2d9bcd | |||
| 8268e58547 | |||
| 3e98d08d59 | |||
| 20c8b04638 | |||
| c662c27fc7 | |||
| 3cefaf85d9 | |||
| 472dead0ff | |||
| fa8032c557 | |||
| 6c5e10feb2 | |||
| 63541a3593 | |||
| bc37a91c7f | |||
| 54826a7f8a | |||
| 8f57af3cf5 | |||
| da6614a9f8 | |||
| 8e44fa5a46 | |||
| 938d15bf83 | |||
| dafaa85480 | |||
| 98e899c5bf | |||
| 0c0d2b559d | |||
| a233a93d16 | |||
| b0e97fc577 | |||
| 5165ea0716 | |||
| c0eb3369c5 | |||
| 625990f594 | |||
| d4c671dc7a | |||
| 944c12a7f3 | |||
| 9a4e2a03e9 | |||
| 40cc2b840d | |||
| 45c2ee7a2f | |||
| b64c627702 | |||
| fc425543ee | |||
| 36045656e7 | |||
| ada832cbba | |||
| 524eea38f4 | |||
| f163e8a353 | |||
| 8b785fd631 | |||
| e4195a8567 | |||
| 25085f6f85 | |||
| 257e4fbeb8 | |||
| cc87169c1c | |||
| e383a8f159 | |||
| 993aa5823a | |||
| 6c00a3e2bb | |||
| efa0d48a40 | |||
| 9fcbf34573 | |||
| 451dc58cd7 | |||
| 26466f57db | |||
| a9dc55c274 | |||
| 5e9a5917fd | |||
| e06edaaa42 | |||
| 1f2ca09b81 | |||
| 56355569f2 | |||
| 036b2a950f | |||
| 7507580c2c | |||
| b016569b10 | |||
| 23f48ccec0 | |||
| 6a5683f6b7 | |||
| b7ed16966c | |||
| baf5e2d4e7 | |||
| 64ade4d595 | |||
| b9b8a70ca1 | |||
| 28ffe3c788 | |||
| 4969026fe9 | |||
| 09d88e6324 | |||
| 1a2d303b4e | |||
| 3579abdecb | |||
| f6ac1b8727 | |||
| eb73398397 | |||
| 1af478d65e | |||
| 7e8a638d01 | |||
| 639004c8c6 | |||
| 17ef39e9a8 | |||
| fff18e05c0 | |||
| c9d0e5ad01 | |||
| 4885d39284 | |||
| 9d42de9fa8 | |||
| bdb7f5e907 | |||
| e8d71bfd03 | |||
| 05ada5f613 | |||
| 5969818478 | |||
| c54eb105cb | |||
| e2605116c5 | |||
| d1e20460ac | |||
| 910f367851 | |||
| cb0d1e4d77 | |||
| 850f93e2ba | |||
| 53278719a8 | |||
| 8ceef31785 | |||
| a642ae84d5 | |||
| 09fa876da3 | |||
| 69fee4e20a | |||
| 1df6964859 | |||
| 3971970975 | |||
| 0a2d999c00 | |||
| 37d94159aa | |||
| 361596bbbc | |||
| 97a359d65c | |||
| 811f127deb | |||
| bfbbfb9c09 | |||
| 41bb5e7c9e | |||
| be041f6e88 | |||
| 45f0618ae9 | |||
| 2b3713f6a8 | |||
| 510c72624b | |||
| 625e2fea6c | |||
| 3697c4b97c | |||
| bedb74ebd5 | |||
| 6bab2e9ab2 | |||
| bd4b86fe85 | |||
| 6ffffc6ea8 | |||
| 57f58c92be | |||
| 5365811a50 | |||
| 3c98600193 | |||
| ff3c3233c1 | |||
| 20e8578440 | |||
| 193ba7472b | |||
| bc9390394a | |||
| 4393280fd9 | |||
| 956411254e | |||
| 4cbf87feb1 | |||
| 93d49fc7e6 | |||
| 85465ce7e1 | |||
| ee83e7ab32 | |||
| bf5fd83f98 | |||
| 446d1b8970 | |||
| b3782a6cc1 | |||
| d31cb65d65 | |||
| c9d8265790 | |||
| f2cc42e0cc | |||
| 5ac6704300 | |||
| 4817dd1f76 | |||
| 16eea32ce7 | |||
| 8a6ed763df | |||
| 0a7a9d91c7 | |||
| 202205c038 | |||
| 394a1dde0d | |||
| 8471505e6f | |||
| 06d29be056 | |||
| af406c215a | |||
| ac9d1fdc85 | |||
| cb77ef9d20 | |||
| 8f7f9445b7 | |||
| ad39ccb561 | |||
| b61ae3cb32 | |||
| 3b7615fd85 | |||
| 0363280ede | |||
| 2764201ca0 | |||
| ae0690a6f4 | |||
| 3ad29f959d | |||
| 1a7a6c28e6 | |||
| 41b51f06e7 | |||
| 526e8fa76e | |||
| b7420c2110 | |||
| 8d9a828df0 | |||
| 6cba0cf779 | |||
| 57a1e66d1a | |||
| ec261caab3 | |||
| cc16a53fac | |||
| bf2ede4bc1 | |||
| 79d4de6c04 | |||
| b8f4d06caa | |||
| 74144b6d2c | |||
| b2796a2383 | |||
| 4bb69d1b06 | |||
| 5715977b25 | |||
| fe4eebb685 | |||
| 286e56c979 | |||
| 9f32588828 | |||
| 77e4a22740 | |||
| d79d075c45 | |||
| 8f56785bc3 | |||
| 2cbe681995 |
2
.vscode/launch.json
vendored
@@ -7,7 +7,7 @@
|
|||||||
"type": "chrome",
|
"type": "chrome",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"preLaunchTask": "npm: start",
|
"preLaunchTask": "npm: start",
|
||||||
"url": "http://localhost:4200/"
|
"url": "http://localhost:4300/"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "ng test",
|
"name": "ng test",
|
||||||
|
|||||||
60
angular.json
@@ -2,7 +2,8 @@
|
|||||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"cli": {
|
"cli": {
|
||||||
"packageManager": "npm"
|
"packageManager": "npm",
|
||||||
|
"analytics": false
|
||||||
},
|
},
|
||||||
"newProjectRoot": "projects",
|
"newProjectRoot": "projects",
|
||||||
"projects": {
|
"projects": {
|
||||||
@@ -29,13 +30,16 @@
|
|||||||
"input": "public"
|
"input": "public"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"styles": [
|
"styles": ["node_modules/leaflet/dist/leaflet.css", "src/styles.scss"],
|
||||||
"src/styles.scss"
|
|
||||||
],
|
|
||||||
"server": "src/main.server.ts",
|
"server": "src/main.server.ts",
|
||||||
"outputMode": "server",
|
"outputMode": "server",
|
||||||
"security": {
|
"security": {
|
||||||
"allowedHosts": ["localhost", "127.0.0.1"]
|
"allowedHosts": [
|
||||||
|
"localhost",
|
||||||
|
"localhost:4300",
|
||||||
|
"127.0.0.1",
|
||||||
|
"127.0.0.1:4300"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"ssr": {
|
"ssr": {
|
||||||
"entry": "src/server.ts"
|
"entry": "src/server.ts"
|
||||||
@@ -43,6 +47,9 @@
|
|||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
|
"optimization": true,
|
||||||
|
"extractLicenses": true,
|
||||||
|
"sourceMap": false,
|
||||||
"budgets": [
|
"budgets": [
|
||||||
{
|
{
|
||||||
"type": "initial",
|
"type": "initial",
|
||||||
@@ -55,27 +62,64 @@
|
|||||||
"maximumError": "8kB"
|
"maximumError": "8kB"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"outputHashing": "all"
|
"outputHashing": "all",
|
||||||
|
"fileReplacements": [
|
||||||
|
{
|
||||||
|
"replace": "src/environments/environment.ts",
|
||||||
|
"with": "src/environments/environment.production.ts"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"development": {
|
"development": {
|
||||||
"optimization": false,
|
"optimization": true,
|
||||||
"extractLicenses": false,
|
"extractLicenses": false,
|
||||||
"sourceMap": true
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"local": {
|
||||||
|
"optimization": false,
|
||||||
|
"extractLicenses": false,
|
||||||
|
"sourceMap": true,
|
||||||
|
"fileReplacements": [
|
||||||
|
{
|
||||||
|
"replace": "src/environments/environment.ts",
|
||||||
|
"with": "src/environments/environment.local.ts"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"homo": {
|
||||||
|
"optimization": true,
|
||||||
|
"extractLicenses": true,
|
||||||
|
"sourceMap": false,
|
||||||
|
"fileReplacements": [
|
||||||
|
{
|
||||||
|
"replace": "src/environments/environment.ts",
|
||||||
|
"with": "src/environments/environment.homo.ts"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"defaultConfiguration": "production"
|
"defaultConfiguration": "production"
|
||||||
},
|
},
|
||||||
"serve": {
|
"serve": {
|
||||||
"builder": "@angular/build:dev-server",
|
"builder": "@angular/build:dev-server",
|
||||||
|
"options": {
|
||||||
|
"port": 4300
|
||||||
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
"buildTarget": "shopit-front:build:production"
|
"buildTarget": "shopit-front:build:production"
|
||||||
},
|
},
|
||||||
"development": {
|
"development": {
|
||||||
"buildTarget": "shopit-front:build:development"
|
"buildTarget": "shopit-front:build:development"
|
||||||
|
},
|
||||||
|
"local": {
|
||||||
|
"buildTarget": "shopit-front:build:local"
|
||||||
|
},
|
||||||
|
"homo": {
|
||||||
|
"buildTarget": "shopit-front:build:homo"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"defaultConfiguration": "development"
|
"defaultConfiguration": "local"
|
||||||
},
|
},
|
||||||
"test": {
|
"test": {
|
||||||
"builder": "@angular/build:unit-test"
|
"builder": "@angular/build:unit-test"
|
||||||
|
|||||||
323
package-lock.json
generated
@@ -19,6 +19,8 @@
|
|||||||
"@fortawesome/fontawesome-free": "^7.2.0",
|
"@fortawesome/fontawesome-free": "^7.2.0",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
|
"qrcode": "1.5.4",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
},
|
},
|
||||||
@@ -27,6 +29,7 @@
|
|||||||
"@angular/cli": "^22.0.4",
|
"@angular/cli": "^22.0.4",
|
||||||
"@angular/compiler-cli": "^22.0.0",
|
"@angular/compiler-cli": "^22.0.0",
|
||||||
"@types/express": "^5.0.1",
|
"@types/express": "^5.0.1",
|
||||||
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/node": "^20.17.19",
|
"@types/node": "^20.17.19",
|
||||||
"jsdom": "^28.0.0",
|
"jsdom": "^28.0.0",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
@@ -3815,6 +3818,13 @@
|
|||||||
"@types/send": "*"
|
"@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": {
|
"node_modules/@types/http-errors": {
|
||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
||||||
@@ -3822,6 +3832,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/node": {
|
||||||
"version": "20.19.43",
|
"version": "20.19.43",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
|
||||||
@@ -4394,6 +4414,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001799",
|
"version": "1.0.30001799",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz",
|
"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"
|
"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": {
|
"node_modules/content-disposition": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
|
"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": {
|
"node_modules/decimal.js": {
|
||||||
"version": "10.6.0",
|
"version": "10.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||||
@@ -4786,6 +4842,12 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/dom-serializer": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||||
@@ -5237,6 +5299,19 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/forwarded": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||||
@@ -5306,7 +5381,6 @@
|
|||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "6.* || 8.* || >= 10.*"
|
"node": "6.* || 8.* || >= 10.*"
|
||||||
@@ -5881,6 +5955,12 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/listr2": {
|
||||||
"version": "10.2.1",
|
"version": "10.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/listr2/-/listr2-10.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/listr2/-/listr2-10.2.1.tgz",
|
||||||
@@ -5927,6 +6007,18 @@
|
|||||||
"@lmdb/lmdb-win32-x64": "3.5.4"
|
"@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": {
|
"node_modules/log-symbols": {
|
||||||
"version": "7.0.1",
|
"version": "7.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz",
|
||||||
@@ -6703,6 +6795,33 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"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": {
|
"node_modules/p-map": {
|
||||||
"version": "7.0.4",
|
"version": "7.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz",
|
||||||
@@ -6716,6 +6835,15 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/pacote": {
|
||||||
"version": "21.5.1",
|
"version": "21.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/pacote/-/pacote-21.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/pacote/-/pacote-21.5.1.tgz",
|
||||||
@@ -6824,6 +6952,15 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/path-key": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||||
@@ -6921,6 +7058,15 @@
|
|||||||
"node": ">=16.20.0"
|
"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": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.15",
|
"version": "8.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||||
@@ -7033,6 +7179,154 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/qs": {
|
||||||
"version": "6.15.2",
|
"version": "6.15.2",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
||||||
@@ -7093,6 +7387,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0"
|
"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": {
|
"node_modules/require-from-string": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||||
@@ -7103,6 +7406,12 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/restore-cursor": {
|
||||||
"version": "5.1.0",
|
"version": "5.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz",
|
||||||
@@ -7325,6 +7634,12 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/setprototypeof": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||||
@@ -8690,6 +9005,12 @@
|
|||||||
"node": ">= 8"
|
"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": {
|
"node_modules/why-is-node-running": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||||
|
|||||||
@@ -23,6 +23,8 @@
|
|||||||
"@fortawesome/fontawesome-free": "^7.2.0",
|
"@fortawesome/fontawesome-free": "^7.2.0",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
|
"qrcode": "1.5.4",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
},
|
},
|
||||||
@@ -31,6 +33,7 @@
|
|||||||
"@angular/cli": "^22.0.4",
|
"@angular/cli": "^22.0.4",
|
||||||
"@angular/compiler-cli": "^22.0.0",
|
"@angular/compiler-cli": "^22.0.0",
|
||||||
"@types/express": "^5.0.1",
|
"@types/express": "^5.0.1",
|
||||||
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/node": "^20.17.19",
|
"@types/node": "^20.17.19",
|
||||||
"jsdom": "^28.0.0",
|
"jsdom": "^28.0.0",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
|
|||||||
BIN
public/images/carousel-auriculares-amarillos.webp
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
public/images/carousel-camara-violeta.webp
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
public/images/carousel-campera-azul.webp
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
public/images/carousel-mochila-roja.webp
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
public/images/carousel-reloj-verde.webp
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
public/images/carousel-zapatillas-naranjas.webp
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
public/images/pantalon-scuba.png
Normal file
|
After Width: | Height: | Size: 647 KiB |
BIN
public/images/ticket-selector-entrada-pasarela.png
Normal file
|
After Width: | Height: | Size: 333 KiB |
BIN
public/images/zapatillas-running.png
Normal file
|
After Width: | Height: | Size: 541 KiB |
28
src/app/app.config.spec.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { HttpRequest } from '@angular/common/http';
|
||||||
|
|
||||||
|
import { isStoreCatalogRequest } from './app.config';
|
||||||
|
|
||||||
|
describe('isStoreCatalogRequest', () => {
|
||||||
|
it.each([
|
||||||
|
'http://localhost:8000/api/tenants/demo/catalog',
|
||||||
|
'http://localhost:8000/api/tenants/demo/catalog?currency=ARS',
|
||||||
|
'http://localhost:8000/api/tenants/demo/catalog/featured-groups/7/items?page=2',
|
||||||
|
'http://localhost:8000/api/tenants/demo/catalog-items?q=remera&page=1',
|
||||||
|
'http://localhost:8000/api/tenants/demo/catalog-items/42?variant_id=3',
|
||||||
|
])('includes GET %s in the hydration transfer cache', (url) => {
|
||||||
|
expect(isStoreCatalogRequest(new HttpRequest('GET', url))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['POST', 'http://localhost:8000/api/tenants/demo/catalog-items'],
|
||||||
|
['GET', 'http://localhost:8000/api/tenants/demo/productos'],
|
||||||
|
['GET', 'http://localhost:8000/api/tenants/demo/categories/7'],
|
||||||
|
['GET', 'http://localhost:8000/api/tenants/demo/catalog-items/42/variant-options'],
|
||||||
|
['GET', 'http://localhost:8000/api/tenants/demo/catalogue'],
|
||||||
|
])('excludes %s %s from the hydration transfer cache', (method, url) => {
|
||||||
|
const request =
|
||||||
|
method === 'GET' ? new HttpRequest('GET', url) : new HttpRequest('POST', url, null);
|
||||||
|
|
||||||
|
expect(isStoreCatalogRequest(request)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,17 +1,44 @@
|
|||||||
import { provideHttpClient } from '@angular/common/http';
|
import { HttpRequest, provideHttpClient, withFetch, withInterceptors } from '@angular/common/http';
|
||||||
import { ApplicationConfig, provideAppInitializer, provideBrowserGlobalErrorListeners } from '@angular/core';
|
import {
|
||||||
import { provideRouter } from '@angular/router';
|
ApplicationConfig,
|
||||||
import { provideClientHydration } from '@angular/platform-browser';
|
provideAppInitializer,
|
||||||
|
provideBrowserGlobalErrorListeners,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { provideRouter, UrlSerializer } from '@angular/router';
|
||||||
|
import { provideClientHydration, withHttpTransferCacheOptions } from '@angular/platform-browser';
|
||||||
|
|
||||||
import { routes } from './app.routes';
|
import { routes } from './app.routes';
|
||||||
|
import { authBootstrap } from './core/services/auth/auth-bootstrap';
|
||||||
|
import { authInterceptor } from './core/services/auth/auth.interceptor';
|
||||||
|
import { globalLoadingInterceptor } from './core/services/global-loading/global-loading.interceptor';
|
||||||
import { tenantBootstrap } from './core/services/tenant-bootstrap';
|
import { tenantBootstrap } from './core/services/tenant-bootstrap';
|
||||||
|
import { TenantUrlSerializer } from './core/services/tenant-url.serializer';
|
||||||
|
|
||||||
|
export function isStoreCatalogRequest(request: HttpRequest<unknown>): boolean {
|
||||||
|
return (
|
||||||
|
request.method === 'GET' &&
|
||||||
|
/\/api\/tenants\/[^/?#]+\/(?:catalog(?:\/featured-groups\/\d+\/items)?|catalog-items(?:\/\d+)?)\/?(?:[?#]|$)/.test(
|
||||||
|
request.urlWithParams,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
provideBrowserGlobalErrorListeners(),
|
provideBrowserGlobalErrorListeners(),
|
||||||
provideRouter(routes),
|
provideRouter(routes),
|
||||||
provideClientHydration(),
|
{ provide: UrlSerializer, useClass: TenantUrlSerializer },
|
||||||
provideHttpClient(),
|
provideClientHydration(
|
||||||
provideAppInitializer(tenantBootstrap)
|
withHttpTransferCacheOptions({
|
||||||
]
|
includeRequestsWithAuthHeaders: true,
|
||||||
|
filter: isStoreCatalogRequest,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
provideHttpClient(
|
||||||
|
withFetch(),
|
||||||
|
withInterceptors([authInterceptor, globalLoadingInterceptor])
|
||||||
|
),
|
||||||
|
provideAppInitializer(authBootstrap),
|
||||||
|
provideAppInitializer(tenantBootstrap),
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1 +1,20 @@
|
|||||||
<router-outlet />
|
@if (status() === 'ready') {
|
||||||
|
<router-outlet />
|
||||||
|
<app-toast-container />
|
||||||
|
<app-modal-host />
|
||||||
|
<app-global-loading />
|
||||||
|
} @else if (status() === 'not-found') {
|
||||||
|
<section class="tenant-status">
|
||||||
|
<div class="tenant-status__card">
|
||||||
|
<span class="tenant-status__code">404</span>
|
||||||
|
<h1 class="tenant-status__title">No encontramos una tienda para este dominio</h1>
|
||||||
|
<p class="tenant-status__description">
|
||||||
|
Verifica la configuracion del tenant o intenta nuevamente con un dominio valido.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
} @else {
|
||||||
|
<section class="tenant-status tenant-status--loading" aria-hidden="true">
|
||||||
|
<div class="tenant-status__spinner"></div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,6 @@ import { RenderMode, ServerRoute } from '@angular/ssr';
|
|||||||
export const serverRoutes: ServerRoute[] = [
|
export const serverRoutes: ServerRoute[] = [
|
||||||
{
|
{
|
||||||
path: '**',
|
path: '**',
|
||||||
renderMode: RenderMode.Prerender
|
renderMode: RenderMode.Server
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,15 +1,104 @@
|
|||||||
|
import { computed, signal } from '@angular/core';
|
||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { provideRouter, Router } from '@angular/router';
|
import { provideRouter, Router } from '@angular/router';
|
||||||
|
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
|
||||||
import { App } from './app';
|
import { App } from './app';
|
||||||
|
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 { routes } from './app.routes';
|
import { routes } from './app.routes';
|
||||||
|
|
||||||
async function renderAppAt(url: string) {
|
const tenant: Tenant = {
|
||||||
|
id: 1,
|
||||||
|
codigo: 'test',
|
||||||
|
nombre: 'Test Tenant',
|
||||||
|
dominio: 'localhost',
|
||||||
|
primary_color: '#6376F3',
|
||||||
|
secondary_color: '#A0A0A0',
|
||||||
|
danger_color: '#FF8888',
|
||||||
|
success_color: '#198754',
|
||||||
|
header_bg_color: '#313131',
|
||||||
|
footer_bg_color: '#313131',
|
||||||
|
header_logo: 'https://example.com/header.png',
|
||||||
|
footer_logo: 'https://example.com/footer.png',
|
||||||
|
categories: []
|
||||||
|
};
|
||||||
|
|
||||||
|
function createTenantServiceStub(
|
||||||
|
status: 'ready' | 'not-found' = 'ready',
|
||||||
|
currentTenant: Tenant | null = tenant
|
||||||
|
) {
|
||||||
|
const tenantState = signal(currentTenant);
|
||||||
|
const statusState = signal(status);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tenant: tenantState.asReadonly(),
|
||||||
|
status: statusState.asReadonly(),
|
||||||
|
getTenant: () => tenantState(),
|
||||||
|
getTenantApiUrl: () => {
|
||||||
|
const tenantVal = tenantState();
|
||||||
|
if (!tenantVal) {
|
||||||
|
throw new Error('No se pudo resolver el tenant activo.');
|
||||||
|
}
|
||||||
|
return `http://localhost:8000/api/tenants/${tenantVal.codigo}`;
|
||||||
|
},
|
||||||
|
bootstrap: vi.fn().mockResolvedValue(undefined)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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(),
|
||||||
|
authService: ReturnType<typeof createAuthServiceStub> = createAuthServiceStub()
|
||||||
|
) {
|
||||||
TestBed.resetTestingModule();
|
TestBed.resetTestingModule();
|
||||||
|
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [App],
|
imports: [App],
|
||||||
providers: [provideRouter(routes)]
|
providers: [
|
||||||
|
provideRouter(routes),
|
||||||
|
{
|
||||||
|
provide: TenantService,
|
||||||
|
useValue: tenantService
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: CartService,
|
||||||
|
useValue: {
|
||||||
|
cart: signal(null).asReadonly(),
|
||||||
|
loadCart: () => of({ id: 1, items: [], subtotal: '0' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: AuthService,
|
||||||
|
useValue: authService
|
||||||
|
}
|
||||||
|
]
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
|
|
||||||
const router = TestBed.inject(Router);
|
const router = TestBed.inject(Router);
|
||||||
@@ -24,21 +113,148 @@ async function renderAppAt(url: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('app routes', () => {
|
describe('app routes', () => {
|
||||||
it('loads the store layout at /store', async () => {
|
it('loads the store layout at /', async () => {
|
||||||
const { fixture, router } = await renderAppAt('/store');
|
const { fixture, router } = await renderAppAt('/');
|
||||||
const compiled = fixture.nativeElement as HTMLElement;
|
const compiled = fixture.nativeElement as HTMLElement;
|
||||||
|
|
||||||
expect(router.url).toBe('/store');
|
expect(router.url).toBe('/');
|
||||||
expect(compiled.querySelector('.store-layout')).not.toBeNull();
|
expect(compiled.querySelector('.store-layout')).not.toBeNull();
|
||||||
expect(compiled.querySelector('.store-home')).not.toBeNull();
|
expect(compiled.querySelector('app-store-home-page')).not.toBeNull();
|
||||||
expect(compiled.textContent).toContain('Tienda en construccion');
|
expect(compiled.textContent).toContain('Productos');
|
||||||
|
expect(compiled.style.getPropertyValue('--tenant-primary')).toBe(tenant.primary_color);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the root redirect under componentes-test', async () => {
|
it('loads componentes-test under a valid tenant', async () => {
|
||||||
const { fixture, router } = await renderAppAt('/');
|
const { fixture, router } = await renderAppAt('/');
|
||||||
|
await router.navigateByUrl('/componentes-test/reutilizables');
|
||||||
|
await fixture.whenStable();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
const compiled = fixture.nativeElement as HTMLElement;
|
const compiled = fixture.nativeElement as HTMLElement;
|
||||||
|
|
||||||
expect(router.url).toBe('/componentes-test/reutilizables');
|
expect(router.url).toBe('/componentes-test/reutilizables');
|
||||||
expect(compiled.textContent).toContain('Componentes reutilizables');
|
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),
|
||||||
|
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('renders the tenant not found screen at / without redirecting to itself', async () => {
|
||||||
|
const { fixture, router } = await renderAppAt(
|
||||||
|
'/',
|
||||||
|
createTenantServiceStub('not-found', null),
|
||||||
|
createAuthServiceStub()
|
||||||
|
);
|
||||||
|
const compiled = fixture.nativeElement as HTMLElement;
|
||||||
|
|
||||||
|
expect(router.url).toBe('/');
|
||||||
|
expect(compiled.querySelector('.tenant-status')).not.toBeNull();
|
||||||
|
expect(compiled.textContent).toContain('No encontramos una tienda para este dominio');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects unauthenticated users from /checkout to /login', async () => {
|
||||||
|
const { router } = await renderAppAt('/checkout', createTenantServiceStub(), createAuthServiceStub(false));
|
||||||
|
|
||||||
|
expect(router.url).toBe('/login?returnUrl=%2Fcheckout');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows authenticated users to access /checkout', async () => {
|
||||||
|
const { router } = await renderAppAt('/checkout', createTenantServiceStub(), createAuthServiceStub(true));
|
||||||
|
|
||||||
|
expect(router.url).toBe('/checkout');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,11 +8,18 @@ export const routes: Routes = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'store',
|
path: 'store',
|
||||||
loadChildren: () => import('./features/store/store.routes').then((m) => m.routes)
|
pathMatch: 'full',
|
||||||
|
redirectTo: ''
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
pathMatch: 'full',
|
loadChildren: () => import('./features/store/store.routes').then((m) => m.routes)
|
||||||
redirectTo: 'componentes-test'
|
},
|
||||||
|
{
|
||||||
|
path: '**',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./shared/pages/route-not-found-page.component').then(
|
||||||
|
(m) => m.RouteNotFoundPageComponent,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,3 +1,68 @@
|
|||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
min-height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tenant-status {
|
||||||
|
min-height: 100dvh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 2rem;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top, color-mix(in srgb, white 82%, var(--tenant-primary)) 0%, transparent 55%),
|
||||||
|
linear-gradient(180deg, #f8f8f8 0%, #efefef 100%);
|
||||||
|
color: #202020;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tenant-status__card {
|
||||||
|
width: min(100%, 34rem);
|
||||||
|
padding: 2rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 1.5rem;
|
||||||
|
background-color: rgba(255, 255, 255, 0.92);
|
||||||
|
box-shadow: 0 1.25rem 3rem rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tenant-status__code {
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.24em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--tenant-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tenant-status__title {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
font-size: clamp(2rem, 4vw, 2.75rem);
|
||||||
|
line-height: 1.05;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tenant-status__description {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: rgba(32, 32, 32, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tenant-status--loading {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at center, color-mix(in srgb, white 88%, var(--tenant-primary)) 0%, transparent 60%),
|
||||||
|
#f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tenant-status__spinner {
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
border: 0.25rem solid var(--border-color);
|
||||||
|
border-top-color: var(--tenant-primary);
|
||||||
|
border-radius: 999px;
|
||||||
|
animation: tenant-status-spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tenant-status-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,120 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import '@angular/compiler';
|
||||||
|
import { signal } from '@angular/core';
|
||||||
|
import { TestBed, getTestBed } from '@angular/core/testing';
|
||||||
|
import {
|
||||||
|
BrowserTestingModule,
|
||||||
|
platformBrowserTesting
|
||||||
|
} from '@angular/platform-browser/testing';
|
||||||
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { App } from './app';
|
import { App } from './app';
|
||||||
|
import { Tenant } from './core/services/tenant.interface';
|
||||||
|
import { TenantService } from './core/services/tenant.service';
|
||||||
|
|
||||||
|
const tenant: Tenant = {
|
||||||
|
id: 1,
|
||||||
|
codigo: 'test',
|
||||||
|
nombre: 'Test Tenant',
|
||||||
|
dominio: 'localhost',
|
||||||
|
site_title: 'Test Store',
|
||||||
|
favicon: 'https://example.com/favicon.png',
|
||||||
|
primary_color: '#6376F3',
|
||||||
|
secondary_color: '#A0A0A0',
|
||||||
|
danger_color: '#FF8888',
|
||||||
|
success_color: '#198754',
|
||||||
|
header_bg_color: '#313131',
|
||||||
|
footer_bg_color: '#313131',
|
||||||
|
header_logo: 'https://example.com/header.png',
|
||||||
|
footer_logo: 'https://example.com/footer.png',
|
||||||
|
categories: []
|
||||||
|
};
|
||||||
|
|
||||||
|
function createTenantServiceStub(status: 'ready' | 'not-found', currentTenant: Tenant | null) {
|
||||||
|
const tenantState = signal(currentTenant);
|
||||||
|
const statusState = signal(status);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tenant: tenantState.asReadonly(),
|
||||||
|
status: statusState.asReadonly(),
|
||||||
|
getTenant: () => tenantState(),
|
||||||
|
bootstrap: vi.fn().mockResolvedValue(undefined)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe('App', () => {
|
describe('App', () => {
|
||||||
beforeEach(async () => {
|
beforeAll(() => {
|
||||||
|
try {
|
||||||
|
getTestBed().initTestEnvironment(
|
||||||
|
BrowserTestingModule,
|
||||||
|
platformBrowserTesting()
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Test environment may already be initialized by another setup entrypoint.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates the app when the tenant is ready', async () => {
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [App],
|
imports: [App],
|
||||||
|
providers: [
|
||||||
|
provideRouter([]),
|
||||||
|
{
|
||||||
|
provide: TenantService,
|
||||||
|
useValue: createTenantServiceStub('ready', tenant)
|
||||||
|
}
|
||||||
|
]
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(App);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(fixture.componentInstance).toBeTruthy();
|
||||||
|
expect(fixture.nativeElement.style.getPropertyValue('--tenant-primary')).toBe(
|
||||||
|
tenant.primary_color
|
||||||
|
);
|
||||||
|
expect(fixture.nativeElement.style.getPropertyValue('--tenant-secondary')).toBe(
|
||||||
|
tenant.secondary_color
|
||||||
|
);
|
||||||
|
expect(fixture.nativeElement.style.getPropertyValue('--tenant-danger')).toBe(
|
||||||
|
tenant.danger_color
|
||||||
|
);
|
||||||
|
expect(fixture.nativeElement.style.getPropertyValue('--tenant-primary-rgb')).toBe(
|
||||||
|
'99, 118, 243'
|
||||||
|
);
|
||||||
|
expect(fixture.nativeElement.style.getPropertyValue('--tenant-secondary-rgb')).toBe(
|
||||||
|
'160, 160, 160'
|
||||||
|
);
|
||||||
|
expect(fixture.nativeElement.style.getPropertyValue('--tenant-danger-rgb')).toBe(
|
||||||
|
'255, 136, 136'
|
||||||
|
);
|
||||||
|
expect(document.title).toBe(tenant.site_title);
|
||||||
|
expect(document.head.querySelector<HTMLLinkElement>('link[rel~="icon"]')?.getAttribute('href'))
|
||||||
|
.toBe(tenant.favicon);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should create the app', () => {
|
it('renders the tenant not found screen when the tenant is missing', async () => {
|
||||||
const fixture = TestBed.createComponent(App);
|
await TestBed.configureTestingModule({
|
||||||
const app = fixture.componentInstance;
|
imports: [App],
|
||||||
expect(app).toBeTruthy();
|
providers: [
|
||||||
});
|
provideRouter([]),
|
||||||
|
{
|
||||||
|
provide: TenantService,
|
||||||
|
useValue: createTenantServiceStub('not-found', null)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
it('should render title', async () => {
|
|
||||||
const fixture = TestBed.createComponent(App);
|
const fixture = TestBed.createComponent(App);
|
||||||
await fixture.whenStable();
|
fixture.detectChanges();
|
||||||
const compiled = fixture.nativeElement as HTMLElement;
|
|
||||||
expect(compiled.querySelector('router-outlet')).not.toBeNull();
|
expect(fixture.nativeElement.textContent).toContain('No encontramos una tienda para este dominio');
|
||||||
|
expect(document.title).toBe('ShopitFront');
|
||||||
|
expect(document.head.querySelector<HTMLLinkElement>('link[rel~="icon"]')?.getAttribute('href'))
|
||||||
|
.toBe('favicon.ico');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
119
src/app/app.ts
@@ -1,10 +1,121 @@
|
|||||||
import { Component } from '@angular/core';
|
import { DOCUMENT } from '@angular/common';
|
||||||
|
import { Component, computed, effect, inject } from '@angular/core';
|
||||||
|
import { Title } from '@angular/platform-browser';
|
||||||
import { RouterOutlet } from '@angular/router';
|
import { RouterOutlet } from '@angular/router';
|
||||||
|
|
||||||
|
import { TenantService } from './core/services/tenant.service';
|
||||||
|
import { DEFAULT_TENANT_BRANDING } from './core/services/tenant-ssr-cache.store';
|
||||||
|
import { GlobalLoadingComponent } from './shared/components/global-loading/global-loading.component';
|
||||||
|
import { ModalHostComponent } from './shared/components/modal-host/modal-host.component';
|
||||||
|
import { ToastContainerComponent } from './shared/components/toast-container/toast-container.component';
|
||||||
|
|
||||||
|
function hexToRgb(hex: string): string {
|
||||||
|
const cleanHex = hex.replace('#', '').trim();
|
||||||
|
let r = 0, g = 0, b = 0;
|
||||||
|
if (cleanHex.length === 3) {
|
||||||
|
r = parseInt(cleanHex[0] + cleanHex[0], 16);
|
||||||
|
g = parseInt(cleanHex[1] + cleanHex[1], 16);
|
||||||
|
b = parseInt(cleanHex[2] + cleanHex[2], 16);
|
||||||
|
} else if (cleanHex.length >= 6) {
|
||||||
|
r = parseInt(cleanHex.slice(0, 2), 16);
|
||||||
|
g = parseInt(cleanHex.slice(2, 4), 16);
|
||||||
|
b = parseInt(cleanHex.slice(4, 6), 16);
|
||||||
|
}
|
||||||
|
return `${r}, ${g}, ${b}`;
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
imports: [RouterOutlet],
|
imports: [
|
||||||
|
RouterOutlet,
|
||||||
|
ToastContainerComponent,
|
||||||
|
ModalHostComponent,
|
||||||
|
GlobalLoadingComponent
|
||||||
|
],
|
||||||
templateUrl: './app.html',
|
templateUrl: './app.html',
|
||||||
styleUrl: './app.scss'
|
styleUrl: './app.scss',
|
||||||
|
host: {
|
||||||
|
'[style.--tenant-primary]': 'tenantPrimaryColor()',
|
||||||
|
'[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.--color-header-bg]': 'tenantHeaderBgColor()',
|
||||||
|
'[style.--color-footer-bg]': 'tenantFooterBgColor()'
|
||||||
|
}
|
||||||
})
|
})
|
||||||
export class App {}
|
export class App {
|
||||||
|
private readonly tenantService = inject(TenantService);
|
||||||
|
private readonly document = inject(DOCUMENT);
|
||||||
|
private readonly title = inject(Title);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
|
const tenant = this.tenantService.tenant();
|
||||||
|
const siteTitle = tenant?.site_title?.trim() || 'ShopitFront';
|
||||||
|
const faviconHref = tenant?.favicon || 'favicon.ico';
|
||||||
|
|
||||||
|
this.title.setTitle(siteTitle);
|
||||||
|
|
||||||
|
let favicon = this.document.head.querySelector<HTMLLinkElement>('link[rel~="icon"]');
|
||||||
|
|
||||||
|
if (!favicon) {
|
||||||
|
favicon = this.document.createElement('link');
|
||||||
|
favicon.setAttribute('rel', 'icon');
|
||||||
|
this.document.head.appendChild(favicon);
|
||||||
|
}
|
||||||
|
|
||||||
|
favicon.setAttribute('href', faviconHref);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected readonly status = this.tenantService.status;
|
||||||
|
|
||||||
|
protected readonly tenantPrimaryColor = computed(
|
||||||
|
() => this.tenantService.tenant()?.primary_color ?? DEFAULT_TENANT_BRANDING.primaryColor
|
||||||
|
);
|
||||||
|
protected readonly tenantSecondaryColor = computed(
|
||||||
|
() => this.tenantService.tenant()?.secondary_color ?? DEFAULT_TENANT_BRANDING.secondaryColor
|
||||||
|
);
|
||||||
|
protected readonly tenantDangerColor = computed(
|
||||||
|
() => this.tenantService.tenant()?.danger_color ?? DEFAULT_TENANT_BRANDING.dangerColor
|
||||||
|
);
|
||||||
|
protected readonly tenantSuccessColor = computed(
|
||||||
|
() => this.tenantService.tenant()?.success_color ?? DEFAULT_TENANT_BRANDING.successColor
|
||||||
|
);
|
||||||
|
protected readonly tenantPrimaryColorRgb = computed(() =>
|
||||||
|
hexToRgb(this.tenantPrimaryColor())
|
||||||
|
);
|
||||||
|
protected readonly tenantSecondaryColorRgb = computed(() =>
|
||||||
|
hexToRgb(this.tenantSecondaryColor())
|
||||||
|
);
|
||||||
|
protected readonly tenantDangerColorRgb = computed(() =>
|
||||||
|
hexToRgb(this.tenantDangerColor())
|
||||||
|
);
|
||||||
|
protected readonly tenantSuccessColorRgb = computed(() =>
|
||||||
|
hexToRgb(this.tenantSuccessColor())
|
||||||
|
);
|
||||||
|
protected readonly tenantHeaderBgColor = computed(
|
||||||
|
() =>
|
||||||
|
this.tenantService.tenant()?.header_bg_color ??
|
||||||
|
DEFAULT_TENANT_BRANDING.headerBgColor
|
||||||
|
);
|
||||||
|
protected readonly tenantFooterBgColor = computed(
|
||||||
|
() =>
|
||||||
|
this.tenantService.tenant()?.footer_bg_color ??
|
||||||
|
DEFAULT_TENANT_BRANDING.footerBgColor
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
76
src/app/core/guards/menu.guard.spec.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { provideRouter, Router, UrlTree } from '@angular/router';
|
||||||
|
|
||||||
|
import { Tenant } from '../services/tenant.interface';
|
||||||
|
import { TenantService } from '../services/tenant.service';
|
||||||
|
import { hasMenuGuard } from './menu.guard';
|
||||||
|
|
||||||
|
const tenant: Tenant = {
|
||||||
|
id: 1,
|
||||||
|
codigo: 'test',
|
||||||
|
nombre: 'Test Tenant',
|
||||||
|
dominio: 'localhost',
|
||||||
|
primary_color: '#6376F3',
|
||||||
|
secondary_color: '#A0A0A0',
|
||||||
|
danger_color: '#FF8888',
|
||||||
|
success_color: '#198754',
|
||||||
|
header_bg_color: '#313131',
|
||||||
|
footer_bg_color: '#313131',
|
||||||
|
header_logo: 'https://example.com/header.png',
|
||||||
|
footer_logo: 'https://example.com/footer.png',
|
||||||
|
categories: [],
|
||||||
|
menues: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
code: 'index',
|
||||||
|
label: 'Inicio',
|
||||||
|
parent_menu_code: null,
|
||||||
|
content_type: 'dynamic',
|
||||||
|
route: '/',
|
||||||
|
submenues: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
function runGuard(menuCode: string, url: string, currentTenant: Tenant | null) {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
provideRouter([]),
|
||||||
|
{
|
||||||
|
provide: TenantService,
|
||||||
|
useValue: { tenant: () => currentTenant },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return TestBed.runInInjectionContext(() =>
|
||||||
|
hasMenuGuard(menuCode)(null as never, { url } as never),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('hasMenuGuard', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows a route exposed by the tenant menu', () => {
|
||||||
|
expect(runGuard('index', '/', tenant)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels navigation when the tenant is missing', () => {
|
||||||
|
expect(runGuard('index', '/', null)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects a missing menu route to the store root', () => {
|
||||||
|
const result = runGuard('checkout', '/checkout', tenant);
|
||||||
|
|
||||||
|
expect(result instanceof UrlTree).toBe(true);
|
||||||
|
expect(TestBed.inject(Router).serializeUrl(result as UrlTree)).toBe('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows the store root when its menu is missing to avoid a self-redirect', () => {
|
||||||
|
const tenantWithoutMenus = { ...tenant, menues: [] };
|
||||||
|
|
||||||
|
expect(runGuard('index', '/', tenantWithoutMenus)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
25
src/app/core/guards/menu.guard.ts
Normal 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 (_route, state) => {
|
||||||
|
const tenantService = inject(TenantService);
|
||||||
|
const router = inject(Router);
|
||||||
|
|
||||||
|
const tenant = tenantService.tenant();
|
||||||
|
|
||||||
|
if (!tenant) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasMenu = findMenu(tenant.menues ?? [], menuCode) !== undefined;
|
||||||
|
|
||||||
|
if (hasMenu || state.url === '/') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return router.createUrlTree(['/']);
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/app/core/layout/simple-layout/simple-layout.component.ts
Normal 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 {}
|
||||||
@@ -1,58 +1,82 @@
|
|||||||
<footer class="store-layout__footer mt-auto text-light">
|
<footer
|
||||||
<div class="container-xl px-3 px-md-4 py-4 py-md-5">
|
class="store-layout__footer mt-auto text-light"
|
||||||
<div class="row g-4">
|
[style.background-image]="backgroundImageUrl ? 'url(' + backgroundImageUrl + ')' : null"
|
||||||
<div class="col-12 col-md-6 col-xl-5 d-grid gap-3 align-content-start">
|
>
|
||||||
|
<div class="container-xl px-3 px-md-4 py-4 py-md-5 store-layout__footer-content">
|
||||||
|
<div class="row g-4 store-layout__footer-grid">
|
||||||
|
<div class="col-12 store-layout__brand-column">
|
||||||
<div
|
<div
|
||||||
class="store-layout__brand-slot d-inline-flex align-items-center gap-3"
|
class="store-layout__brand-slot d-inline-flex align-items-center gap-3"
|
||||||
data-testid="store-footer-brand-slot"
|
data-testid="store-footer-brand-slot"
|
||||||
aria-label="Logo del comercio"
|
aria-label="Logo del comercio"
|
||||||
>
|
>
|
||||||
@if (logoUrl && logoLoaded) {
|
@if (logoUrl) {
|
||||||
<img class="store-layout__brand-logo" [src]="logoUrl" alt="Logo del comercio" />
|
<img class="store-layout__brand-logo" [src]="logoUrl" alt="Logo del comercio" />
|
||||||
} @else {
|
} @else {
|
||||||
<div class="store-layout__brand-logo-shell" aria-hidden="true">
|
<div class="store-layout__brand-logo-shell" aria-hidden="true">
|
||||||
<span class="store-layout__brand-logo-skeleton"></span>
|
<span class="store-layout__brand-logo-skeleton"></span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
@if (logoUrl && !logoLoaded) {
|
<div class="col-12 col-md-6 col-xl-5 d-grid gap-3 align-content-start store-layout__contact">
|
||||||
<img class="store-layout__brand-logo store-layout__brand-logo--preload" [src]="logoUrl" alt="" (load)="onLogoLoad()" />
|
<div class="d-grid gap-2 store-layout__contact-details">
|
||||||
}
|
@if (address) {
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<i class="fa-solid fa-location-dot" aria-hidden="true"></i>
|
||||||
|
<span>{{ address }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (phone) {
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<i class="fa-solid fa-phone" aria-hidden="true"></i>
|
||||||
|
<span>{{ phone }}</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="d-grid gap-2">
|
<p class="mb-0 small store-layout__muted-text store-layout__copyright">
|
||||||
<div class="d-flex align-items-center gap-2 text-light-emphasis">
|
Copyright © {{ currentYear }} - {{ storeName || 'Sonder' }}
|
||||||
<i class="fa-solid fa-location-dot" aria-hidden="true"></i>
|
</p>
|
||||||
<span>Av. San Lorenzo 1542, Rosario</span>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex align-items-center gap-2 text-light-emphasis">
|
|
||||||
<i class="fa-solid fa-phone" aria-hidden="true"></i>
|
|
||||||
<span>54 9 (0341) 6658247</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="mb-0 small text-white-50">Copyright © {{ currentYear }} - Sonder</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@for (section of footerSections; track section.heading) {
|
@for (section of footerSections; track section.heading) {
|
||||||
<div class="col-12 col-md-6 col-xl-2 d-grid gap-3 align-content-start">
|
<div class="col-12 col-md-6 col-xl-2 d-grid align-content-start store-layout__links-column">
|
||||||
<h2 class="store-layout__footer-heading">{{ section.heading }}</h2>
|
|
||||||
<div class="d-grid gap-2">
|
<div class="d-grid gap-2">
|
||||||
@for (link of section.links; track link) {
|
@for (link of section.links; track link.label) {
|
||||||
<span class="text-light-emphasis">{{ link }}</span>
|
@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>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="col-12 col-md-6 col-xl-3 d-grid gap-3 align-content-start">
|
<div class="store-layout__mobile-divider" aria-hidden="true"></div>
|
||||||
<h2 class="store-layout__footer-heading">Seguinos:</h2>
|
|
||||||
<div class="d-flex align-items-center gap-3">
|
<div class="col-12 col-md-6 col-xl-3 d-grid align-content-start store-layout__social-column">
|
||||||
@for (social of socialLinks; track social.label) {
|
<div class="d-flex align-items-center gap-3 store-layout__social-list">
|
||||||
<span class="fs-5 text-white">
|
@for (social of socialMedia; track social.code) {
|
||||||
<i [class]="social.iconClass" aria-hidden="true"></i>
|
<app-social-media
|
||||||
<span class="visually-hidden">{{ social.label }}</span>
|
class="text-white"
|
||||||
</span>
|
[code]="social.code"
|
||||||
|
[icon]="social.icon"
|
||||||
|
[name]="social.name"
|
||||||
|
[url]="social.url"
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,30 +1,32 @@
|
|||||||
.store-layout__footer {
|
.store-layout__footer {
|
||||||
background-color: #323232;
|
background-color: var(--tenant-footer-bg);
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
|
background-size: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-layout__mobile-divider {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-layout__brand-slot {
|
.store-layout__brand-slot {
|
||||||
|
width: min(100%, 230px);
|
||||||
min-width: 10rem;
|
min-width: 10rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-layout__brand-logo-shell {
|
.store-layout__brand-logo-shell {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: min(100%, 12rem);
|
width: min(100%, 230px);
|
||||||
height: 2.75rem;
|
height: 65px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-layout__brand-logo {
|
.store-layout__brand-logo {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
max-height: 2.75rem;
|
max-height: 65px;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
object-position: left center;
|
||||||
|
|
||||||
.store-layout__brand-logo--preload {
|
|
||||||
opacity: 0;
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-layout__brand-logo-skeleton {
|
.store-layout__brand-logo-skeleton {
|
||||||
@@ -47,7 +49,7 @@
|
|||||||
place-items: center;
|
place-items: center;
|
||||||
width: 2.75rem;
|
width: 2.75rem;
|
||||||
height: 2.75rem;
|
height: 2.75rem;
|
||||||
border: 1px dashed rgba(255, 255, 255, 0.35);
|
border: 1px dashed var(--border-color);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
font-size: 1.05rem;
|
font-size: 1.05rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -62,11 +64,28 @@
|
|||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-layout__footer-heading {
|
.store-layout__muted-text {
|
||||||
margin: 0;
|
color: rgba(255, 255, 255, 0.72);
|
||||||
font-size: 0.95rem;
|
}
|
||||||
font-weight: 500;
|
|
||||||
color: #f8f8f8;
|
.store-layout__footer-link {
|
||||||
|
width: fit-content;
|
||||||
|
color: #ffffff;
|
||||||
|
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 {
|
@keyframes store-footer-logo-skeleton {
|
||||||
@@ -80,11 +99,97 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 767.98px) {
|
@media (max-width: 767.98px) {
|
||||||
|
.store-layout__footer-content {
|
||||||
|
padding: 1.45rem 2.5rem 1.8rem !important;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-layout__footer-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-layout__footer-grid > * {
|
||||||
|
margin-top: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-layout__links-column {
|
||||||
|
order: 1;
|
||||||
|
width: auto;
|
||||||
|
padding-bottom: 1.65rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
|
||||||
|
&:nth-of-type(even) {
|
||||||
|
padding-left: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
> div {
|
||||||
|
gap: 0.5rem !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-layout__mobile-divider {
|
||||||
|
display: block;
|
||||||
|
order: 2;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
width: 100%;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-layout__brand-column,
|
||||||
|
.store-layout__social-column {
|
||||||
|
order: 3;
|
||||||
|
width: auto;
|
||||||
|
padding-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
.store-layout__brand-slot {
|
.store-layout__brand-slot {
|
||||||
|
width: min(100%, 150px);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.store-layout__brand-logo {
|
||||||
|
max-height: 55px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-layout__brand-logo-shell {
|
||||||
|
width: min(100%, 150px);
|
||||||
|
height: 55px;
|
||||||
|
}
|
||||||
|
|
||||||
.store-layout__brand-text {
|
.store-layout__brand-text {
|
||||||
font-size: 1.45rem;
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-layout__social-column {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-layout__social-list {
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-layout__contact {
|
||||||
|
order: 4;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
width: auto;
|
||||||
|
gap: 1.25rem !important;
|
||||||
|
padding-top: 0.9rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-layout__contact-details {
|
||||||
|
justify-items: center;
|
||||||
|
gap: 0.4rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-layout__copyright {
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +1,33 @@
|
|||||||
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 = {
|
export type StoreFooterSection = {
|
||||||
heading: string;
|
heading: string;
|
||||||
links: string[];
|
links: StoreFooterLink[];
|
||||||
};
|
|
||||||
|
|
||||||
export type StoreSocialLink = {
|
|
||||||
label: string;
|
|
||||||
iconClass: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-store-footer',
|
selector: 'app-store-footer',
|
||||||
imports: [],
|
imports: [RouterLink, SocialMediaComponent],
|
||||||
templateUrl: './store-footer.component.html',
|
templateUrl: './store-footer.component.html',
|
||||||
styleUrl: './store-footer.component.scss'
|
styleUrl: './store-footer.component.scss',
|
||||||
})
|
})
|
||||||
export class StoreFooterComponent {
|
export class StoreFooterComponent {
|
||||||
private logoUrlValue: string | null = null;
|
|
||||||
|
|
||||||
@Input({ required: true }) currentYear = new Date().getFullYear();
|
@Input({ required: true }) currentYear = new Date().getFullYear();
|
||||||
@Input({ required: true }) footerSections: StoreFooterSection[] = [];
|
@Input({ required: true }) footerSections: StoreFooterSection[] = [];
|
||||||
@Input({ required: true }) socialLinks: StoreSocialLink[] = [];
|
@Input({ required: true }) socialMedia: SocialMedia[] = [];
|
||||||
|
@Input() logoUrl: string | null = null;
|
||||||
@Input()
|
@Input() backgroundImageUrl: string | null = null;
|
||||||
set logoUrl(value: string | null) {
|
@Input() storeName: string | null = null;
|
||||||
this.logoUrlValue = value;
|
@Input() address: string | null = null;
|
||||||
this.logoLoaded = false;
|
@Input() phone: string | null = null;
|
||||||
}
|
readonly logoutClick = output<void>();
|
||||||
|
|
||||||
get logoUrl(): string | null {
|
|
||||||
return this.logoUrlValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected logoLoaded = false;
|
|
||||||
|
|
||||||
protected onLogoLoad(): void {
|
|
||||||
this.logoLoaded = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,64 +1,181 @@
|
|||||||
<header class="store-layout__header bg-white border-bottom">
|
<header
|
||||||
<div class="border-bottom">
|
class="store-layout__header"
|
||||||
<div class="container-xl px-3 px-md-4 py-3 py-lg-4 d-flex flex-wrap align-items-center justify-content-between gap-3">
|
[style.background-image]="backgroundImageUrl() ? 'url(' + backgroundImageUrl() + ')' : null"
|
||||||
<div
|
>
|
||||||
class="store-layout__brand-slot d-inline-flex align-items-center gap-3 flex-shrink-0"
|
<div class="store-layout__header-main">
|
||||||
data-testid="store-header-brand-slot"
|
<div class="container-xl px-3 px-md-4 py-3 py-lg-4">
|
||||||
aria-label="Logo del comercio"
|
<div class="row align-items-center gy-3 gy-md-0">
|
||||||
>
|
<div class="col-auto col-md-auto order-1 store-layout__brand-column">
|
||||||
@if (logoUrl && logoLoaded) {
|
<a
|
||||||
<img class="store-layout__brand-logo" [src]="logoUrl" alt="Logo del comercio" />
|
routerLink="/"
|
||||||
} @else {
|
class="store-layout__brand-slot d-inline-flex align-items-center gap-3 text-decoration-none"
|
||||||
<div class="store-layout__brand-logo-shell" aria-hidden="true">
|
data-testid="store-header-brand-slot"
|
||||||
<span class="store-layout__brand-logo-skeleton"></span>
|
aria-label="Logo del comercio"
|
||||||
|
>
|
||||||
@if (logoUrl && !logoLoaded) {
|
@if (logoUrl()) {
|
||||||
<img class="store-layout__brand-logo store-layout__brand-logo--preload" [src]="logoUrl" alt="" (load)="onLogoLoad()" />
|
<img class="store-layout__brand-logo" [src]="logoUrl()" alt="Logo del comercio" />
|
||||||
|
} @else {
|
||||||
|
<div class="store-layout__brand-logo-shell" aria-hidden="true">
|
||||||
|
<span class="store-layout__brand-logo-skeleton"></span>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (displaySeachBar()) {
|
||||||
|
<div class="col-12 px-0 d-md-none order-3">
|
||||||
|
<hr class="store-layout__mobile-separator" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-md order-4 order-md-2">
|
||||||
|
<div class="store-layout__search-wrapper ms-md-auto">
|
||||||
|
<div class="input-group store-layout__search" role="search">
|
||||||
|
<label class="visually-hidden" for="store-search-input">Buscar productos</label>
|
||||||
|
<input
|
||||||
|
id="store-search-input"
|
||||||
|
type="search"
|
||||||
|
class="form-control store-layout__search-input border-end-0 rounded-start"
|
||||||
|
placeholder="Buscar productos"
|
||||||
|
autocomplete="off"
|
||||||
|
[attr.aria-describedby]="showSearchError() ? 'store-search-error' : null"
|
||||||
|
[attr.aria-invalid]="showSearchError()"
|
||||||
|
[minlength]="minSearchLength"
|
||||||
|
[formControl]="searchControl"
|
||||||
|
(input)="clearSearchError()"
|
||||||
|
(keydown.enter)="submitSearch($event)"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn store-layout__search-button border-start-0 rounded-end px-3"
|
||||||
|
aria-label="Buscar"
|
||||||
|
(click)="submitSearch()"
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (showSearchError()) {
|
||||||
|
<p id="store-search-error" class="store-layout__search-error mb-0" role="alert">
|
||||||
|
Ingresá al menos {{ minSearchLength }} caracteres para buscar.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="d-flex flex-column flex-md-row align-items-stretch align-items-md-center gap-3 ms-auto w-100 w-lg-auto">
|
<div
|
||||||
<div class="input-group store-layout__search" role="search">
|
class="store-layout__actions-column col-auto col-md-auto order-2 order-md-3 ms-auto d-flex flex-row align-items-center justify-content-end gap-2 gap-md-3"
|
||||||
<label class="visually-hidden" for="store-search-input">Buscar productos</label>
|
>
|
||||||
<input
|
@if (ticketsMenu(); as menu) {
|
||||||
id="store-search-input"
|
<app-button
|
||||||
type="search"
|
variant="primary"
|
||||||
class="form-control border-dark border-end-0 rounded-start"
|
hostClass="store-layout__tickets-btn-host"
|
||||||
placeholder="Buscar productos"
|
data-testid="store-header-tickets"
|
||||||
/>
|
(click)="ticketsClick.emit()"
|
||||||
<button type="button" class="btn btn-outline-dark border-start-0 rounded-end px-3" aria-label="Buscar">
|
|
||||||
<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="d-flex align-items-center justify-content-end gap-1" aria-label="Acciones de usuario">
|
|
||||||
@for (action of headerActions; track action.label) {
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-link text-dark p-2 text-decoration-none"
|
|
||||||
[attr.aria-label]="action.label"
|
|
||||||
[attr.title]="action.label"
|
|
||||||
>
|
>
|
||||||
<i [class]="action.iconClass" aria-hidden="true"></i>
|
{{ menu.label }}
|
||||||
</button>
|
</app-button>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (displayCart()) {
|
||||||
|
<app-cart-icon
|
||||||
|
[quantity]="cartDisabled() ? null : cartQuantity()"
|
||||||
|
[disabled]="cartDisabled()"
|
||||||
|
[ariaLabel]="
|
||||||
|
cartDisabled() ? 'Carrito no disponible durante el checkout' : 'Carrito de compras'
|
||||||
|
"
|
||||||
|
[title]="cartDisabled() ? 'Carrito no disponible durante el checkout' : 'Carrito'"
|
||||||
|
(click)="onCartClick()"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="store-layout__user-menu d-none d-md-inline-flex position-relative">
|
||||||
|
<app-icon-button
|
||||||
|
variant="user"
|
||||||
|
ariaLabel="Mi cuenta"
|
||||||
|
ariaControls="store-user-dropdown"
|
||||||
|
[ariaExpanded]="isUserDropdownOpen()"
|
||||||
|
title="Mi cuenta"
|
||||||
|
(clicked)="toggleUserDropdown()"
|
||||||
|
/>
|
||||||
|
|
||||||
|
@if (isUserDropdownOpen()) {
|
||||||
|
<app-user-dropdown
|
||||||
|
id="store-user-dropdown"
|
||||||
|
[user]="user()"
|
||||||
|
[accountNavigationMenus]="accountNavigationMenus()"
|
||||||
|
(logoutClick)="onLogoutClick()"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="store-layout__mobile-menu d-inline-flex d-md-none position-relative">
|
||||||
|
<app-icon-button
|
||||||
|
variant="bars"
|
||||||
|
ariaLabel="Menu"
|
||||||
|
ariaControls="store-mobile-menu"
|
||||||
|
[ariaExpanded]="isMobileMenuOpen()"
|
||||||
|
title="Menu"
|
||||||
|
(clicked)="toggleMobileMenu()"
|
||||||
|
/>
|
||||||
|
|
||||||
|
@if (isMobileMenuOpen()) {
|
||||||
|
<div
|
||||||
|
id="store-mobile-menu"
|
||||||
|
class="position-absolute end-0 bg-white rounded shadow mt-2 py-2 store-layout__mobile-dropdown-menu"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
class="dropdown-item d-flex align-items-center gap-2 px-3 py-2 text-decoration-none store-layout__mobile-dropdown-item"
|
||||||
|
(click)="onMobileAccountClick()"
|
||||||
|
>
|
||||||
|
<i class="fa-regular fa-circle-user fa-lg"></i>
|
||||||
|
<span class="fw-medium">Mi cuenta</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
@if (displayCategories()) {
|
||||||
|
<hr class="my-1 border-secondary opacity-25" />
|
||||||
|
|
||||||
|
@for (category of categories(); track category.id) {
|
||||||
|
<a
|
||||||
|
class="dropdown-item px-3 py-2 text-decoration-none store-layout__mobile-dropdown-item"
|
||||||
|
(click)="onCategorySelect(category)"
|
||||||
|
>
|
||||||
|
<span class="fw-medium">{{ category.nombre }}</span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
@if (displayCategories()) {
|
||||||
<div class="container-xl px-3 px-md-4">
|
<div class="store-layout__categories d-none d-md-block">
|
||||||
<button
|
<div class="container-xl px-3 px-md-4">
|
||||||
type="button"
|
<div class="store-layout__category-menu">
|
||||||
class="btn btn-link d-inline-flex align-items-center gap-2 px-0 py-2 text-dark text-uppercase fw-bold text-decoration-none store-layout__category-trigger"
|
<button
|
||||||
aria-label="Categorias"
|
type="button"
|
||||||
>
|
class="btn btn-link d-inline-flex align-items-center gap-2 px-0 py-3 text-uppercase fw-bold text-decoration-none store-layout__category-trigger"
|
||||||
<span>Categorias</span>
|
aria-label="Categorias"
|
||||||
<i class="fa-solid fa-chevron-down" aria-hidden="true"></i>
|
aria-controls="store-category-dropdown"
|
||||||
</button>
|
[attr.aria-expanded]="isCategoryDropdownOpen()"
|
||||||
|
(click)="toggleCategoryDropdown()"
|
||||||
|
>
|
||||||
|
<span>Categorias</span>
|
||||||
|
<i class="fa-solid fa-chevron-down" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<app-category-dropdown
|
||||||
|
id="store-category-dropdown"
|
||||||
|
[categories]="categories()"
|
||||||
|
[open]="isCategoryDropdownOpen()"
|
||||||
|
(categorySelect)="onCategorySelect($event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
|
.store-layout__header {
|
||||||
|
color: #ffffff;
|
||||||
|
background-color: var(--tenant-header-bg);
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
|
background-size: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-layout__header-main {
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
.store-layout__brand-slot {
|
.store-layout__brand-slot {
|
||||||
min-width: 10rem;
|
min-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-layout__brand-column {
|
||||||
|
max-width: 12rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-layout__brand-logo-shell {
|
.store-layout__brand-logo-shell {
|
||||||
@@ -15,31 +31,117 @@
|
|||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-layout__brand-logo--preload {
|
|
||||||
opacity: 0;
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-layout__brand-logo-skeleton {
|
.store-layout__brand-logo-skeleton {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
background: linear-gradient(90deg, rgba(0, 0, 0, 0.08) 0%, rgba(0, 0, 0, 0.14) 50%, rgba(0, 0, 0, 0.08) 100%);
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(255, 255, 255, 0.12) 0%,
|
||||||
|
rgba(255, 255, 255, 0.22) 50%,
|
||||||
|
rgba(255, 255, 255, 0.12) 100%
|
||||||
|
);
|
||||||
background-size: 200% 100%;
|
background-size: 200% 100%;
|
||||||
animation: store-header-logo-skeleton 1.2s ease-in-out infinite;
|
animation: store-header-logo-skeleton 1.2s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.store-layout__search-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.store-layout__search-wrapper {
|
||||||
|
width: min(100%, 260px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-layout__actions-column {
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
::ng-deep .store-layout__tickets-btn-host {
|
||||||
|
flex: 0 0 clamp(8rem, 15vw, 13.5rem);
|
||||||
|
width: clamp(8rem, 15vw, 13.5rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.store-layout__search {
|
.store-layout__search {
|
||||||
width: min(100%, 24rem);
|
width: 100%;
|
||||||
min-width: 18rem;
|
}
|
||||||
|
|
||||||
|
.store-layout__search-error {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: var(--tenant-danger, #dc3545);
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-layout__search-input {
|
||||||
|
color: #666666;
|
||||||
|
border-color: var(--border-color);
|
||||||
|
background-color: rgba(255, 255, 255, 0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-layout__search-input::placeholder {
|
||||||
|
color: #666666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-layout__search-input:focus {
|
||||||
|
border-color: var(--border-color);
|
||||||
|
box-shadow: none;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-layout__search-button {
|
||||||
|
color: #a0a0a0;
|
||||||
|
border-color: var(--border-color);
|
||||||
|
background-color: rgba(255, 255, 255, 0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-layout__search-button:hover,
|
||||||
|
.store-layout__search-button:focus-visible {
|
||||||
|
color: #8a8a8a;
|
||||||
|
border-color: var(--border-color);
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-layout__action-button {
|
||||||
|
color: #a0a0a0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-layout__action-button:hover,
|
||||||
|
.store-layout__action-button:focus-visible {
|
||||||
|
color: #8a8a8a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-layout__user-menu {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-layout__category-trigger {
|
.store-layout__category-trigger {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
|
color: #a0a0a0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-layout__category-trigger:hover,
|
||||||
|
.store-layout__category-trigger:focus-visible {
|
||||||
|
color: #8a8a8a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-layout__category-menu {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-layout__category-menu app-category-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1050;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes store-header-logo-skeleton {
|
@keyframes store-header-logo-skeleton {
|
||||||
@@ -55,9 +157,70 @@
|
|||||||
@media (max-width: 767.98px) {
|
@media (max-width: 767.98px) {
|
||||||
.store-layout__brand-slot {
|
.store-layout__brand-slot {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
max-width: 150px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-layout__search {
|
.store-layout__actions-column {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
width: auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
::ng-deep .store-layout__tickets-btn-host {
|
||||||
|
flex: 1 1 0;
|
||||||
|
width: auto;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 50vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-layout__mobile-separator {
|
||||||
|
border-color: var(--border-color);
|
||||||
|
opacity: 1;
|
||||||
|
margin: 0 -1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 399.98px) {
|
||||||
|
.store-layout__brand-column {
|
||||||
|
flex: 1 1 0;
|
||||||
|
width: auto;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: none;
|
||||||
|
padding-right: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-layout__brand-slot {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 6.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-layout__actions-column {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
width: auto;
|
||||||
|
min-width: 0;
|
||||||
|
margin-left: 0 !important;
|
||||||
|
padding-left: 0.25rem;
|
||||||
|
gap: 0.25rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
::ng-deep .store-layout__tickets-btn-host .btn {
|
||||||
|
padding-inline: 0.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-layout__mobile-dropdown-menu {
|
||||||
|
top: 100%;
|
||||||
|
min-width: 220px;
|
||||||
|
z-index: 1050;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-layout__mobile-dropdown-item {
|
||||||
|
cursor: pointer;
|
||||||
|
color: #8a8a8a;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus-visible {
|
||||||
|
color: #666666;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +1,150 @@
|
|||||||
import { Component, Input } from '@angular/core';
|
import { Component, ElementRef, HostListener, inject, input, output, signal } from '@angular/core';
|
||||||
|
import { Router, RouterLink } from '@angular/router';
|
||||||
export type StoreHeaderAction = {
|
import { AuthUser } from '../../../services/auth/auth.interfaces';
|
||||||
label: string;
|
import { CartIconComponent } from '../../../../shared/components/cart-icon/cart-icon.component';
|
||||||
iconClass: string;
|
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({
|
@Component({
|
||||||
selector: 'app-store-header',
|
selector: 'app-store-header',
|
||||||
imports: [],
|
imports: [
|
||||||
|
ButtonComponent,
|
||||||
|
CartIconComponent,
|
||||||
|
IconButtonComponent,
|
||||||
|
RouterLink,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
UserDropdownComponent,
|
||||||
|
CategoryDropdownComponent,
|
||||||
|
],
|
||||||
templateUrl: './store-header.component.html',
|
templateUrl: './store-header.component.html',
|
||||||
styleUrl: './store-header.component.scss'
|
styleUrl: './store-header.component.scss',
|
||||||
})
|
})
|
||||||
export class StoreHeaderComponent {
|
export class StoreHeaderComponent {
|
||||||
private logoUrlValue: string | null = null;
|
private readonly elementRef = inject(ElementRef<HTMLElement>);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
|
||||||
@Input({ required: true }) headerActions: StoreHeaderAction[] = [];
|
readonly logoUrl = input<string | null>(null);
|
||||||
|
readonly backgroundImageUrl = input<string | null>(null);
|
||||||
|
readonly cartQuantity = input<number>(0);
|
||||||
|
readonly user = input<AuthUser | null>(null);
|
||||||
|
readonly isAuthenticated = input<boolean>(false);
|
||||||
|
readonly accountNavigationMenus = input<readonly Menu[]>([]);
|
||||||
|
readonly ticketsMenu = input<Menu | null>(null);
|
||||||
|
readonly categories = input<readonly Category[]>([]);
|
||||||
|
readonly displayCategories = input(true);
|
||||||
|
readonly displaySeachBar = input(true);
|
||||||
|
readonly displayCart = input(true);
|
||||||
|
readonly cartDisabled = input(false);
|
||||||
|
readonly cartClick = output<void>();
|
||||||
|
readonly ticketsClick = output<void>();
|
||||||
|
readonly loginClick = output<void>();
|
||||||
|
readonly logoutClick = output<void>();
|
||||||
|
readonly searchSubmit = output<string>();
|
||||||
|
readonly categorySelect = output<Category>();
|
||||||
|
|
||||||
@Input()
|
protected readonly isUserDropdownOpen = signal(false);
|
||||||
set logoUrl(value: string | null) {
|
protected readonly isCategoryDropdownOpen = signal(false);
|
||||||
this.logoUrlValue = value;
|
protected readonly isMobileMenuOpen = signal(false);
|
||||||
this.logoLoaded = false;
|
protected readonly minSearchLength = 3;
|
||||||
|
protected readonly showSearchError = signal(false);
|
||||||
|
protected readonly searchControl = new FormControl('', { nonNullable: true });
|
||||||
|
|
||||||
|
protected onCartClick(): void {
|
||||||
|
if (this.cartDisabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cartClick.emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
get logoUrl(): string | null {
|
@HostListener('document:click', ['$event'])
|
||||||
return this.logoUrlValue;
|
protected onDocumentClick(event: MouseEvent): void {
|
||||||
|
if (!this.isUserDropdownOpen() && !this.isCategoryDropdownOpen() && !this.isMobileMenuOpen()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = event.target;
|
||||||
|
if (target instanceof Node && !this.elementRef.nativeElement.contains(target)) {
|
||||||
|
this.isUserDropdownOpen.set(false);
|
||||||
|
this.isCategoryDropdownOpen.set(false);
|
||||||
|
this.isMobileMenuOpen.set(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected logoLoaded = false;
|
@HostListener('document:keydown.escape')
|
||||||
|
protected onEscapeKey(): void {
|
||||||
|
this.isUserDropdownOpen.set(false);
|
||||||
|
this.isCategoryDropdownOpen.set(false);
|
||||||
|
this.isMobileMenuOpen.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
protected onLogoLoad(): void {
|
protected toggleUserDropdown(): void {
|
||||||
this.logoLoaded = true;
|
if (!this.isAuthenticated()) {
|
||||||
|
this.loginClick.emit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isUserDropdownOpen.update((isOpen) => !isOpen);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onLoginClick(): void {
|
||||||
|
this.isUserDropdownOpen.set(false);
|
||||||
|
this.loginClick.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onLogoutClick(): void {
|
||||||
|
this.isUserDropdownOpen.set(false);
|
||||||
|
this.logoutClick.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected submitSearch(event?: Event): void {
|
||||||
|
event?.preventDefault();
|
||||||
|
|
||||||
|
const term = this.searchControl.value.trim();
|
||||||
|
|
||||||
|
if (term.length < this.minSearchLength) {
|
||||||
|
this.showSearchError.set(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.showSearchError.set(false);
|
||||||
|
this.searchSubmit.emit(term);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected clearSearchError(): void {
|
||||||
|
this.showSearchError.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected toggleCategoryDropdown(): void {
|
||||||
|
this.isCategoryDropdownOpen.update((isOpen) => !isOpen);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onCategorySelect(category: Category): void {
|
||||||
|
this.isCategoryDropdownOpen.set(false);
|
||||||
|
this.isMobileMenuOpen.set(false);
|
||||||
|
this.categorySelect.emit(category);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected toggleMobileMenu(): void {
|
||||||
|
this.isMobileMenuOpen.update((isOpen) => !isOpen);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onMobileAccountClick(): void {
|
||||||
|
this.isMobileMenuOpen.set(false);
|
||||||
|
if (!this.isAuthenticated()) {
|
||||||
|
this.loginClick.emit();
|
||||||
|
} else {
|
||||||
|
const menus = this.accountNavigationMenus();
|
||||||
|
if (menus.length > 0) {
|
||||||
|
this.router.navigate([menus[0].route]);
|
||||||
|
} else {
|
||||||
|
this.router.navigate(['/cuenta']);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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 var(--border-color);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -1,14 +1,75 @@
|
|||||||
<div class="store-layout d-flex flex-column flex-grow-1">
|
<div class="store-layout d-flex flex-column flex-grow-1">
|
||||||
<app-store-header [headerActions]="headerActions" [logoUrl]="tenant()?.header_logo ?? null" />
|
<app-store-header
|
||||||
|
ngSkipHydration
|
||||||
|
[cartQuantity]="cartQuantity()"
|
||||||
|
[logoUrl]="tenant()?.header_logo ?? null"
|
||||||
|
[backgroundImageUrl]="tenant()?.header_bg_image ?? null"
|
||||||
|
[user]="user()"
|
||||||
|
[isAuthenticated]="isAuthenticated()"
|
||||||
|
[accountNavigationMenus]="accountNavigationMenus()"
|
||||||
|
[ticketsMenu]="ticketsMenu() ?? null"
|
||||||
|
[categories]="tenant()?.categories ?? []"
|
||||||
|
[displayCategories]="tenant()?.display_categories ?? true"
|
||||||
|
[displaySeachBar]="tenant()?.display_seach_bar ?? true"
|
||||||
|
[displayCart]="displayCart()"
|
||||||
|
[cartDisabled]="isCheckoutRoute()"
|
||||||
|
(cartClick)="onCartClick()"
|
||||||
|
(ticketsClick)="onTicketsClick()"
|
||||||
|
(loginClick)="onLoginClick()"
|
||||||
|
(logoutClick)="onLogoutClick()"
|
||||||
|
(searchSubmit)="onSearch($event)"
|
||||||
|
(categorySelect)="onCategorySelect($event)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
@if (displayCart() && !isCheckoutRoute() && isCartOpen()) {
|
||||||
|
<div class="store-layout__cart-overlay" (click)="isCartOpen.set(false)"></div>
|
||||||
|
<div class="store-layout__cart-dropdown card border-0 shadow-lg">
|
||||||
|
<app-cart
|
||||||
|
[showClose]="true"
|
||||||
|
[items]="mappedCartItems()"
|
||||||
|
[subtotal]="cartSubtotal()"
|
||||||
|
[discount]="cartDiscount()"
|
||||||
|
[total]="cartTotal()"
|
||||||
|
[readonly]="!canModifyCart()"
|
||||||
|
[allowModify]="canModifyCart()"
|
||||||
|
[allowUpdateQuantity]="canUpdateCartQuantity()"
|
||||||
|
[allowUpdateVariant]="canUpdateCartVariant()"
|
||||||
|
[allowDelete]="canDeleteCartItems()"
|
||||||
|
[backgroundColor]="'#ffffff'"
|
||||||
|
(closed)="isCartOpen.set(false)"
|
||||||
|
>
|
||||||
|
<app-button variant="secondary" class="flex-grow-1" (click)="isCartOpen.set(false)"
|
||||||
|
>Seguir comprando</app-button
|
||||||
|
>
|
||||||
|
<app-button
|
||||||
|
variant="primary"
|
||||||
|
class="flex-grow-1"
|
||||||
|
[disabled]="isCreatingPurchase()"
|
||||||
|
(click)="onCheckoutClick()"
|
||||||
|
>
|
||||||
|
Comprar
|
||||||
|
</app-button>
|
||||||
|
</app-cart>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<main class="flex-grow-1 d-block">
|
<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>
|
</main>
|
||||||
|
|
||||||
<app-store-footer
|
<app-store-footer
|
||||||
[currentYear]="currentYear"
|
[currentYear]="currentYear"
|
||||||
[footerSections]="footerSections"
|
[footerSections]="footerSections()"
|
||||||
[socialLinks]="socialLinks"
|
[socialMedia]="tenant()?.social_media ?? []"
|
||||||
[logoUrl]="tenant()?.footer_logo ?? null"
|
[logoUrl]="tenant()?.footer_logo ?? null"
|
||||||
|
[backgroundImageUrl]="tenant()?.footer_bg_image ?? null"
|
||||||
|
[storeName]="tenant()?.nombre ?? null"
|
||||||
|
[address]="tenant()?.address ?? null"
|
||||||
|
[phone]="tenant()?.phone ?? null"
|
||||||
|
(logoutClick)="onLogoutClick()"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,63 @@
|
|||||||
:host {
|
:host {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 100dvh;
|
min-height: 100dvh;
|
||||||
background-color: #f6f6f6;
|
background: #f5f5f5;
|
||||||
color: #202020;
|
color: #202020;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.store-layout {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-layout__cart-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
z-index: 1040;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-layout__cart-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
top: 100px;
|
||||||
|
right: calc((100% - 1320px) / 2 + 1.5rem);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
max-height: calc(100vh - 120px);
|
||||||
|
z-index: 1050;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.08);
|
||||||
|
overflow: hidden;
|
||||||
|
animation: store-layout-slide-down 0.2s ease-out;
|
||||||
|
|
||||||
|
> app-cart {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1400px) {
|
||||||
|
right: 1.5rem;
|
||||||
|
}
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
right: 1rem;
|
||||||
|
left: 1rem;
|
||||||
|
width: auto;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes store-layout-slide-down {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,220 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { signal } from '@angular/core';
|
||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { provideRouter } from '@angular/router';
|
import { By } from '@angular/platform-browser';
|
||||||
|
import {
|
||||||
|
ActivatedRoute,
|
||||||
|
convertToParamMap,
|
||||||
|
ParamMap,
|
||||||
|
provideRouter,
|
||||||
|
Router,
|
||||||
|
UrlSerializer,
|
||||||
|
} from '@angular/router';
|
||||||
|
|
||||||
|
import { BehaviorSubject, 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 { StoreLayoutComponent } from './store-layout.component';
|
||||||
|
import { StoreHeaderComponent } from './store-header/store-header.component';
|
||||||
|
import { CheckoutService } from '../../services/checkout.service';
|
||||||
|
import { TenantUrlSerializer } from '../../services/tenant-url.serializer';
|
||||||
|
|
||||||
|
const tenant: Tenant = {
|
||||||
|
id: 1,
|
||||||
|
codigo: 'test',
|
||||||
|
nombre: 'Test Tenant',
|
||||||
|
dominio: 'localhost',
|
||||||
|
address: 'Calle Test 123, Rosario',
|
||||||
|
phone: '+54 341 555 1234',
|
||||||
|
primary_color: '#6376F3',
|
||||||
|
secondary_color: '#A0A0A0',
|
||||||
|
danger_color: '#FF8888',
|
||||||
|
success_color: '#198754',
|
||||||
|
header_bg_color: '#313131',
|
||||||
|
footer_bg_color: '#313131',
|
||||||
|
header_logo: 'https://example.com/header.png',
|
||||||
|
footer_logo: 'https://example.com/footer.png',
|
||||||
|
header_bg_image: 'https://example.com/header-background.png',
|
||||||
|
footer_bg_image: 'https://example.com/footer-background.png',
|
||||||
|
cart_editing_policy: {
|
||||||
|
code: 'full',
|
||||||
|
allow_modify: true,
|
||||||
|
allow_delete: true,
|
||||||
|
allow_update_quantity: true,
|
||||||
|
allow_update_variant: true,
|
||||||
|
},
|
||||||
|
categories: [],
|
||||||
|
menues: [
|
||||||
|
{
|
||||||
|
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', () => {
|
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> };
|
||||||
|
let queryParamMapState: BehaviorSubject<ParamMap>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
tenantState = signal<Tenant | null>(tenant);
|
||||||
|
cartState = signal<Cart | null>(null);
|
||||||
|
authUserState = signal<AuthUser | null>(null);
|
||||||
|
queryParamMapState = new BehaviorSubject(convertToParamMap({}));
|
||||||
|
const isAuthenticatedState = signal(false);
|
||||||
|
checkoutServiceStub = {
|
||||||
|
startCheckout: vi.fn().mockResolvedValue({ id: 55 }),
|
||||||
|
};
|
||||||
|
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [StoreLayoutComponent],
|
imports: [StoreLayoutComponent],
|
||||||
providers: [provideRouter([])]
|
providers: [
|
||||||
|
provideRouter([]),
|
||||||
|
{ provide: UrlSerializer, useClass: TenantUrlSerializer },
|
||||||
|
{
|
||||||
|
provide: ActivatedRoute,
|
||||||
|
useValue: { queryParamMap: queryParamMapState.asObservable() },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: TenantService,
|
||||||
|
useValue: {
|
||||||
|
tenant: tenantState.asReadonly(),
|
||||||
|
status: signal<'ready'>('ready').asReadonly(),
|
||||||
|
getTenant: () => tenantState(),
|
||||||
|
bootstrap: vi.fn().mockResolvedValue(undefined),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: CartService,
|
||||||
|
useValue: {
|
||||||
|
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' } })),
|
||||||
|
clearCart: vi.fn(() => cartState.set(null)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: ToastService,
|
||||||
|
useValue: {
|
||||||
|
show: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
danger: vi.fn(),
|
||||||
|
success: vi.fn(),
|
||||||
|
dismiss: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: AuthService,
|
||||||
|
useValue: {
|
||||||
|
user: authUserState,
|
||||||
|
isAuthenticated: isAuthenticatedState,
|
||||||
|
logout: vi.fn().mockReturnValue(of(void 0)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: CheckoutService,
|
||||||
|
useValue: checkoutServiceStub,
|
||||||
|
},
|
||||||
|
],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -23,7 +230,78 @@ describe('StoreLayoutComponent', () => {
|
|||||||
expect(compiled.querySelector('app-store-footer .store-layout__footer')).not.toBeNull();
|
expect(compiled.querySelector('app-store-footer .store-layout__footer')).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the placeholder branding and action icons', () => {
|
it('opens the cart when requested through the openCart query parameter', () => {
|
||||||
|
const fixture = TestBed.createComponent(StoreLayoutComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect((fixture.componentInstance as any).isCartOpen()).toBe(false);
|
||||||
|
|
||||||
|
queryParamMapState.next(convertToParamMap({ openCart: 'true' }));
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect((fixture.componentInstance as any).isCartOpen()).toBe(true);
|
||||||
|
expect((fixture.nativeElement as HTMLElement).querySelector('app-cart')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables the cart icon and prevents opening the popup during checkout with a tenant base path', () => {
|
||||||
|
tenantState.set({ ...tenant, base_path: 'fiesta' });
|
||||||
|
const router = TestBed.inject(Router);
|
||||||
|
vi.spyOn(router, 'url', 'get').mockReturnValue('/fiesta/checkout?purchase=25');
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(StoreLayoutComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
const cartButton = element.querySelector<HTMLButtonElement>('app-cart-icon button');
|
||||||
|
const header = fixture.debugElement.query(By.directive(StoreHeaderComponent));
|
||||||
|
|
||||||
|
expect(cartButton?.disabled).toBe(true);
|
||||||
|
expect(cartButton?.getAttribute('aria-label')).toBe(
|
||||||
|
'Carrito no disponible durante el checkout',
|
||||||
|
);
|
||||||
|
expect(element.querySelector('[data-testid="cart-badge"]')).toBeNull();
|
||||||
|
|
||||||
|
header.componentInstance.cartClick.emit();
|
||||||
|
queryParamMapState.next(convertToParamMap({ openCart: 'true' }));
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect((fixture.componentInstance as any).isCartOpen()).toBe(false);
|
||||||
|
expect(element.querySelector('app-cart')).toBeNull();
|
||||||
|
expect(element.querySelector('.store-layout__cart-overlay')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the configured header elements when the tenant disables them', () => {
|
||||||
|
tenantState.set({
|
||||||
|
...tenant,
|
||||||
|
categories: [{ id: 1, nombre: 'Remeras', subcategories: [] }],
|
||||||
|
display_categories: false,
|
||||||
|
display_seach_bar: false,
|
||||||
|
display_cart: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(StoreLayoutComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const compiled = fixture.nativeElement as HTMLElement;
|
||||||
|
|
||||||
|
expect(compiled.querySelector('#store-search-input')).toBeNull();
|
||||||
|
expect(compiled.querySelector('.store-layout__category-trigger')).toBeNull();
|
||||||
|
expect(compiled.querySelector('app-cart-icon')).toBeNull();
|
||||||
|
|
||||||
|
(fixture.componentInstance as any).isCartOpen.set(true);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(compiled.querySelector('app-cart')).toBeNull();
|
||||||
|
expect(compiled.querySelector('.store-layout__cart-overlay')).toBeNull();
|
||||||
|
|
||||||
|
const header = fixture.debugElement.query(By.directive(StoreHeaderComponent));
|
||||||
|
(header.componentInstance as any).toggleMobileMenu();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(compiled.querySelectorAll('.store-layout__mobile-dropdown-item')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the tenant branding and action icons', () => {
|
||||||
const fixture = TestBed.createComponent(StoreLayoutComponent);
|
const fixture = TestBed.createComponent(StoreLayoutComponent);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
@@ -31,11 +309,508 @@ describe('StoreLayoutComponent', () => {
|
|||||||
|
|
||||||
expect(compiled.querySelector('[data-testid="store-header-brand-slot"]')).not.toBeNull();
|
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('[data-testid="store-footer-brand-slot"]')).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
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}"]`,
|
||||||
|
),
|
||||||
|
).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
(compiled.querySelector('.store-layout__header') as HTMLElement).style.backgroundImage,
|
||||||
|
).toContain(tenant.header_bg_image!);
|
||||||
|
expect(
|
||||||
|
(compiled.querySelector('.store-layout__footer') as HTMLElement).style.backgroundImage,
|
||||||
|
).toContain(tenant.footer_bg_image!);
|
||||||
expect(compiled.querySelector('.fa-cart-shopping')).not.toBeNull();
|
expect(compiled.querySelector('.fa-cart-shopping')).not.toBeNull();
|
||||||
expect(compiled.querySelector('.fa-circle-user')).not.toBeNull();
|
expect(compiled.querySelector('.fa-circle-user')).not.toBeNull();
|
||||||
expect(compiled.querySelector('.fa-instagram')).not.toBeNull();
|
expect(compiled.querySelector('.fa-instagram')).not.toBeNull();
|
||||||
expect(compiled.querySelector('.fa-whatsapp')).not.toBeNull();
|
expect(compiled.querySelector('.fa-whatsapp')).not.toBeNull();
|
||||||
expect(compiled.querySelector('.fa-facebook')).not.toBeNull();
|
expect(compiled.querySelector('.fa-facebook')).not.toBeNull();
|
||||||
expect(compiled.querySelector('.fa-linkedin-in')).not.toBeNull();
|
expect(compiled.querySelector('.fa-linkedin-in')).not.toBeNull();
|
||||||
|
expect(compiled.querySelector('.store-layout__contact-details')?.textContent).toContain(
|
||||||
|
tenant.address,
|
||||||
|
);
|
||||||
|
expect(compiled.querySelector('.store-layout__contact-details')?.textContent).toContain(
|
||||||
|
tenant.phone,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('navigates to search results when the search button is clicked', () => {
|
||||||
|
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,
|
||||||
|
nombre: 'Producto',
|
||||||
|
imagen: null,
|
||||||
|
variant: 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,
|
||||||
|
nombre: 'Producto',
|
||||||
|
imagen: null,
|
||||||
|
variant: 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();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides quantity selectors when the tenant disables cart editing', () => {
|
||||||
|
tenantState.set({
|
||||||
|
...tenant,
|
||||||
|
cart_editing_policy: {
|
||||||
|
code: 'disabled',
|
||||||
|
allow_modify: false,
|
||||||
|
allow_delete: false,
|
||||||
|
allow_update_quantity: false,
|
||||||
|
allow_update_variant: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
cartState.set({
|
||||||
|
id: 1,
|
||||||
|
tenant_codigo: tenant.codigo,
|
||||||
|
status: 'active',
|
||||||
|
subtotal: '100.00',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
cantidad: 1,
|
||||||
|
precio_unitario: '100.00',
|
||||||
|
catalog_item_id: 1,
|
||||||
|
variant_id: null,
|
||||||
|
nombre: 'Producto',
|
||||||
|
imagen: null,
|
||||||
|
variant: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(StoreLayoutComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
(fixture.componentInstance as any).isCartOpen.set(true);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const cartItem = fixture.debugElement.query(By.css('app-cart-item'));
|
||||||
|
expect(cartItem.componentInstance.readonly()).toBe(true);
|
||||||
|
expect(cartItem.nativeElement.querySelector('app-quantity-selector')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows variant selectors only for the full cart editing policy', () => {
|
||||||
|
const variantCart: Cart = {
|
||||||
|
id: 1,
|
||||||
|
tenant_codigo: tenant.codigo,
|
||||||
|
status: 'active',
|
||||||
|
subtotal: '100.00',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
cantidad: 1,
|
||||||
|
precio_unitario: '100.00',
|
||||||
|
catalog_item_id: 1,
|
||||||
|
variant_id: 10,
|
||||||
|
nombre: 'Producto',
|
||||||
|
imagen: null,
|
||||||
|
variant: { id: 10, precio: '100.00', stock_tecnico: 5, values: { talle: 'M' } },
|
||||||
|
variants: [
|
||||||
|
{ id: 10, precio: '100.00', stock_tecnico: 5, values: { talle: 'M' } },
|
||||||
|
{ id: 11, precio: '100.00', stock_tecnico: 5, values: { talle: 'L' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
tenantState.set({
|
||||||
|
...tenant,
|
||||||
|
cart_editing_policy: {
|
||||||
|
code: 'quantity_and_remove',
|
||||||
|
allow_modify: true,
|
||||||
|
allow_delete: true,
|
||||||
|
allow_update_quantity: true,
|
||||||
|
allow_update_variant: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
cartState.set(variantCart);
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(StoreLayoutComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
(fixture.componentInstance as any).isCartOpen.set(true);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(fixture.nativeElement.querySelector('app-variant-selector')).toBeNull();
|
||||||
|
expect(fixture.nativeElement.querySelector('app-quantity-selector')).not.toBeNull();
|
||||||
|
|
||||||
|
tenantState.set({
|
||||||
|
...tenant,
|
||||||
|
cart_editing_policy: {
|
||||||
|
code: 'full',
|
||||||
|
allow_modify: true,
|
||||||
|
allow_delete: true,
|
||||||
|
allow_update_quantity: true,
|
||||||
|
allow_update_variant: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(fixture.nativeElement.querySelector('app-variant-selector')).not.toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,59 +1,310 @@
|
|||||||
import { Component, inject } from '@angular/core';
|
import { Component, computed, DestroyRef, inject, OnInit, signal } from '@angular/core';
|
||||||
import { RouterOutlet } from '@angular/router';
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
|
import {
|
||||||
|
ActivatedRoute,
|
||||||
|
NavigationEnd,
|
||||||
|
PRIMARY_OUTLET,
|
||||||
|
Router,
|
||||||
|
RouterOutlet,
|
||||||
|
} from '@angular/router';
|
||||||
|
import { filter } from 'rxjs';
|
||||||
import { TenantService } from '../../services/tenant.service';
|
import { TenantService } from '../../services/tenant.service';
|
||||||
import { StoreFooterComponent, StoreFooterSection, StoreSocialLink } from './store-footer/store-footer.component';
|
import { CartService } from '../../services/cart/cart.service';
|
||||||
import { StoreHeaderComponent, StoreHeaderAction } from './store-header/store-header.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, CartItemVariantValue } from '../../services/cart/cart.interface';
|
||||||
|
import { AuthService } from '../../services/auth/auth.service';
|
||||||
|
import { findMenu } from '../../services/menu.utils';
|
||||||
|
import { CheckoutService } from '../../services/checkout.service';
|
||||||
|
import { ToastService } from '../../services/toast.service';
|
||||||
|
import { Category } from '../../services/tenant.interface';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-store-layout',
|
selector: 'app-store-layout',
|
||||||
imports: [RouterOutlet, StoreHeaderComponent, StoreFooterComponent],
|
imports: [
|
||||||
|
RouterOutlet,
|
||||||
|
StoreHeaderComponent,
|
||||||
|
StoreFooterComponent,
|
||||||
|
CartComponent,
|
||||||
|
ButtonComponent,
|
||||||
|
],
|
||||||
templateUrl: './store-layout.component.html',
|
templateUrl: './store-layout.component.html',
|
||||||
styleUrl: './store-layout.component.scss'
|
styleUrl: './store-layout.component.scss',
|
||||||
})
|
})
|
||||||
export class StoreLayoutComponent {
|
export class StoreLayoutComponent implements OnInit {
|
||||||
private readonly tenantService = inject(TenantService);
|
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);
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
|
protected readonly isCartOpen = signal(false);
|
||||||
|
protected readonly isCheckoutRoute = signal(this.isCheckoutUrl(this.router.url));
|
||||||
|
protected readonly isCreatingPurchase = signal(false);
|
||||||
|
protected readonly displayCart = computed(() => this.tenant()?.display_cart ?? true);
|
||||||
|
protected readonly cartEditingPolicy = computed(() => this.tenant()?.cart_editing_policy ?? null);
|
||||||
|
protected readonly canModifyCart = computed(
|
||||||
|
() => this.cartEditingPolicy()?.allow_modify ?? false,
|
||||||
|
);
|
||||||
|
protected readonly canDeleteCartItems = computed(
|
||||||
|
() => this.cartEditingPolicy()?.allow_delete ?? false,
|
||||||
|
);
|
||||||
|
protected readonly canUpdateCartQuantity = computed(
|
||||||
|
() => this.cartEditingPolicy()?.allow_update_quantity ?? false,
|
||||||
|
);
|
||||||
|
protected readonly canUpdateCartVariant = computed(
|
||||||
|
() => this.cartEditingPolicy()?.allow_update_variant ?? false,
|
||||||
|
);
|
||||||
|
|
||||||
|
protected readonly cartSubtotal = computed(() => {
|
||||||
|
const cart = this.cartService.cart();
|
||||||
|
return cart ? parseFloat(cart.subtotal) : 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
protected readonly cartDiscount = computed(() => 0);
|
||||||
|
|
||||||
|
protected readonly cartTotal = computed(() => this.cartSubtotal() - this.cartDiscount());
|
||||||
|
|
||||||
|
protected readonly mappedCartItems = computed<CartItemMock[]>(() => {
|
||||||
|
const cart = this.cartService.cart();
|
||||||
|
if (!cart || !cart.items) return [];
|
||||||
|
return cart.items.map((item) => this.mapCartItemToMock(item));
|
||||||
|
});
|
||||||
|
|
||||||
|
private mapCartItemToMock(item: CartItem): CartItemMock {
|
||||||
|
const fullName = item.nombre ?? '';
|
||||||
|
let product = fullName;
|
||||||
|
let attributes: { label: string; value: string }[] = [];
|
||||||
|
|
||||||
|
const match = fullName.match(/^(.*?)\s*\((.*?)\)$/);
|
||||||
|
if (match) {
|
||||||
|
product = match[1];
|
||||||
|
const attributesString = match[2];
|
||||||
|
attributes = attributesString.split(',').map((attr) => {
|
||||||
|
const parts = attr.split(':');
|
||||||
|
if (parts.length === 2) {
|
||||||
|
return { label: parts[0].trim(), value: parts[1].trim() };
|
||||||
|
}
|
||||||
|
return { label: '', value: attr.trim() };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedVariant = item.variant;
|
||||||
|
|
||||||
|
if (selectedVariant) {
|
||||||
|
attributes = Object.entries(selectedVariant.values).map(([label, value]) => ({
|
||||||
|
label: this.formatAttributeLabel(label),
|
||||||
|
value: this.formatAttributeValue(value),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
cartItemId: item.id,
|
||||||
|
imageUrl: item.imagen,
|
||||||
|
product,
|
||||||
|
originalPrice: null,
|
||||||
|
discountedPrice: parseFloat(item.precio_unitario),
|
||||||
|
discountPercentage: null,
|
||||||
|
attributes,
|
||||||
|
quantity: item.cantidad,
|
||||||
|
variantId: item.variant_id,
|
||||||
|
variants: item.variants,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatAttributeLabel(value: string): string {
|
||||||
|
const label = value.replace(/[_-]+/g, ' ');
|
||||||
|
return label.charAt(0).toUpperCase() + label.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatAttributeValue(value: CartItemVariantValue): string {
|
||||||
|
const values = Array.isArray(value) ? value : [value];
|
||||||
|
return values.map((item) => (typeof item === 'string' ? item : item.label)).join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
protected readonly currentYear = new Date().getFullYear();
|
protected readonly currentYear = new Date().getFullYear();
|
||||||
protected readonly tenant = this.tenantService.tenant;
|
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');
|
||||||
|
|
||||||
protected readonly headerActions: StoreHeaderAction[] = [
|
if (!accountMenu) {
|
||||||
{
|
return [];
|
||||||
label: 'Carrito',
|
|
||||||
iconClass: 'fa-solid fa-cart-shopping'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Mi cuenta',
|
|
||||||
iconClass: 'fa-solid fa-circle-user'
|
|
||||||
}
|
}
|
||||||
];
|
|
||||||
|
|
||||||
protected readonly footerSections: StoreFooterSection[] = [
|
const profile = accountMenu.submenues.find((menu) => menu.code === 'account.profile');
|
||||||
{
|
const purchases = accountMenu.submenues.find((menu) => menu.code === 'account.purchases');
|
||||||
heading: 'Cuenta',
|
|
||||||
links: ['Mi cuenta', 'Mis compras', 'Cerrar sesion']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
heading: 'Ayuda',
|
|
||||||
links: ['Contacto', 'Ayuda', 'Preguntas frecuentes']
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
protected readonly socialLinks: StoreSocialLink[] = [
|
return [
|
||||||
{
|
...(profile ? [{ ...accountMenu, route: profile.route }] : []),
|
||||||
label: 'Instagram',
|
...(purchases ? [purchases] : []),
|
||||||
iconClass: 'fa-brands fa-instagram'
|
];
|
||||||
},
|
});
|
||||||
{
|
|
||||||
label: 'WhatsApp',
|
protected readonly cartQuantity = computed(() => {
|
||||||
iconClass: 'fa-brands fa-whatsapp'
|
const cart = this.cartService.cart();
|
||||||
},
|
if (!cart || !cart.items) return 0;
|
||||||
{
|
return cart.items.reduce((total, item) => total + item.cantidad, 0);
|
||||||
label: 'Facebook',
|
});
|
||||||
iconClass: 'fa-brands fa-facebook'
|
|
||||||
},
|
ngOnInit(): void {
|
||||||
{
|
this.router.events
|
||||||
label: 'LinkedIn',
|
.pipe(
|
||||||
iconClass: 'fa-brands fa-linkedin-in'
|
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
|
||||||
|
takeUntilDestroyed(this.destroyRef),
|
||||||
|
)
|
||||||
|
.subscribe((event) => {
|
||||||
|
const isCheckoutRoute = this.isCheckoutUrl(event.urlAfterRedirects);
|
||||||
|
this.isCheckoutRoute.set(isCheckoutRoute);
|
||||||
|
|
||||||
|
if (isCheckoutRoute) {
|
||||||
|
this.isCartOpen.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.route.queryParamMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => {
|
||||||
|
if (params.get('openCart') === 'true' && !this.isCheckoutRoute()) {
|
||||||
|
this.isCartOpen.set(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.cartService.loadCart().subscribe({
|
||||||
|
error: (err) => console.error('Error loading cart', err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private isCheckoutUrl(url: string): boolean {
|
||||||
|
const primarySegments = this.router.parseUrl(url).root.children[PRIMARY_OUTLET]?.segments ?? [];
|
||||||
|
|
||||||
|
return primarySegments[0]?.path === 'checkout';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onLoginClick(): void {
|
||||||
|
void this.router.navigate(['/login']);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onCartClick(): void {
|
||||||
|
if (this.isCheckoutRoute()) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
];
|
|
||||||
|
this.isCartOpen.update((isOpen) => !isOpen);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onSearch(term: string): void {
|
||||||
|
void this.router.navigate(['/buscar'], {
|
||||||
|
queryParams: { q: term, page: 1 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onCategorySelect(category: Category): void {
|
||||||
|
void this.router.navigate(['/categoria', category.id], {
|
||||||
|
queryParams: { page: 1 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onTicketsClick(): void {
|
||||||
|
const route = this.ticketsMenu()?.route;
|
||||||
|
|
||||||
|
if (route) {
|
||||||
|
void this.router.navigate([route]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async onLogoutClick(): Promise<void> {
|
||||||
|
const isLeavingCheckout = this.router.url.startsWith('/checkout');
|
||||||
|
|
||||||
|
// Leave checkout before closing the authenticated session so its component
|
||||||
|
// can stop payment polling cleanly. The checkout itself remains pending.
|
||||||
|
if (isLeavingCheckout) {
|
||||||
|
const navigationSucceeded = await this.router.navigate(['/']);
|
||||||
|
|
||||||
|
if (!navigationSucceeded) {
|
||||||
|
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.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,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
30
src/app/core/services/api-paginated-response.interface.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { ApiResponse } from './api-response.interface';
|
||||||
|
|
||||||
|
export interface ApiPaginationMetaLink {
|
||||||
|
url: string | null;
|
||||||
|
label: string;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiPaginationMeta {
|
||||||
|
current_page: number;
|
||||||
|
from: number | null;
|
||||||
|
last_page: number;
|
||||||
|
links: ApiPaginationMetaLink[];
|
||||||
|
path: string;
|
||||||
|
per_page: number;
|
||||||
|
to: number | null;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiPaginationLinks {
|
||||||
|
first: string;
|
||||||
|
last: string;
|
||||||
|
prev: string | null;
|
||||||
|
next: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiPaginatedResponse<T> extends ApiResponse<T> {
|
||||||
|
meta: ApiPaginationMeta;
|
||||||
|
links: ApiPaginationLinks;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export interface ApiPaginationQueryParams {
|
||||||
|
page?: number;
|
||||||
|
per_page?: number;
|
||||||
|
}
|
||||||
5
src/app/core/services/api-response.interface.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export interface ApiResponse<T> {
|
||||||
|
data: T;
|
||||||
|
message?: string;
|
||||||
|
errors?: Record<string, string[]>;
|
||||||
|
}
|
||||||
9
src/app/core/services/auth/auth-bootstrap.ts
Normal 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();
|
||||||
|
}
|
||||||
68
src/app/core/services/auth/auth.guards.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
22
src/app/core/services/auth/auth.guards.ts
Normal 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;
|
||||||
|
};
|
||||||
103
src/app/core/services/auth/auth.interceptor.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
23
src/app/core/services/auth/auth.interceptor.ts
Normal 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);
|
||||||
|
};
|
||||||
61
src/app/core/services/auth/auth.interfaces.ts
Normal 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';
|
||||||
|
}
|
||||||
430
src/app/core/services/auth/auth.service.spec.ts
Normal file
@@ -0,0 +1,430 @@
|
|||||||
|
import { PLATFORM_ID, TransferState } from '@angular/core';
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
|
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
|
import { TimeoutError } from 'rxjs';
|
||||||
|
|
||||||
|
import { environment } from '../../../../environments/environment';
|
||||||
|
import { BOOTSTRAP_REQUEST_TIMEOUT_MS } from '../bootstrap-request-timeout';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { CookieService } from '../cookie/cookie.service';
|
||||||
|
import { TenantService } from '../tenant.service';
|
||||||
|
|
||||||
|
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.front.auth.token']).toBe('plain-text-token');
|
||||||
|
|
||||||
|
httpController.verify();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hydrates token from cookies and loads the current user during bootstrap', async () => {
|
||||||
|
cookieStore['shopit.front.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.front.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.front.auth.token']).toBeUndefined();
|
||||||
|
|
||||||
|
httpController.verify();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('propagates a /me timeout without clearing the local session', async () => {
|
||||||
|
cookieStore['shopit.front.auth.token'] = 'valid-token';
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
try {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
provideHttpClient(),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
AuthService,
|
||||||
|
{ provide: CookieService, useValue: createCookieServiceStub() },
|
||||||
|
{ provide: BOOTSTRAP_REQUEST_TIMEOUT_MS, useValue: 25 },
|
||||||
|
TransferState,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = TestBed.inject(AuthService);
|
||||||
|
const httpController = TestBed.inject(HttpTestingController);
|
||||||
|
|
||||||
|
const bootstrapPromise = service.bootstrap();
|
||||||
|
const rejection = expect(bootstrapPromise).rejects.toBeInstanceOf(TimeoutError);
|
||||||
|
const request = httpController.expectOne(`${environment.url}me`);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(25);
|
||||||
|
await rejection;
|
||||||
|
|
||||||
|
expect(request.cancelled).toBe(true);
|
||||||
|
expect(service.user()).toBeNull();
|
||||||
|
expect(service.token()).toBe('valid-token');
|
||||||
|
expect(cookieStore['shopit.front.auth.token']).toBe('valid-token');
|
||||||
|
|
||||||
|
httpController.verify();
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers without creating a session', () => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
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.front.auth.token']).toBe('plain-text-token');
|
||||||
|
|
||||||
|
httpController.verify();
|
||||||
|
});
|
||||||
|
});
|
||||||
228
src/app/core/services/auth/auth.service.ts
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
import { DOCUMENT } from '@angular/common';
|
||||||
|
import { HttpErrorResponse, HttpResponse } from '@angular/common/http';
|
||||||
|
import { isPlatformBrowser, isPlatformServer } from '@angular/common';
|
||||||
|
import { computed, inject, Injectable, PLATFORM_ID, signal, TransferState, makeStateKey } from '@angular/core';
|
||||||
|
import { firstValueFrom, map, Observable, of, tap, timeout } from 'rxjs';
|
||||||
|
|
||||||
|
import { environment } from '../../../../environments/environment';
|
||||||
|
import { BaseApiService } from '../base-api.service';
|
||||||
|
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';
|
||||||
|
import { BOOTSTRAP_REQUEST_TIMEOUT_MS } from '../bootstrap-request-timeout';
|
||||||
|
|
||||||
|
const AUTH_TOKEN_COOKIE_KEY = 'shopit.front.auth.token';
|
||||||
|
const AUTH_USER_SSR_STATE_KEY = makeStateKey<AuthUser>('shopit.auth.user');
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class AuthService extends BaseApiService {
|
||||||
|
private readonly document = inject(DOCUMENT);
|
||||||
|
private readonly platformId = inject(PLATFORM_ID);
|
||||||
|
private readonly cookieService = inject(CookieService);
|
||||||
|
private readonly transferState = inject(TransferState);
|
||||||
|
private readonly tenantService = inject(TenantService);
|
||||||
|
private readonly bootstrapRequestTimeoutMs = inject(BOOTSTRAP_REQUEST_TIMEOUT_MS);
|
||||||
|
|
||||||
|
private readonly userState = signal<AuthUser | null>(null);
|
||||||
|
private readonly tokenState = signal<string | null>(null);
|
||||||
|
|
||||||
|
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);
|
||||||
|
const basePath = tenant.base_path && tenant.base_path !== '/' ? tenant.base_path : '';
|
||||||
|
authorizationUrl.searchParams.set('tenant', tenant.codigo);
|
||||||
|
authorizationUrl.searchParams.set(
|
||||||
|
'return_url',
|
||||||
|
`${this.document.location.origin}${basePath}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
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().pipe(timeout(this.bootstrapRequestTimeoutMs)),
|
||||||
|
);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
42
src/app/core/services/base-api.service.spec.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import '@angular/compiler';
|
||||||
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { BaseApiService } from './base-api.service';
|
||||||
|
import { LOADING_MODE } from './global-loading/loading-mode';
|
||||||
|
|
||||||
|
class TestApiService extends BaseApiService {
|
||||||
|
contextFor() {
|
||||||
|
return this.loadingContext();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BaseApiService', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses global loading by default', () => {
|
||||||
|
TestBed.configureTestingModule({ providers: [provideHttpClient()] });
|
||||||
|
const service = TestBed.runInInjectionContext(() => new TestApiService());
|
||||||
|
|
||||||
|
expect(service.contextFor().get(LOADING_MODE)).toBe('global');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates an isolated custom-loading scope', () => {
|
||||||
|
TestBed.configureTestingModule({ providers: [provideHttpClient()] });
|
||||||
|
const service = TestBed.runInInjectionContext(() => new TestApiService());
|
||||||
|
|
||||||
|
expect(service.withCustomLoading().contextFor().get(LOADING_MODE)).toBe('custom');
|
||||||
|
expect(service.contextFor().get(LOADING_MODE)).toBe('global');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates an isolated scope without a loading indicator', () => {
|
||||||
|
TestBed.configureTestingModule({ providers: [provideHttpClient()] });
|
||||||
|
const service = TestBed.runInInjectionContext(() => new TestApiService());
|
||||||
|
|
||||||
|
expect(service.withoutLoading().contextFor().get(LOADING_MODE)).toBe('none');
|
||||||
|
expect(service.contextFor().get(LOADING_MODE)).toBe('global');
|
||||||
|
});
|
||||||
|
});
|
||||||
79
src/app/core/services/base-api.service.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { HttpClient, HttpContext } from '@angular/common/http';
|
||||||
|
import { inject } from '@angular/core';
|
||||||
|
|
||||||
|
import {
|
||||||
|
LOADING_MODE,
|
||||||
|
LoadingMode
|
||||||
|
} from './global-loading/loading-mode';
|
||||||
|
|
||||||
|
const REQUEST_LOADING_MODE = Symbol('request-loading-mode');
|
||||||
|
|
||||||
|
class LoadingHttpClient {
|
||||||
|
readonly get: HttpClient['get'];
|
||||||
|
readonly post: HttpClient['post'];
|
||||||
|
readonly put: HttpClient['put'];
|
||||||
|
readonly patch: HttpClient['patch'];
|
||||||
|
readonly delete: HttpClient['delete'];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
http: HttpClient,
|
||||||
|
private readonly buildContext: (context?: HttpContext) => HttpContext
|
||||||
|
) {
|
||||||
|
this.get = ((url: string, options?: object) =>
|
||||||
|
http.get(url, this.withLoadingContext(options))) as HttpClient['get'];
|
||||||
|
this.post = ((url: string, body: unknown, options?: object) =>
|
||||||
|
http.post(url, body, this.withLoadingContext(options))) as HttpClient['post'];
|
||||||
|
this.put = ((url: string, body: unknown, options?: object) =>
|
||||||
|
http.put(url, body, this.withLoadingContext(options))) as HttpClient['put'];
|
||||||
|
this.patch = ((url: string, body: unknown, options?: object) =>
|
||||||
|
http.patch(url, body, this.withLoadingContext(options))) as HttpClient['patch'];
|
||||||
|
this.delete = ((url: string, options?: object) =>
|
||||||
|
http.delete(url, this.withLoadingContext(options))) as HttpClient['delete'];
|
||||||
|
}
|
||||||
|
|
||||||
|
private withLoadingContext(options?: object): object {
|
||||||
|
const requestOptions = options as { context?: HttpContext } | undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...requestOptions,
|
||||||
|
context: this.buildContext(requestOptions?.context)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export abstract class BaseApiService {
|
||||||
|
private readonly rawHttp = inject(HttpClient);
|
||||||
|
private readonly [REQUEST_LOADING_MODE]: LoadingMode = 'global';
|
||||||
|
|
||||||
|
protected get http(): LoadingHttpClient {
|
||||||
|
return new LoadingHttpClient(
|
||||||
|
this.rawHttp,
|
||||||
|
(context) => this.loadingContext(context)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
withLoading(loadingMode: LoadingMode): this {
|
||||||
|
const scopedService = Object.create(this) as this;
|
||||||
|
|
||||||
|
Object.defineProperty(scopedService, REQUEST_LOADING_MODE, {
|
||||||
|
configurable: false,
|
||||||
|
enumerable: false,
|
||||||
|
value: loadingMode,
|
||||||
|
writable: false
|
||||||
|
});
|
||||||
|
|
||||||
|
return scopedService;
|
||||||
|
}
|
||||||
|
|
||||||
|
withCustomLoading(): this {
|
||||||
|
return this.withLoading('custom');
|
||||||
|
}
|
||||||
|
|
||||||
|
withoutLoading(): this {
|
||||||
|
return this.withLoading('none');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected loadingContext(context: HttpContext = new HttpContext()): HttpContext {
|
||||||
|
return context.set(LOADING_MODE, this[REQUEST_LOADING_MODE]);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/app/core/services/bootstrap-request-timeout.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { InjectionToken } from '@angular/core';
|
||||||
|
|
||||||
|
export const BOOTSTRAP_REQUEST_TIMEOUT_MS = new InjectionToken<number>(
|
||||||
|
'BOOTSTRAP_REQUEST_TIMEOUT_MS',
|
||||||
|
{
|
||||||
|
providedIn: 'root',
|
||||||
|
factory: () => 10_000,
|
||||||
|
},
|
||||||
|
);
|
||||||
36
src/app/core/services/cart/cart.interface.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
export interface CartItemVariantOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CartItemVariantValue =
|
||||||
|
| string
|
||||||
|
| CartItemVariantOption
|
||||||
|
| Array<string | CartItemVariantOption>;
|
||||||
|
|
||||||
|
export interface CartItemVariant {
|
||||||
|
id: number;
|
||||||
|
precio: string;
|
||||||
|
stock_tecnico: number | null;
|
||||||
|
values: Record<string, CartItemVariantValue>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CartItem {
|
||||||
|
id: number;
|
||||||
|
cantidad: number;
|
||||||
|
precio_unitario: string;
|
||||||
|
catalog_item_id: number;
|
||||||
|
variant_id: number | null;
|
||||||
|
nombre: string | null;
|
||||||
|
imagen: string | null;
|
||||||
|
variant: CartItemVariant | null;
|
||||||
|
variants?: CartItemVariant[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Cart {
|
||||||
|
id: number | null;
|
||||||
|
tenant_codigo: string;
|
||||||
|
status: string;
|
||||||
|
items: CartItem[];
|
||||||
|
subtotal: string;
|
||||||
|
}
|
||||||
182
src/app/core/services/cart/cart.service.spec.ts
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { CartService } from './cart.service';
|
||||||
|
import { TenantService } from '../tenant.service';
|
||||||
|
import { Cart } from './cart.interface';
|
||||||
|
import { LOADING_MODE } from '../global-loading/loading-mode';
|
||||||
|
import { CatalogAvailabilityService } from '../catalog/catalog-availability.service';
|
||||||
|
|
||||||
|
describe('CartService', () => {
|
||||||
|
let service: CartService;
|
||||||
|
let httpMock: HttpTestingController;
|
||||||
|
let tenantServiceMock: any;
|
||||||
|
let catalogAvailabilityService: CatalogAvailabilityService;
|
||||||
|
|
||||||
|
const mockCart: Cart = {
|
||||||
|
id: 123,
|
||||||
|
tenant_codigo: 'acme',
|
||||||
|
status: 'active',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
cantidad: 2,
|
||||||
|
precio_unitario: '10.00',
|
||||||
|
catalog_item_id: 5,
|
||||||
|
variant_id: 10,
|
||||||
|
nombre: 'Test Product (Size: M)',
|
||||||
|
imagen: null,
|
||||||
|
variant: {
|
||||||
|
id: 10,
|
||||||
|
precio: '10.00',
|
||||||
|
stock_tecnico: 1,
|
||||||
|
values: { size: 'M' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
subtotal: '20.00',
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tenantServiceMock = {
|
||||||
|
getTenantApiUrl: vi.fn().mockReturnValue('http://api.test/tenants/acme'),
|
||||||
|
tenant: vi.fn().mockReturnValue({ codigo: 'acme' }),
|
||||||
|
};
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
CartService,
|
||||||
|
provideHttpClient(),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
{ provide: TenantService, useValue: tenantServiceMock },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
service = TestBed.inject(CartService);
|
||||||
|
catalogAvailabilityService = TestBed.inject(CatalogAvailabilityService);
|
||||||
|
httpMock = TestBed.inject(HttpTestingController);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
httpMock.verify();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be created', () => {
|
||||||
|
expect(service).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should initialize with null cart signal', () => {
|
||||||
|
expect(service.cart()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should load cart and update signal', () => {
|
||||||
|
service.loadCart().subscribe((cart) => {
|
||||||
|
expect(cart).toEqual(mockCart);
|
||||||
|
expect(service.cart()).toEqual(mockCart);
|
||||||
|
});
|
||||||
|
|
||||||
|
const req = httpMock.expectOne('http://api.test/tenants/acme/cart');
|
||||||
|
expect(req.request.method).toBe('GET');
|
||||||
|
expect(req.request.withCredentials).toBe(true);
|
||||||
|
req.flush({ data: mockCart });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('propagates a custom loading mode to the request context', () => {
|
||||||
|
service.withCustomLoading().loadCart().subscribe();
|
||||||
|
|
||||||
|
const req = httpMock.expectOne('http://api.test/tenants/acme/cart');
|
||||||
|
expect(req.request.context.get(LOADING_MODE)).toBe('custom');
|
||||||
|
req.flush({ data: mockCart });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear cart state locally', () => {
|
||||||
|
service.clearCart();
|
||||||
|
|
||||||
|
expect(service.cart()).toEqual({
|
||||||
|
id: null,
|
||||||
|
tenant_codigo: 'acme',
|
||||||
|
status: 'active',
|
||||||
|
items: [],
|
||||||
|
subtotal: '0.00',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add item and update signal', () => {
|
||||||
|
const availabilityChanged = vi.fn();
|
||||||
|
catalogAvailabilityService.availabilityChanged$.subscribe(availabilityChanged);
|
||||||
|
|
||||||
|
service.addItem(5, 10, 2).subscribe((res) => {
|
||||||
|
expect(res.data).toEqual(mockCart);
|
||||||
|
expect(service.cart()).toEqual(mockCart);
|
||||||
|
});
|
||||||
|
|
||||||
|
const req = httpMock.expectOne('http://api.test/tenants/acme/cart/items');
|
||||||
|
expect(req.request.method).toBe('POST');
|
||||||
|
expect(req.request.body).toEqual({
|
||||||
|
catalog_item_id: 5,
|
||||||
|
variant_id: 10,
|
||||||
|
cantidad: 2,
|
||||||
|
});
|
||||||
|
expect(req.request.withCredentials).toBe(true);
|
||||||
|
req.flush({ data: mockCart });
|
||||||
|
expect(availabilityChanged).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update item quantity and update signal', () => {
|
||||||
|
const availabilityChanged = vi.fn();
|
||||||
|
catalogAvailabilityService.availabilityChanged$.subscribe(availabilityChanged);
|
||||||
|
|
||||||
|
const updatedCart = { ...mockCart, subtotal: '30.00' };
|
||||||
|
updatedCart.items[0].cantidad = 3;
|
||||||
|
|
||||||
|
service.updateItemQuantity(10, 3).subscribe((res) => {
|
||||||
|
expect(res.data).toEqual(updatedCart);
|
||||||
|
expect(service.cart()).toEqual(updatedCart);
|
||||||
|
});
|
||||||
|
|
||||||
|
const req = httpMock.expectOne('http://api.test/tenants/acme/cart/items/10');
|
||||||
|
expect(req.request.method).toBe('PATCH');
|
||||||
|
expect(req.request.body).toEqual({ cantidad: 3 });
|
||||||
|
expect(req.request.withCredentials).toBe(true);
|
||||||
|
req.flush({ data: updatedCart });
|
||||||
|
expect(availabilityChanged).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove item and update signal', () => {
|
||||||
|
const availabilityChanged = vi.fn();
|
||||||
|
catalogAvailabilityService.availabilityChanged$.subscribe(availabilityChanged);
|
||||||
|
|
||||||
|
const emptyCart: Cart = {
|
||||||
|
id: 123,
|
||||||
|
tenant_codigo: 'acme',
|
||||||
|
status: 'active',
|
||||||
|
items: [],
|
||||||
|
subtotal: '0.00',
|
||||||
|
};
|
||||||
|
|
||||||
|
service.removeItem(10).subscribe((res) => {
|
||||||
|
expect(res.data).toEqual(emptyCart);
|
||||||
|
expect(service.cart()).toEqual(emptyCart);
|
||||||
|
});
|
||||||
|
|
||||||
|
const req = httpMock.expectOne('http://api.test/tenants/acme/cart/items/10');
|
||||||
|
expect(req.request.method).toBe('DELETE');
|
||||||
|
expect(req.request.withCredentials).toBe(true);
|
||||||
|
req.flush({ data: emptyCart });
|
||||||
|
expect(availabilityChanged).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not notify an availability change when a cart mutation fails', () => {
|
||||||
|
const availabilityChanged = vi.fn();
|
||||||
|
catalogAvailabilityService.availabilityChanged$.subscribe(availabilityChanged);
|
||||||
|
|
||||||
|
service.removeItem(10).subscribe({ error: vi.fn() });
|
||||||
|
|
||||||
|
const req = httpMock.expectOne('http://api.test/tenants/acme/cart/items/10');
|
||||||
|
req.flush({ message: 'Error' }, { status: 500, statusText: 'Server Error' });
|
||||||
|
|
||||||
|
expect(availabilityChanged).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
127
src/app/core/services/cart/cart.service.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { inject, Injectable, signal } from '@angular/core';
|
||||||
|
import { catchError, map, Observable, tap } from 'rxjs';
|
||||||
|
|
||||||
|
import { ApiResponse } from '../api-response.interface';
|
||||||
|
import { BaseApiService } from '../base-api.service';
|
||||||
|
import { CatalogAvailabilityService } from '../catalog/catalog-availability.service';
|
||||||
|
import { TenantService } from '../tenant.service';
|
||||||
|
import { Cart } from './cart.interface';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class CartService extends BaseApiService {
|
||||||
|
private readonly catalogAvailabilityService = inject(CatalogAvailabilityService);
|
||||||
|
private readonly tenantService = inject(TenantService);
|
||||||
|
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
.pipe(
|
||||||
|
map((response) => response.data),
|
||||||
|
tap((cart) => this.cartState.set(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`,
|
||||||
|
{ catalog_item_id: catalogItemId, variant_id: variantId, cantidad },
|
||||||
|
{
|
||||||
|
withCredentials: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.pipe(
|
||||||
|
tap((response) => {
|
||||||
|
this.cartState.set(response.data);
|
||||||
|
this.catalogAvailabilityService.notifyAvailabilityChanged();
|
||||||
|
this.isUpdatingState.set(false);
|
||||||
|
}),
|
||||||
|
catchError((error) => {
|
||||||
|
this.isUpdatingState.set(false);
|
||||||
|
throw error;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateItemQuantity(cartItemId: number, cantidad: number): Observable<ApiResponse<Cart>> {
|
||||||
|
return this.updateItem(cartItemId, { cantidad });
|
||||||
|
}
|
||||||
|
|
||||||
|
updateItemVariant(
|
||||||
|
cartItemId: number,
|
||||||
|
cantidad: number,
|
||||||
|
variantId: number,
|
||||||
|
): Observable<ApiResponse<Cart>> {
|
||||||
|
return this.updateItem(cartItemId, { cantidad, variant_id: variantId });
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateItem(
|
||||||
|
cartItemId: number,
|
||||||
|
payload: { cantidad: number; variant_id?: number },
|
||||||
|
): Observable<ApiResponse<Cart>> {
|
||||||
|
this.isUpdatingState.set(true);
|
||||||
|
return this.http
|
||||||
|
.patch<ApiResponse<Cart>>(`${this.tenantApiUrl}/cart/items/${cartItemId}`, payload, {
|
||||||
|
withCredentials: true,
|
||||||
|
})
|
||||||
|
.pipe(
|
||||||
|
tap((response) => {
|
||||||
|
this.cartState.set(response.data);
|
||||||
|
this.catalogAvailabilityService.notifyAvailabilityChanged();
|
||||||
|
this.isUpdatingState.set(false);
|
||||||
|
}),
|
||||||
|
catchError((error) => {
|
||||||
|
this.isUpdatingState.set(false);
|
||||||
|
throw error;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeItem(cartItemId: number): Observable<ApiResponse<Cart>> {
|
||||||
|
this.isUpdatingState.set(true);
|
||||||
|
return this.http
|
||||||
|
.delete<ApiResponse<Cart>>(`${this.tenantApiUrl}/cart/items/${cartItemId}`, {
|
||||||
|
withCredentials: true,
|
||||||
|
})
|
||||||
|
.pipe(
|
||||||
|
tap((response) => {
|
||||||
|
this.cartState.set(response.data);
|
||||||
|
this.catalogAvailabilityService.notifyAvailabilityChanged();
|
||||||
|
this.isUpdatingState.set(false);
|
||||||
|
}),
|
||||||
|
catchError((error) => {
|
||||||
|
this.isUpdatingState.set(false);
|
||||||
|
throw error;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { Observable, Subject } from 'rxjs';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class CatalogAvailabilityService {
|
||||||
|
private readonly availabilityChangedSubject = new Subject<void>();
|
||||||
|
|
||||||
|
readonly availabilityChanged$: Observable<void> = this.availabilityChangedSubject.asObservable();
|
||||||
|
|
||||||
|
notifyAvailabilityChanged(): void {
|
||||||
|
this.availabilityChangedSubject.next();
|
||||||
|
}
|
||||||
|
}
|
||||||
179
src/app/core/services/catalog/catalog.interface.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import { ApiPaginatedResponse } from '../api-paginated-response.interface';
|
||||||
|
|
||||||
|
export type CatalogItemType = 'product' | 'bundle';
|
||||||
|
|
||||||
|
export interface Product {
|
||||||
|
id: number;
|
||||||
|
type: CatalogItemType;
|
||||||
|
category_id: number;
|
||||||
|
brand_id: number | null;
|
||||||
|
slug: string;
|
||||||
|
nombre: string;
|
||||||
|
descripcion: string;
|
||||||
|
precio: string;
|
||||||
|
category: string;
|
||||||
|
brand: string | null;
|
||||||
|
images: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductAttributeOption {
|
||||||
|
id: number;
|
||||||
|
validity_time_id?: number | null;
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
sort_order: number;
|
||||||
|
metadata: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProductAttributeType =
|
||||||
|
| 'string'
|
||||||
|
| 'number'
|
||||||
|
| 'boolean'
|
||||||
|
| 'select'
|
||||||
|
| 'multiselect'
|
||||||
|
| 'color'
|
||||||
|
| 'image'
|
||||||
|
| 'event_date';
|
||||||
|
|
||||||
|
export interface ProductAttribute {
|
||||||
|
id: number;
|
||||||
|
codigo: string;
|
||||||
|
nombre: string;
|
||||||
|
is_required: boolean;
|
||||||
|
allow_multi_select?: boolean;
|
||||||
|
show_in_selector?: boolean;
|
||||||
|
metadata_schema: Record<string, unknown> | null;
|
||||||
|
type: ProductAttributeType;
|
||||||
|
options: ProductAttributeOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InventoryPolicy = 'tracked' | 'unlimited';
|
||||||
|
|
||||||
|
export interface CatalogVariantOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CatalogVariantValue =
|
||||||
|
| string
|
||||||
|
| CatalogVariantOption
|
||||||
|
| Array<string | CatalogVariantOption>;
|
||||||
|
|
||||||
|
export interface CatalogItemVariant {
|
||||||
|
id: number;
|
||||||
|
descripcion?: string | null;
|
||||||
|
precio?: string;
|
||||||
|
event_date_id?: number | null;
|
||||||
|
event_date_ids?: number[];
|
||||||
|
event_dates?: string[];
|
||||||
|
maximum_addable_quantity?: number | null;
|
||||||
|
unavailable_message?: string | null;
|
||||||
|
minimum_use_date?: string | null;
|
||||||
|
maximum_use_date?: string | null;
|
||||||
|
effective_minimum_use_date?: string | null;
|
||||||
|
effective_maximum_use_date?: string | null;
|
||||||
|
values: Record<string, string | string[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectedCatalogItemVariant extends CatalogItemVariant {
|
||||||
|
images: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CatalogItemDetail {
|
||||||
|
id: number;
|
||||||
|
type: CatalogItemType;
|
||||||
|
purpose?: 'product' | 'entry';
|
||||||
|
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;
|
||||||
|
max_units_per_user?: number | null;
|
||||||
|
has_tickets: boolean;
|
||||||
|
minimum_use_date: string | null;
|
||||||
|
maximum_use_date: string | null;
|
||||||
|
attributes: ProductAttribute[];
|
||||||
|
variants: CatalogItemVariant[];
|
||||||
|
selected_variant?: SelectedCatalogItemVariant;
|
||||||
|
maximum_addable_quantity?: number | null;
|
||||||
|
images?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CatalogProductLayout =
|
||||||
|
| 'row'
|
||||||
|
| 'column_with_image'
|
||||||
|
| 'column_with_cart'
|
||||||
|
| 'ticket_selector';
|
||||||
|
export type CatalogGroupLayout = 'paginated' | 'simple' | 'simple_vertical' | 'carousel' | 'single';
|
||||||
|
|
||||||
|
export interface CatalogFeaturedItemVariant {
|
||||||
|
id: number;
|
||||||
|
descripcion?: string | null;
|
||||||
|
precio?: string;
|
||||||
|
maximum_addable_quantity?: number | null;
|
||||||
|
unavailable_message?: string | null;
|
||||||
|
values: Record<string, CatalogVariantValue>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CatalogVariantOptionsResponse {
|
||||||
|
variants: CatalogFeaturedItemVariant[];
|
||||||
|
selectors: CatalogVariantSelector[];
|
||||||
|
selected_values: Record<string, string | string[]>;
|
||||||
|
resolved_variant: CatalogFeaturedItemVariant | null;
|
||||||
|
valid: boolean;
|
||||||
|
available_variant_count: number;
|
||||||
|
matching_variant_count: number;
|
||||||
|
price_range: {
|
||||||
|
minimum: string;
|
||||||
|
maximum: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CatalogVariantSelector {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
options: CatalogVariantValue[];
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CatalogFeaturedItem {
|
||||||
|
id: number;
|
||||||
|
type: CatalogItemType;
|
||||||
|
nombre: string;
|
||||||
|
descripcion?: string | null;
|
||||||
|
precio: number | string;
|
||||||
|
image?: string | null;
|
||||||
|
maximum_addable_quantity?: number | null;
|
||||||
|
unavailable_message?: string | null;
|
||||||
|
variants?: CatalogFeaturedItemVariant[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CatalogFeaturedItems =
|
||||||
|
| ApiPaginatedResponse<CatalogFeaturedItem[]>
|
||||||
|
| CatalogFeaturedItem[];
|
||||||
|
|
||||||
|
export interface CatalogFeaturedGroup {
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
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;
|
||||||
|
}
|
||||||
123
src/app/core/services/catalog/catalog.service.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { HttpParams } from '@angular/common/http';
|
||||||
|
import { inject, Injectable } from '@angular/core';
|
||||||
|
import { map, Observable } from 'rxjs';
|
||||||
|
|
||||||
|
import { ApiPaginationQueryParams } from '../api-pagination-query-params.interface';
|
||||||
|
import { ApiPaginatedResponse } from '../api-paginated-response.interface';
|
||||||
|
import { ApiResponse } from '../api-response.interface';
|
||||||
|
import { BaseApiService } from '../base-api.service';
|
||||||
|
import { TenantService } from '../tenant.service';
|
||||||
|
import {
|
||||||
|
CatalogFeaturedGroup,
|
||||||
|
CatalogFeaturedItem,
|
||||||
|
CatalogFeaturedItems,
|
||||||
|
CatalogItemDetail,
|
||||||
|
CatalogVariantOptionsResponse,
|
||||||
|
CategoryItemsResponse,
|
||||||
|
Product,
|
||||||
|
} from './catalog.interface';
|
||||||
|
|
||||||
|
type HttpParamValue = string | number | boolean | readonly (string | number | boolean)[];
|
||||||
|
|
||||||
|
export interface CatalogSearchQueryParams extends ApiPaginationQueryParams {
|
||||||
|
q: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class CatalogService extends BaseApiService {
|
||||||
|
private readonly tenantService = inject(TenantService);
|
||||||
|
|
||||||
|
private get tenantApiUrl(): string {
|
||||||
|
return this.tenantService.getTenantApiUrl();
|
||||||
|
}
|
||||||
|
|
||||||
|
getProductos(params?: ApiPaginationQueryParams): Observable<ApiPaginatedResponse<Product[]>> {
|
||||||
|
return this.http.get<ApiPaginatedResponse<Product[]>>(`${this.tenantApiUrl}/productos`, {
|
||||||
|
params: this.buildHttpParams(params),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<CatalogItemDetail>>(`${this.tenantApiUrl}/catalog-items/${id}`, {
|
||||||
|
params,
|
||||||
|
})
|
||||||
|
.pipe(map((response) => response.data));
|
||||||
|
}
|
||||||
|
|
||||||
|
getVariantOptions(
|
||||||
|
catalogItemId: number,
|
||||||
|
payload: {
|
||||||
|
selected_values: Record<string, string | string[]>;
|
||||||
|
cart_item_id?: number | null;
|
||||||
|
},
|
||||||
|
): Observable<CatalogVariantOptionsResponse> {
|
||||||
|
return this.http
|
||||||
|
.post<
|
||||||
|
ApiResponse<CatalogVariantOptionsResponse>
|
||||||
|
>(`${this.tenantApiUrl}/catalog-items/${catalogItemId}/variant-options`, payload, { withCredentials: true })
|
||||||
|
.pipe(map((response) => response.data));
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildHttpParams(params?: ApiPaginationQueryParams): HttpParams {
|
||||||
|
if (!params) {
|
||||||
|
return new HttpParams();
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromObject = Object.entries(params).reduce<Record<string, HttpParamValue>>(
|
||||||
|
(acc, [key, value]) => {
|
||||||
|
if (value !== undefined) {
|
||||||
|
acc[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
return new HttpParams({ fromObject });
|
||||||
|
}
|
||||||
|
}
|
||||||
41
src/app/core/services/checkout.service.spec.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
|
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
import { CheckoutService } from './checkout.service';
|
||||||
|
|
||||||
|
describe('CheckoutService', () => {
|
||||||
|
let service: CheckoutService;
|
||||||
|
let httpMock: HttpTestingController;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [CheckoutService, provideHttpClient(), provideHttpClientTesting()],
|
||||||
|
});
|
||||||
|
|
||||||
|
service = TestBed.inject(CheckoutService);
|
||||||
|
httpMock = TestBed.inject(HttpTestingController);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => httpMock.verify());
|
||||||
|
|
||||||
|
it('starts one direct checkout with all selected variants', async () => {
|
||||||
|
const payload = {
|
||||||
|
direct_items: [
|
||||||
|
{ catalog_item_id: 10, variant_id: 101, cantidad: 1 },
|
||||||
|
{ catalog_item_id: 10, variant_id: 102, cantidad: 1 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const purchasePromise = service.startCheckout('desfile', payload);
|
||||||
|
const request = httpMock.expectOne(`${environment.url}tenants/desfile/compras/start-checkout`);
|
||||||
|
|
||||||
|
expect(request.request.method).toBe('POST');
|
||||||
|
expect(request.request.body).toEqual(payload);
|
||||||
|
|
||||||
|
request.flush({ data: { id: 55 } });
|
||||||
|
|
||||||
|
await expect(purchasePromise).resolves.toMatchObject({ id: 55 });
|
||||||
|
});
|
||||||
|
});
|
||||||
271
src/app/core/services/checkout.service.ts
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
import { BaseApiService } from './base-api.service';
|
||||||
|
|
||||||
|
export interface UpdatePurchaseCustomerPayload {
|
||||||
|
dni: string;
|
||||||
|
telefono: string;
|
||||||
|
nombre_apellido: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DirectCheckoutItem {
|
||||||
|
catalog_item_id: number;
|
||||||
|
variant_id: number | null;
|
||||||
|
cantidad: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnavailableCheckoutItem {
|
||||||
|
index: number;
|
||||||
|
catalog_item_id: number;
|
||||||
|
variant_id: number | null;
|
||||||
|
requested_quantity: number;
|
||||||
|
available_quantity: number;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InsufficientStockResponse {
|
||||||
|
code: 'purchase.insufficient_stock';
|
||||||
|
message: string;
|
||||||
|
errors: Record<string, string[]>;
|
||||||
|
unavailable_items: UnavailableCheckoutItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isInsufficientStockResponse(value: unknown): value is InsufficientStockResponse {
|
||||||
|
if (typeof value !== 'object' || value === null) return false;
|
||||||
|
|
||||||
|
const response = value as Partial<InsufficientStockResponse>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
response.code === 'purchase.insufficient_stock' && Array.isArray(response.unavailable_items)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StartCheckoutPayload =
|
||||||
|
| {
|
||||||
|
cart_id: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
direct_items: DirectCheckoutItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface PurchaseStatusResponse {
|
||||||
|
status: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
expires_at: string | null;
|
||||||
|
dni: string | null;
|
||||||
|
transfer_payer_dni: string | null;
|
||||||
|
telefono: string | null;
|
||||||
|
nombre_apellido: string | null;
|
||||||
|
email: string | null;
|
||||||
|
items_source: 'purchase';
|
||||||
|
items: PurchaseDetailItemResponse[];
|
||||||
|
tickets_count?: number;
|
||||||
|
has_generated_tickets?: boolean;
|
||||||
|
subtotal: string;
|
||||||
|
total: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class CheckoutService extends BaseApiService {
|
||||||
|
async startCheckout(
|
||||||
|
tenantCode: string,
|
||||||
|
payload: StartCheckoutPayload,
|
||||||
|
): Promise<PurchaseDetailResponse> {
|
||||||
|
const response = await firstValueFrom(
|
||||||
|
this.http.post<{ data?: 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',
|
||||||
|
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 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
56
src/app/core/services/cookie/cookie.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import '@angular/compiler';
|
||||||
|
import { HttpClient, HttpContext, provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||||
|
import {
|
||||||
|
HttpTestingController,
|
||||||
|
provideHttpClientTesting
|
||||||
|
} from '@angular/common/http/testing';
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { globalLoadingInterceptor } from './global-loading.interceptor';
|
||||||
|
import { GlobalLoadingService } from './global-loading.service';
|
||||||
|
import { LOADING_MODE, LoadingMode } from './loading-mode';
|
||||||
|
|
||||||
|
describe('globalLoadingInterceptor', () => {
|
||||||
|
let client: HttpClient;
|
||||||
|
let httpTesting: HttpTestingController;
|
||||||
|
let loadingService: GlobalLoadingService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
provideHttpClient(withInterceptors([globalLoadingInterceptor])),
|
||||||
|
provideHttpClientTesting()
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
client = TestBed.inject(HttpClient);
|
||||||
|
httpTesting = TestBed.inject(HttpTestingController);
|
||||||
|
loadingService = TestBed.inject(GlobalLoadingService);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
httpTesting.verify();
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks concurrent requests until the last one completes', () => {
|
||||||
|
client.get('/api/first').subscribe();
|
||||||
|
client.get('/api/second').subscribe();
|
||||||
|
vi.advanceTimersByTime(150);
|
||||||
|
|
||||||
|
expect(loadingService.isLoading()).toBe(true);
|
||||||
|
|
||||||
|
httpTesting.expectOne('/api/first').flush({});
|
||||||
|
expect(loadingService.isLoading()).toBe(true);
|
||||||
|
|
||||||
|
httpTesting.expectOne('/api/second').flush({});
|
||||||
|
expect(loadingService.isLoading()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops loading when a request fails', () => {
|
||||||
|
client.get('/api/failing').subscribe({ error: () => undefined });
|
||||||
|
vi.advanceTimersByTime(150);
|
||||||
|
|
||||||
|
expect(loadingService.isLoading()).toBe(true);
|
||||||
|
|
||||||
|
httpTesting.expectOne('/api/failing').flush(
|
||||||
|
{ message: 'Error' },
|
||||||
|
{ status: 500, statusText: 'Server Error' }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(loadingService.isLoading()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each<LoadingMode>(['custom', 'none'])(
|
||||||
|
'does not show the global loader for %s requests',
|
||||||
|
(loadingMode) => {
|
||||||
|
const context = new HttpContext().set(LOADING_MODE, loadingMode);
|
||||||
|
|
||||||
|
client.get(`/api/${loadingMode}`, { context }).subscribe();
|
||||||
|
vi.advanceTimersByTime(150);
|
||||||
|
|
||||||
|
expect(loadingService.isLoading()).toBe(false);
|
||||||
|
|
||||||
|
httpTesting.expectOne(`/api/${loadingMode}`).flush({});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { HttpEvent, HttpHandlerFn, HttpInterceptorFn, HttpRequest } from '@angular/common/http';
|
||||||
|
import { inject } from '@angular/core';
|
||||||
|
import { Observable, finalize } from 'rxjs';
|
||||||
|
|
||||||
|
import { GlobalLoadingService } from './global-loading.service';
|
||||||
|
import { LOADING_MODE } from './loading-mode';
|
||||||
|
|
||||||
|
export const globalLoadingInterceptor: HttpInterceptorFn = (
|
||||||
|
request: HttpRequest<unknown>,
|
||||||
|
next: HttpHandlerFn
|
||||||
|
): Observable<HttpEvent<unknown>> => {
|
||||||
|
if (request.context.get(LOADING_MODE) !== 'global') {
|
||||||
|
return next(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadingService = inject(GlobalLoadingService);
|
||||||
|
loadingService.start();
|
||||||
|
|
||||||
|
return next(request).pipe(finalize(() => loadingService.stop()));
|
||||||
|
};
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import '@angular/compiler';
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { GlobalLoadingService } from './global-loading.service';
|
||||||
|
|
||||||
|
describe('GlobalLoadingService', () => {
|
||||||
|
let service: GlobalLoadingService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
TestBed.configureTestingModule({});
|
||||||
|
service = TestBed.inject(GlobalLoadingService);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('delays the loading state to avoid flashes on fast operations', () => {
|
||||||
|
service.start();
|
||||||
|
|
||||||
|
expect(service.isLoading()).toBe(false);
|
||||||
|
|
||||||
|
service.stop();
|
||||||
|
vi.advanceTimersByTime(150);
|
||||||
|
|
||||||
|
expect(service.isLoading()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the loading state for an operation that exceeds the delay', () => {
|
||||||
|
service.start();
|
||||||
|
vi.advanceTimersByTime(150);
|
||||||
|
|
||||||
|
expect(service.isLoading()).toBe(true);
|
||||||
|
|
||||||
|
service.stop();
|
||||||
|
|
||||||
|
expect(service.isLoading()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays visible until all concurrent operations finish', () => {
|
||||||
|
service.start();
|
||||||
|
service.start();
|
||||||
|
vi.advanceTimersByTime(150);
|
||||||
|
|
||||||
|
service.stop();
|
||||||
|
expect(service.isLoading()).toBe(true);
|
||||||
|
|
||||||
|
service.stop();
|
||||||
|
expect(service.isLoading()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores extra stop calls without corrupting the counter', () => {
|
||||||
|
service.stop();
|
||||||
|
service.start();
|
||||||
|
vi.advanceTimersByTime(150);
|
||||||
|
|
||||||
|
expect(service.isLoading()).toBe(true);
|
||||||
|
|
||||||
|
service.stop();
|
||||||
|
expect(service.isLoading()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { isPlatformBrowser } from '@angular/common';
|
||||||
|
import { Injectable, PLATFORM_ID, inject, signal } from '@angular/core';
|
||||||
|
|
||||||
|
const SHOW_DELAY_MS = 150;
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class GlobalLoadingService {
|
||||||
|
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
||||||
|
private readonly loadingState = signal(false);
|
||||||
|
private pendingOperations = 0;
|
||||||
|
private showTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
readonly isLoading = this.loadingState.asReadonly();
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
if (!this.isBrowser) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pendingOperations += 1;
|
||||||
|
|
||||||
|
if (this.pendingOperations > 1 || this.loadingState()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.showTimer = setTimeout(() => {
|
||||||
|
this.showTimer = undefined;
|
||||||
|
|
||||||
|
if (this.pendingOperations > 0) {
|
||||||
|
this.loadingState.set(true);
|
||||||
|
}
|
||||||
|
}, SHOW_DELAY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
if (!this.isBrowser || this.pendingOperations === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pendingOperations -= 1;
|
||||||
|
|
||||||
|
if (this.pendingOperations > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cancelShowTimer();
|
||||||
|
this.loadingState.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private cancelShowTimer(): void {
|
||||||
|
if (this.showTimer === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTimeout(this.showTimer);
|
||||||
|
this.showTimer = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
5
src/app/core/services/global-loading/loading-mode.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { HttpContextToken } from '@angular/common/http';
|
||||||
|
|
||||||
|
export type LoadingMode = 'global' | 'custom' | 'none';
|
||||||
|
|
||||||
|
export const LOADING_MODE = new HttpContextToken<LoadingMode>(() => 'global');
|
||||||
17
src/app/core/services/menu.utils.ts
Normal 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;
|
||||||
|
}
|
||||||
269
src/app/core/services/modal.service.spec.ts
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
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 { firstValueFrom } from 'rxjs';
|
||||||
|
|
||||||
|
import { ConfirmDeleteModalComponent } from '../../shared/components/confirm-delete-modal/confirm-delete-modal.component';
|
||||||
|
import { ConfirmModalComponent } from '../../shared/components/confirm-modal/confirm-modal.component';
|
||||||
|
import { QrModalComponent } from '../../shared/components/qr-modal/qr-modal.component';
|
||||||
|
import { SimpleModalComponent } from '../../shared/components/simple-modal/simple-modal.component';
|
||||||
|
import { MODAL_DATA, ModalRef, ModalService } from './modal.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
template: '',
|
||||||
|
})
|
||||||
|
class FirstTestModalComponent {}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
template: '',
|
||||||
|
})
|
||||||
|
class SecondTestModalComponent {}
|
||||||
|
|
||||||
|
describe('ModalService', () => {
|
||||||
|
let service: ModalService;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
try {
|
||||||
|
getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
|
||||||
|
} catch {
|
||||||
|
// Test environment may already be initialized by another setup entrypoint.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [ModalService],
|
||||||
|
});
|
||||||
|
|
||||||
|
service = TestBed.inject(ModalService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens a modal and stores the normalized config', () => {
|
||||||
|
const ref = service.open(FirstTestModalComponent, {
|
||||||
|
title: 'Confirmar compra',
|
||||||
|
data: { productId: 10 },
|
||||||
|
size: 'lg',
|
||||||
|
closeOnBackdrop: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeModal = service.activeModal();
|
||||||
|
|
||||||
|
expect(activeModal).not.toBeNull();
|
||||||
|
expect(activeModal?.component).toBe(FirstTestModalComponent);
|
||||||
|
expect(activeModal?.ref).toBe(ref);
|
||||||
|
expect(activeModal?.config).toEqual({
|
||||||
|
title: 'Confirmar compra',
|
||||||
|
data: { productId: 10 },
|
||||||
|
size: 'lg',
|
||||||
|
closeOnBackdrop: false,
|
||||||
|
closeOnEscape: true,
|
||||||
|
showCloseButton: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits the close result and clears the active modal', () => {
|
||||||
|
const ref = service.open(FirstTestModalComponent);
|
||||||
|
const closedSpy = vi.fn();
|
||||||
|
|
||||||
|
ref.afterClosed$.subscribe(closedSpy);
|
||||||
|
ref.close({ confirmed: true });
|
||||||
|
|
||||||
|
expect(closedSpy).toHaveBeenCalledWith({ confirmed: true });
|
||||||
|
expect(service.activeModal()).toBeNull();
|
||||||
|
expect(ref.dismissReason()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks dismiss reasons when closing programmatically', () => {
|
||||||
|
const ref = service.open(FirstTestModalComponent);
|
||||||
|
const closedSpy = vi.fn();
|
||||||
|
|
||||||
|
ref.afterClosed$.subscribe(closedSpy);
|
||||||
|
ref.dismiss();
|
||||||
|
|
||||||
|
expect(closedSpy).toHaveBeenCalledWith(undefined);
|
||||||
|
expect(ref.dismissReason()).toBe('programmatic');
|
||||||
|
expect(service.activeModal()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dismisses the current modal as replaced when another one opens', () => {
|
||||||
|
const firstRef = service.open(FirstTestModalComponent);
|
||||||
|
const firstClosedSpy = vi.fn();
|
||||||
|
|
||||||
|
firstRef.afterClosed$.subscribe(firstClosedSpy);
|
||||||
|
|
||||||
|
const secondRef = service.open(SecondTestModalComponent, {
|
||||||
|
title: 'Segundo modal',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(firstClosedSpy).toHaveBeenCalledWith(undefined);
|
||||||
|
expect(firstRef.dismissReason()).toBe('replaced');
|
||||||
|
expect(service.activeModal()?.ref).toBe(secondRef);
|
||||||
|
expect(service.activeModal()?.component).toBe(SecondTestModalComponent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('injects MODAL_DATA and ModalRef into opened components through the host injector contract', () => {
|
||||||
|
const ref = service.open(DataTestModalComponent, {
|
||||||
|
data: { amount: 3 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeModal = service.activeModal();
|
||||||
|
|
||||||
|
expect(activeModal?.config.data).toEqual({ amount: 3 });
|
||||||
|
expect(activeModal?.ref).toBe(ref);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the standard confirm modal with default labels', () => {
|
||||||
|
const result$ = service.openConfirm({
|
||||||
|
title: 'Confirmar compra',
|
||||||
|
content: 'Esto confirmara la compra actual.',
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeModal = service.activeModal();
|
||||||
|
|
||||||
|
expect(activeModal?.component).toBe(ConfirmModalComponent);
|
||||||
|
expect(result$).toBeDefined();
|
||||||
|
expect(activeModal?.config).toEqual({
|
||||||
|
title: 'Confirmar compra',
|
||||||
|
data: {
|
||||||
|
content: 'Esto confirmara la compra actual.',
|
||||||
|
confirmLabel: 'Confirmar',
|
||||||
|
cancelLabel: 'Cancelar',
|
||||||
|
},
|
||||||
|
size: 'md',
|
||||||
|
closeOnBackdrop: true,
|
||||||
|
closeOnEscape: 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.',
|
||||||
|
});
|
||||||
|
const activeModal = service.activeModal();
|
||||||
|
const resultPromise = firstValueFrom(result$);
|
||||||
|
|
||||||
|
activeModal?.ref.close(true);
|
||||||
|
|
||||||
|
await expect(resultPromise).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps dismissing a confirm modal to false', async () => {
|
||||||
|
const result$ = service.openConfirmDelete({
|
||||||
|
title: 'Eliminar producto',
|
||||||
|
content: 'Se eliminara el producto.',
|
||||||
|
});
|
||||||
|
const activeModal = service.activeModal();
|
||||||
|
const resultPromise = firstValueFrom(result$);
|
||||||
|
|
||||||
|
expect(activeModal?.config.size).toBe('sm');
|
||||||
|
|
||||||
|
activeModal?.ref.dismiss('escape');
|
||||||
|
|
||||||
|
await expect(resultPromise).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the delete confirm modal preserving modal overrides', () => {
|
||||||
|
service.openConfirmDelete({
|
||||||
|
title: 'Eliminar producto',
|
||||||
|
content: 'Se eliminara el producto.',
|
||||||
|
confirmLabel: 'Eliminar',
|
||||||
|
cancelLabel: 'Conservar',
|
||||||
|
size: 'lg',
|
||||||
|
closeOnBackdrop: false,
|
||||||
|
closeOnEscape: false,
|
||||||
|
showCloseButton: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeModal = service.activeModal();
|
||||||
|
|
||||||
|
expect(activeModal?.component).toBe(ConfirmDeleteModalComponent);
|
||||||
|
expect(activeModal?.config).toEqual({
|
||||||
|
title: 'Eliminar producto',
|
||||||
|
data: {
|
||||||
|
content: 'Se eliminara el producto.',
|
||||||
|
confirmLabel: 'Eliminar',
|
||||||
|
cancelLabel: 'Conservar',
|
||||||
|
},
|
||||||
|
size: 'lg',
|
||||||
|
closeOnBackdrop: false,
|
||||||
|
closeOnEscape: 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: '',
|
||||||
|
})
|
||||||
|
class DataTestModalComponent {
|
||||||
|
readonly data = inject(MODAL_DATA);
|
||||||
|
readonly modalRef = inject(ModalRef);
|
||||||
|
}
|
||||||
255
src/app/core/services/modal.service.ts
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
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 = 'qr' | 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
||||||
|
export type ModalDismissReason = 'backdrop' | 'escape' | 'programmatic' | 'replaced';
|
||||||
|
|
||||||
|
export interface ModalConfig<TData = unknown> {
|
||||||
|
title?: string;
|
||||||
|
data?: TData;
|
||||||
|
size?: ModalSize;
|
||||||
|
closeOnBackdrop?: boolean;
|
||||||
|
closeOnEscape?: boolean;
|
||||||
|
showCloseButton?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NormalizedModalConfig<TData = unknown> extends Omit<
|
||||||
|
ModalConfig<TData>,
|
||||||
|
'size' | 'closeOnBackdrop' | 'closeOnEscape' | 'showCloseButton'
|
||||||
|
> {
|
||||||
|
size: ModalSize;
|
||||||
|
closeOnBackdrop: boolean;
|
||||||
|
closeOnEscape: boolean;
|
||||||
|
showCloseButton: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfirmModalData {
|
||||||
|
content: string;
|
||||||
|
confirmLabel: string;
|
||||||
|
cancelLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
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>;
|
||||||
|
ref: ModalRef<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MODAL_DATA = new InjectionToken<unknown>('MODAL_DATA');
|
||||||
|
|
||||||
|
const DEFAULT_MODAL_CONFIG: Pick<
|
||||||
|
NormalizedModalConfig,
|
||||||
|
'size' | 'closeOnBackdrop' | 'closeOnEscape' | 'showCloseButton'
|
||||||
|
> = {
|
||||||
|
size: 'md',
|
||||||
|
closeOnBackdrop: true,
|
||||||
|
closeOnEscape: true,
|
||||||
|
showCloseButton: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_CONFIRM_MODAL_LABELS = {
|
||||||
|
confirmLabel: 'Confirmar',
|
||||||
|
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 dismissReason = signal<ModalDismissReason | null>(null);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly closeHandler: (result?: TResult) => void,
|
||||||
|
private readonly dismissHandler: (reason: ModalDismissReason) => void,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
close(result?: TResult): void {
|
||||||
|
if (this.closed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.closeHandler(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
dismiss(reason: ModalDismissReason = 'programmatic'): void {
|
||||||
|
if (this.closed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dismissHandler(reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
finalize(result?: TResult, dismissReason?: ModalDismissReason): void {
|
||||||
|
if (this.closed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.closed = true;
|
||||||
|
this.dismissReason.set(dismissReason ?? null);
|
||||||
|
this.afterClosedSubject.next(result);
|
||||||
|
this.afterClosedSubject.complete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class ModalService {
|
||||||
|
private readonly activeModalState = signal<ActiveModalState | null>(null);
|
||||||
|
readonly activeModal = this.activeModalState.asReadonly();
|
||||||
|
|
||||||
|
open<TComponent, TResult = unknown, TData = unknown>(
|
||||||
|
component: Type<TComponent>,
|
||||||
|
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),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.activeModalState.set({
|
||||||
|
component,
|
||||||
|
config: this.normalizeConfig(config),
|
||||||
|
ref: ref as ModalRef<unknown>,
|
||||||
|
});
|
||||||
|
|
||||||
|
return ref;
|
||||||
|
}
|
||||||
|
|
||||||
|
openConfirm(config: ConfirmModalConfig): Observable<boolean> {
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
openConfirmRef(config: ConfirmModalConfig): ModalRef<boolean> {
|
||||||
|
return this.open(ConfirmModalComponent, this.buildConfirmModalConfig(config));
|
||||||
|
}
|
||||||
|
|
||||||
|
openConfirmDeleteRef(config: ConfirmModalConfig): ModalRef<boolean> {
|
||||||
|
return this.open(
|
||||||
|
ConfirmDeleteModalComponent,
|
||||||
|
this.buildConfirmModalConfig({ size: 'sm', ...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 {
|
||||||
|
if (this.activeModalState()?.ref !== ref) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ref.finalize(result);
|
||||||
|
this.activeModalState.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private dismiss<TResult>(
|
||||||
|
ref: ModalRef<TResult>,
|
||||||
|
reason: ModalDismissReason = 'programmatic',
|
||||||
|
): void {
|
||||||
|
if (this.activeModalState()?.ref !== ref) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ref.finalize(undefined, reason);
|
||||||
|
this.activeModalState.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeConfig<TData>(config: ModalConfig<TData>): NormalizedModalConfig<TData> {
|
||||||
|
return {
|
||||||
|
...DEFAULT_MODAL_CONFIG,
|
||||||
|
...config,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildConfirmModalConfig(config: ConfirmModalConfig): ModalConfig<ConfirmModalData> {
|
||||||
|
const {
|
||||||
|
content,
|
||||||
|
confirmLabel = DEFAULT_CONFIRM_MODAL_LABELS.confirmLabel,
|
||||||
|
cancelLabel = DEFAULT_CONFIRM_MODAL_LABELS.cancelLabel,
|
||||||
|
...modalConfig
|
||||||
|
} = config;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...modalConfig,
|
||||||
|
data: {
|
||||||
|
content,
|
||||||
|
confirmLabel,
|
||||||
|
cancelLabel,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildSimpleModalConfig(config: SimpleModalConfig): ModalConfig<SimpleModalData> {
|
||||||
|
const { content, buttonLabel = 'Entendido', ...modalConfig } = config;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...modalConfig,
|
||||||
|
data: {
|
||||||
|
content,
|
||||||
|
buttonLabel,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,18 +5,7 @@ import { TenantService } from './tenant.service';
|
|||||||
|
|
||||||
describe('tenantBootstrap', () => {
|
describe('tenantBootstrap', () => {
|
||||||
it('waits for TenantService.bootstrap to resolve', async () => {
|
it('waits for TenantService.bootstrap to resolve', async () => {
|
||||||
const bootstrapSpy = vi.fn().mockResolvedValue({
|
const bootstrapSpy = vi.fn().mockResolvedValue(undefined);
|
||||||
id: 1,
|
|
||||||
codigo: 'test',
|
|
||||||
nombre: 'Test Tennant',
|
|
||||||
dominio: 'localhost',
|
|
||||||
primary_color: '#6376F3',
|
|
||||||
secondary_color: '#A0A0A0',
|
|
||||||
danger_color: '#FF8888',
|
|
||||||
header_footer_bg_color: '#313131',
|
|
||||||
header_logo: 'https://example.com/header.png',
|
|
||||||
footer_logo: 'https://example.com/footer.png'
|
|
||||||
});
|
|
||||||
|
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -5,5 +5,5 @@ import { TenantService } from './tenant.service';
|
|||||||
export function tenantBootstrap(): Promise<void> {
|
export function tenantBootstrap(): Promise<void> {
|
||||||
const tenantService = inject(TenantService);
|
const tenantService = inject(TenantService);
|
||||||
|
|
||||||
return tenantService.bootstrap().then(() => undefined);
|
return tenantService.bootstrap();
|
||||||
}
|
}
|
||||||
|
|||||||
25
src/app/core/services/tenant-ssr-cache.store.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { makeStateKey } from '@angular/core';
|
||||||
|
|
||||||
|
import { Tenant } from './tenant.interface';
|
||||||
|
|
||||||
|
export type TenantBootstrapStatus = 'idle' | 'ready' | 'not-found';
|
||||||
|
|
||||||
|
export type TenantSsrState =
|
||||||
|
| {
|
||||||
|
status: 'ready';
|
||||||
|
tenant: Tenant;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: 'not-found';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_TENANT_BRANDING = {
|
||||||
|
primaryColor: '#102bda',
|
||||||
|
secondaryColor: '#9e9e9e',
|
||||||
|
dangerColor: '#fd4c4c',
|
||||||
|
successColor: '#198754',
|
||||||
|
headerBgColor: '#313131',
|
||||||
|
footerBgColor: '#313131'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const TENANT_SSR_STATE_KEY = makeStateKey<TenantSsrState | null>('tenant-ssr-state');
|
||||||
50
src/app/core/services/tenant-url.serializer.spec.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import '@angular/compiler';
|
||||||
|
import { DefaultUrlSerializer } from '@angular/router';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { Tenant } from './tenant.interface';
|
||||||
|
import { TenantService } from './tenant.service';
|
||||||
|
import { TenantUrlSerializer } from './tenant-url.serializer';
|
||||||
|
|
||||||
|
describe('TenantUrlSerializer', () => {
|
||||||
|
const defaultSerializer = new DefaultUrlSerializer();
|
||||||
|
|
||||||
|
function createSerializer(basePath: string): TenantUrlSerializer {
|
||||||
|
const tenantService = {
|
||||||
|
getTenant: () => ({ base_path: basePath }) as Tenant,
|
||||||
|
} as TenantService;
|
||||||
|
|
||||||
|
return new TenantUrlSerializer(tenantService);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('keeps root tenants unchanged', () => {
|
||||||
|
const serializer = createSerializer('/');
|
||||||
|
const tree = serializer.parse('/producto/123?ref=home');
|
||||||
|
|
||||||
|
expect(defaultSerializer.serialize(tree)).toBe('/producto/123?ref=home');
|
||||||
|
expect(serializer.serialize(tree)).toBe('/producto/123?ref=home');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes the tenant base path when parsing and restores it when serializing', () => {
|
||||||
|
const serializer = createSerializer('/desfile');
|
||||||
|
const tree = serializer.parse('/desfile/producto/123?ref=home#detalle');
|
||||||
|
|
||||||
|
expect(defaultSerializer.serialize(tree)).toBe('/producto/123?ref=home#detalle');
|
||||||
|
expect(serializer.serialize(tree)).toBe('/desfile/producto/123?ref=home#detalle');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps the tenant base path to the application root', () => {
|
||||||
|
const serializer = createSerializer('/desfile/');
|
||||||
|
const tree = serializer.parse('/desfile');
|
||||||
|
|
||||||
|
expect(defaultSerializer.serialize(tree)).toBe('/');
|
||||||
|
expect(serializer.serialize(tree)).toBe('/desfile');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not strip partial path segment matches', () => {
|
||||||
|
const serializer = createSerializer('/desfile');
|
||||||
|
const tree = serializer.parse('/desfile-shop/producto/123');
|
||||||
|
|
||||||
|
expect(defaultSerializer.serialize(tree)).toBe('/desfile-shop/producto/123');
|
||||||
|
});
|
||||||
|
});
|
||||||
66
src/app/core/services/tenant-url.serializer.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { DefaultUrlSerializer, UrlSerializer, UrlTree } from '@angular/router';
|
||||||
|
|
||||||
|
import { TenantService } from './tenant.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TenantUrlSerializer extends UrlSerializer {
|
||||||
|
private readonly defaultSerializer = new DefaultUrlSerializer();
|
||||||
|
|
||||||
|
constructor(private readonly tenantService: TenantService) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
override parse(url: string): UrlTree {
|
||||||
|
return this.defaultSerializer.parse(this.removeBasePath(url));
|
||||||
|
}
|
||||||
|
|
||||||
|
override serialize(tree: UrlTree): string {
|
||||||
|
const url = this.defaultSerializer.serialize(tree);
|
||||||
|
const basePath = this.basePath();
|
||||||
|
|
||||||
|
if (basePath === '/') {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
return url === '/' ? basePath : `${basePath}${url}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private removeBasePath(url: string): string {
|
||||||
|
const basePath = this.basePath();
|
||||||
|
|
||||||
|
if (basePath === '/' || !this.startsWithCompletePathSegment(url, basePath)) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainder = url.slice(basePath.length);
|
||||||
|
|
||||||
|
if (remainder === '') {
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
return remainder.startsWith('?') || remainder.startsWith('#')
|
||||||
|
? `/${remainder}`
|
||||||
|
: remainder;
|
||||||
|
}
|
||||||
|
|
||||||
|
private basePath(): string {
|
||||||
|
const configuredPath = this.tenantService.getTenant()?.base_path?.trim() ?? '/';
|
||||||
|
|
||||||
|
if (configuredPath === '' || configuredPath === '/') {
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `/${configuredPath.replace(/^\/+|\/+$/g, '')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private startsWithCompletePathSegment(url: string, basePath: string): boolean {
|
||||||
|
if (!url.startsWith(basePath)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const boundary = url.charAt(basePath.length);
|
||||||
|
|
||||||
|
return boundary === '' || boundary === '/' || boundary === '?' || boundary === '#';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,150 @@
|
|||||||
|
import { ApiResponse } from './api-response.interface';
|
||||||
|
|
||||||
|
export type CartEditingPolicyCode = 'disabled' | 'quantity_and_remove' | 'full';
|
||||||
|
|
||||||
|
export interface CartEditingPolicy {
|
||||||
|
code: CartEditingPolicyCode;
|
||||||
|
allow_modify: boolean;
|
||||||
|
allow_delete: boolean;
|
||||||
|
allow_update_quantity: boolean;
|
||||||
|
allow_update_variant: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BankAccount {
|
||||||
|
id: number;
|
||||||
|
tenant_code: string;
|
||||||
|
titular: string;
|
||||||
|
entidad: string;
|
||||||
|
alias: string;
|
||||||
|
cvu: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebsiteType {
|
||||||
|
codigo: string;
|
||||||
|
nombre: string;
|
||||||
|
primary_color: string | null;
|
||||||
|
secondary_color: string | null;
|
||||||
|
danger_color: string | null;
|
||||||
|
success_color: string | null;
|
||||||
|
site_logo: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HeroConfig {
|
||||||
|
background_image_id?: string | ResponsiveImage | null;
|
||||||
|
title_html?: string | null;
|
||||||
|
description_html?: string | null;
|
||||||
|
button_text?: string | null;
|
||||||
|
button_href?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResponsiveImage {
|
||||||
|
desktop: string;
|
||||||
|
mobile: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdditionalInfoConfig {
|
||||||
|
description?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventDate {
|
||||||
|
id?: number;
|
||||||
|
date: string;
|
||||||
|
start_time: string;
|
||||||
|
end_time: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventConfig {
|
||||||
|
id?: number;
|
||||||
|
title?: string | null;
|
||||||
|
location?: string | null;
|
||||||
|
dates_text?: string | null;
|
||||||
|
dates?: EventDate[] | null;
|
||||||
|
contact?: SocialMedia[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiveEventDate {
|
||||||
|
id: number;
|
||||||
|
date: string;
|
||||||
|
time_start: string;
|
||||||
|
time_end: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TenantEvent {
|
||||||
|
title: string;
|
||||||
|
location: string;
|
||||||
|
dates: ActiveEventDate[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebsiteExtras {
|
||||||
|
carousel?: string[];
|
||||||
|
heroConfig?: HeroConfig | null;
|
||||||
|
additionalInfoConfig?: AdditionalInfoConfig | null;
|
||||||
|
[extraName: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Menu {
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
label: string;
|
||||||
|
parent_menu_code: string | null;
|
||||||
|
content_type: 'static' | 'dynamic';
|
||||||
|
route: string;
|
||||||
|
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 {
|
export interface Tenant {
|
||||||
id: number;
|
id: number;
|
||||||
codigo: string;
|
codigo: string;
|
||||||
nombre: string;
|
nombre: string;
|
||||||
dominio: string;
|
dominio: string;
|
||||||
|
base_path?: string;
|
||||||
|
site_title?: string | null;
|
||||||
|
address?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
favicon?: string | null;
|
||||||
primary_color: string;
|
primary_color: string;
|
||||||
secondary_color: string;
|
secondary_color: string;
|
||||||
danger_color: string;
|
danger_color: string;
|
||||||
header_footer_bg_color: string;
|
success_color: string;
|
||||||
|
header_bg_color: string;
|
||||||
|
footer_bg_color: string;
|
||||||
header_logo: string;
|
header_logo: string;
|
||||||
footer_logo: string;
|
footer_logo: string;
|
||||||
|
header_bg_image?: string | null;
|
||||||
|
footer_bg_image?: string | null;
|
||||||
|
website_type_code?: string | null;
|
||||||
|
website_type?: WebsiteType | null;
|
||||||
|
event_date_text?: string | null;
|
||||||
|
extras?: WebsiteExtras;
|
||||||
|
event?: TenantEvent | null;
|
||||||
|
selected_bank_account_id?: number | null;
|
||||||
|
selected_bank_account?: BankAccount | null;
|
||||||
|
search_product_layout?: 'row' | 'column_with_image' | 'column_with_cart' | 'ticket_selector';
|
||||||
|
search_group_layout?: 'paginated' | 'simple' | 'simple_vertical' | 'carousel' | 'single';
|
||||||
|
search_items_per_page?: number;
|
||||||
|
display_categories?: boolean;
|
||||||
|
display_seach_bar?: boolean;
|
||||||
|
display_cart?: boolean;
|
||||||
|
cart_editing_policy?: CartEditingPolicy;
|
||||||
|
checkout_editing_policy?: CartEditingPolicy;
|
||||||
|
display_cart_item_images?: boolean;
|
||||||
|
social_media?: SocialMedia[];
|
||||||
|
menues?: Menu[];
|
||||||
|
categories: Category[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TenantBootstrapResponse {
|
export type TenantBootstrapResponse = ApiResponse<Tenant>;
|
||||||
data: Tenant;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,90 +1,285 @@
|
|||||||
import { PLATFORM_ID } from '@angular/core';
|
import { PLATFORM_ID, REQUEST, RESPONSE_INIT, TransferState } from '@angular/core';
|
||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { provideHttpClient } from '@angular/common/http';
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
|
import { TimeoutError } from 'rxjs';
|
||||||
|
|
||||||
import { environment } from '../../../environments/environment';
|
import { environment } from '../../../environments/environment';
|
||||||
import { TenantBootstrapResponse } from './tenant.interface';
|
import { BOOTSTRAP_REQUEST_TIMEOUT_MS } from './bootstrap-request-timeout';
|
||||||
|
import { Tenant, TenantBootstrapResponse } from './tenant.interface';
|
||||||
|
import { TENANT_SSR_STATE_KEY } from './tenant-ssr-cache.store';
|
||||||
import { TenantService } from './tenant.service';
|
import { TenantService } from './tenant.service';
|
||||||
|
|
||||||
|
const tenant: Tenant = {
|
||||||
|
id: 1,
|
||||||
|
codigo: 'test',
|
||||||
|
nombre: 'Test Tenant',
|
||||||
|
dominio: 'localhost',
|
||||||
|
primary_color: '#6376F3',
|
||||||
|
secondary_color: '#A0A0A0',
|
||||||
|
danger_color: '#FF8888',
|
||||||
|
success_color: '#198754',
|
||||||
|
header_bg_color: '#313131',
|
||||||
|
footer_bg_color: '#313131',
|
||||||
|
header_logo: 'https://example.com/header.png',
|
||||||
|
footer_logo: 'https://example.com/footer.png',
|
||||||
|
categories: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
nombre: 'Remeras',
|
||||||
|
subcategories: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
nombre: 'Manga corta',
|
||||||
|
subcategories: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const tenantResponse: TenantBootstrapResponse = {
|
||||||
|
data: tenant,
|
||||||
|
};
|
||||||
|
|
||||||
describe('TenantService', () => {
|
describe('TenantService', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
window.history.replaceState({}, '', 'http://localhost:4200/store');
|
try {
|
||||||
|
window.history.replaceState({}, '', 'http://localhost:4200/');
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore SecurityError under test environments
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('requests the tenant bootstrap endpoint using the current hostname and stores the response', async () => {
|
it('requests the tenant bootstrap endpoint using the current hostname and stores the response', async () => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
providers: [provideHttpClient(), provideHttpClientTesting(), TenantService]
|
providers: [provideHttpClient(), provideHttpClientTesting(), TenantService],
|
||||||
});
|
});
|
||||||
|
|
||||||
const service = TestBed.inject(TenantService);
|
const service = TestBed.inject(TenantService);
|
||||||
const httpController = TestBed.inject(HttpTestingController);
|
const httpController = TestBed.inject(HttpTestingController);
|
||||||
|
|
||||||
const bootstrapPromise = service.bootstrap();
|
const bootstrapPromise = service.bootstrap();
|
||||||
const request = httpController.expectOne(`${environment.url}tennatns/bootstrap/localhost`);
|
const request = httpController.expectOne(
|
||||||
|
`${environment.url}tenants/bootstrap?dominio=localhost&path=/`,
|
||||||
|
);
|
||||||
|
|
||||||
expect(request.request.method).toBe('GET');
|
expect(request.request.method).toBe('GET');
|
||||||
|
|
||||||
const response: TenantBootstrapResponse = {
|
request.flush(tenantResponse);
|
||||||
data: {
|
|
||||||
id: 1,
|
|
||||||
codigo: 'test',
|
|
||||||
nombre: 'Test Tennant',
|
|
||||||
dominio: 'localhost',
|
|
||||||
primary_color: '#6376F3',
|
|
||||||
secondary_color: '#A0A0A0',
|
|
||||||
danger_color: '#FF8888',
|
|
||||||
header_footer_bg_color: '#313131',
|
|
||||||
header_logo: 'https://example.com/header.png',
|
|
||||||
footer_logo: 'https://example.com/footer.png'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
request.flush(response);
|
await expect(bootstrapPromise).resolves.toBeUndefined();
|
||||||
|
expect(service.status()).toBe('ready');
|
||||||
|
expect(service.tenant()).toEqual(tenant);
|
||||||
|
expect(service.getTenant()).toEqual(tenant);
|
||||||
|
|
||||||
await expect(bootstrapPromise).resolves.toEqual(response.data);
|
httpController.verify();
|
||||||
expect(service.tenant()).toEqual(response.data);
|
});
|
||||||
expect(service.getTenant()).toEqual(response.data);
|
|
||||||
|
it('hydrates the tenant from TransferState without performing HTTP on the browser', async () => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [provideHttpClient(), provideHttpClientTesting(), TenantService],
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = TestBed.inject(TenantService);
|
||||||
|
const transferState = TestBed.inject(TransferState);
|
||||||
|
const httpController = TestBed.inject(HttpTestingController);
|
||||||
|
|
||||||
|
transferState.set(TENANT_SSR_STATE_KEY, {
|
||||||
|
status: 'ready',
|
||||||
|
tenant,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.bootstrap()).resolves.toBeUndefined();
|
||||||
|
expect(service.status()).toBe('ready');
|
||||||
|
expect(service.tenant()).toEqual(tenant);
|
||||||
|
expect(transferState.get(TENANT_SSR_STATE_KEY, null)).toBeNull();
|
||||||
|
|
||||||
|
httpController.expectNone(() => true);
|
||||||
|
httpController.verify();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the forwarded host during SSR and stores the resolved tenant in TransferState', async () => {
|
||||||
|
const responseInit: ResponseInit = {};
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
provideHttpClient(),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
TenantService,
|
||||||
|
{ provide: PLATFORM_ID, useValue: 'server' },
|
||||||
|
{
|
||||||
|
provide: REQUEST,
|
||||||
|
useValue: new Request('https://internal.example/render', {
|
||||||
|
headers: {
|
||||||
|
'x-forwarded-host': 'STORE.EXAMPLE.COM:443, proxy.internal',
|
||||||
|
host: 'ignored.example.com',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{ provide: RESPONSE_INIT, useValue: responseInit },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = TestBed.inject(TenantService);
|
||||||
|
const transferState = TestBed.inject(TransferState);
|
||||||
|
const httpController = TestBed.inject(HttpTestingController);
|
||||||
|
|
||||||
|
const bootstrapPromise = service.bootstrap();
|
||||||
|
const request = httpController.expectOne(
|
||||||
|
`${environment.url}tenants/bootstrap?dominio=store.example.com&path=/render`,
|
||||||
|
);
|
||||||
|
|
||||||
|
request.flush(tenantResponse);
|
||||||
|
|
||||||
|
await expect(bootstrapPromise).resolves.toBeUndefined();
|
||||||
|
expect(service.status()).toBe('ready');
|
||||||
|
expect(service.tenant()).toEqual(tenant);
|
||||||
|
expect(transferState.get(TENANT_SSR_STATE_KEY, null)).toEqual({
|
||||||
|
status: 'ready',
|
||||||
|
tenant,
|
||||||
|
});
|
||||||
|
expect(responseInit.status).toBeUndefined();
|
||||||
|
|
||||||
|
httpController.verify();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores a not-found state and sets the SSR response status to 404 when the tenant does not exist', async () => {
|
||||||
|
const responseInit: ResponseInit = {};
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
provideHttpClient(),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
TenantService,
|
||||||
|
{ provide: PLATFORM_ID, useValue: 'server' },
|
||||||
|
{
|
||||||
|
provide: REQUEST,
|
||||||
|
useValue: new Request('https://internal.example/render', {
|
||||||
|
headers: {
|
||||||
|
host: 'missing.example.com:8443',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{ provide: RESPONSE_INIT, useValue: responseInit },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = TestBed.inject(TenantService);
|
||||||
|
const transferState = TestBed.inject(TransferState);
|
||||||
|
const httpController = TestBed.inject(HttpTestingController);
|
||||||
|
|
||||||
|
const bootstrapPromise = service.bootstrap();
|
||||||
|
const request = httpController.expectOne(
|
||||||
|
`${environment.url}tenants/bootstrap?dominio=missing.example.com&path=/render`,
|
||||||
|
);
|
||||||
|
|
||||||
|
request.flush({ message: 'Not Found' }, { status: 404, statusText: 'Not Found' });
|
||||||
|
|
||||||
|
await expect(bootstrapPromise).resolves.toBeUndefined();
|
||||||
|
expect(service.status()).toBe('not-found');
|
||||||
|
expect(service.tenant()).toBeNull();
|
||||||
|
expect(service.getTenant()).toBeNull();
|
||||||
|
expect(transferState.get(TENANT_SSR_STATE_KEY, null)).toEqual({
|
||||||
|
status: 'not-found',
|
||||||
|
});
|
||||||
|
expect(responseInit.status).toBe(404);
|
||||||
|
|
||||||
httpController.verify();
|
httpController.verify();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects when the bootstrap response does not contain tenant data', async () => {
|
it('rejects when the bootstrap response does not contain tenant data', async () => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
providers: [provideHttpClient(), provideHttpClientTesting(), TenantService]
|
providers: [provideHttpClient(), provideHttpClientTesting(), TenantService],
|
||||||
});
|
});
|
||||||
|
|
||||||
const service = TestBed.inject(TenantService);
|
const service = TestBed.inject(TenantService);
|
||||||
const httpController = TestBed.inject(HttpTestingController);
|
const httpController = TestBed.inject(HttpTestingController);
|
||||||
|
|
||||||
const bootstrapPromise = service.bootstrap();
|
const bootstrapPromise = service.bootstrap();
|
||||||
const request = httpController.expectOne(`${environment.url}tennatns/bootstrap/localhost`);
|
const request = httpController.expectOne(
|
||||||
|
`${environment.url}tenants/bootstrap?dominio=localhost&path=/`,
|
||||||
|
);
|
||||||
|
|
||||||
request.flush({});
|
request.flush({});
|
||||||
|
|
||||||
await expect(bootstrapPromise).rejects.toThrow('Tenant bootstrap returned no data');
|
await expect(bootstrapPromise).rejects.toThrow('Tenant bootstrap returned no data');
|
||||||
|
expect(service.status()).toBe('idle');
|
||||||
|
expect(service.tenant()).toBeNull();
|
||||||
|
|
||||||
httpController.verify();
|
httpController.verify();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('skips the HTTP bootstrap on the server platform', async () => {
|
it('propagates non-404 backend failures', async () => {
|
||||||
|
const responseInit: ResponseInit = {};
|
||||||
|
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
provideHttpClient(),
|
provideHttpClient(),
|
||||||
provideHttpClientTesting(),
|
provideHttpClientTesting(),
|
||||||
TenantService,
|
TenantService,
|
||||||
{ provide: PLATFORM_ID, useValue: 'server' }
|
{ provide: PLATFORM_ID, useValue: 'server' },
|
||||||
]
|
{
|
||||||
|
provide: REQUEST,
|
||||||
|
useValue: new Request('https://internal.example/render', {
|
||||||
|
headers: {
|
||||||
|
host: 'broken.example.com',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{ provide: RESPONSE_INIT, useValue: responseInit },
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const service = TestBed.inject(TenantService);
|
const service = TestBed.inject(TenantService);
|
||||||
const httpController = TestBed.inject(HttpTestingController);
|
const httpController = TestBed.inject(HttpTestingController);
|
||||||
|
|
||||||
await expect(service.bootstrap()).resolves.toBeNull();
|
const bootstrapPromise = service.bootstrap();
|
||||||
expect(service.tenant()).toBeNull();
|
const request = httpController.expectOne(
|
||||||
expect(service.getTenant()).toBeNull();
|
`${environment.url}tenants/bootstrap?dominio=broken.example.com&path=/render`,
|
||||||
|
);
|
||||||
|
|
||||||
|
request.flush({ message: 'Boom' }, { status: 500, statusText: 'Server Error' });
|
||||||
|
|
||||||
|
await expect(bootstrapPromise).rejects.toThrow('Http failure response');
|
||||||
|
expect(service.status()).toBe('idle');
|
||||||
|
expect(responseInit.status).toBeUndefined();
|
||||||
|
|
||||||
httpController.expectNone(() => true);
|
|
||||||
httpController.verify();
|
httpController.verify();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects and cancels a tenant bootstrap request that exceeds the configured timeout', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
try {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
provideHttpClient(),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
TenantService,
|
||||||
|
{ provide: BOOTSTRAP_REQUEST_TIMEOUT_MS, useValue: 25 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = TestBed.inject(TenantService);
|
||||||
|
const httpController = TestBed.inject(HttpTestingController);
|
||||||
|
|
||||||
|
const bootstrapPromise = service.bootstrap();
|
||||||
|
const rejection = expect(bootstrapPromise).rejects.toBeInstanceOf(TimeoutError);
|
||||||
|
const request = httpController.expectOne(
|
||||||
|
`${environment.url}tenants/bootstrap?dominio=localhost&path=/`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(25);
|
||||||
|
await rejection;
|
||||||
|
|
||||||
|
expect(request.cancelled).toBe(true);
|
||||||
|
expect(service.status()).toBe('idle');
|
||||||
|
expect(service.tenant()).toBeNull();
|
||||||
|
|
||||||
|
httpController.verify();
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,51 +1,203 @@
|
|||||||
import { HttpClient } from '@angular/common/http';
|
import { isPlatformBrowser, isPlatformServer } from '@angular/common';
|
||||||
import { inject, Injectable, PLATFORM_ID, signal } from '@angular/core';
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
import { isPlatformBrowser } from '@angular/common';
|
import {
|
||||||
import { firstValueFrom } from 'rxjs';
|
inject,
|
||||||
|
Injectable,
|
||||||
|
PLATFORM_ID,
|
||||||
|
REQUEST,
|
||||||
|
RESPONSE_INIT,
|
||||||
|
signal,
|
||||||
|
TransferState,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { IS_DISCOVERING_ROUTES } from '@angular/ssr';
|
||||||
|
import { firstValueFrom, timeout } from 'rxjs';
|
||||||
|
|
||||||
import { environment } from '../../../environments/environment';
|
import { environment } from '../../../environments/environment';
|
||||||
|
import { BaseApiService } from './base-api.service';
|
||||||
|
import { BOOTSTRAP_REQUEST_TIMEOUT_MS } from './bootstrap-request-timeout';
|
||||||
import { Tenant, TenantBootstrapResponse } from './tenant.interface';
|
import { Tenant, TenantBootstrapResponse } from './tenant.interface';
|
||||||
|
import {
|
||||||
|
TENANT_SSR_STATE_KEY,
|
||||||
|
TenantBootstrapStatus,
|
||||||
|
TenantSsrState,
|
||||||
|
} from './tenant-ssr-cache.store';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root',
|
||||||
})
|
})
|
||||||
export class TenantService {
|
export class TenantService extends BaseApiService {
|
||||||
private readonly http = inject(HttpClient);
|
|
||||||
private readonly platformId = inject(PLATFORM_ID);
|
private readonly platformId = inject(PLATFORM_ID);
|
||||||
|
private readonly request = inject(REQUEST, { optional: true });
|
||||||
|
private readonly responseInit = inject(RESPONSE_INIT, { optional: true });
|
||||||
|
private readonly transferState = inject(TransferState);
|
||||||
|
private readonly isDiscoveringRoutes = inject(IS_DISCOVERING_ROUTES, { optional: true }) ?? false;
|
||||||
|
private readonly bootstrapRequestTimeoutMs = inject(BOOTSTRAP_REQUEST_TIMEOUT_MS);
|
||||||
private readonly tenantState = signal<Tenant | null>(null);
|
private readonly tenantState = signal<Tenant | null>(null);
|
||||||
|
private readonly statusState = signal<TenantBootstrapStatus>('idle');
|
||||||
|
|
||||||
readonly tenant = this.tenantState.asReadonly();
|
readonly tenant = this.tenantState.asReadonly();
|
||||||
|
readonly status = this.statusState.asReadonly();
|
||||||
|
|
||||||
getTenant(): Tenant | null {
|
getTenant(): Tenant | null {
|
||||||
return this.tenantState();
|
return this.tenantState();
|
||||||
}
|
}
|
||||||
|
|
||||||
async bootstrap(): Promise<Tenant | null> {
|
getTenantApiUrl(): string {
|
||||||
if (!isPlatformBrowser(this.platformId)) {
|
const tenant = this.tenantState();
|
||||||
return null;
|
if (!tenant) {
|
||||||
|
throw new Error('No se pudo resolver el tenant activo.');
|
||||||
}
|
}
|
||||||
|
return `${environment.url}tenants/${tenant.codigo}`;
|
||||||
const domain = this.resolveDomain();
|
|
||||||
const response = await firstValueFrom(
|
|
||||||
this.http.get<TenantBootstrapResponse>(`${environment.url}tenants/bootstrap/${domain}`)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response?.data) {
|
|
||||||
throw new Error(`Tenant bootstrap returned no data for domain "${domain}".`);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.tenantState.set(response.data);
|
|
||||||
|
|
||||||
return response.data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveDomain(): string {
|
async bootstrap(): Promise<void> {
|
||||||
const domain = window.location.hostname;
|
if (this.statusState() !== 'idle') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!domain) {
|
if (this.isDiscoveringRoutes) {
|
||||||
|
this.statusState.set('ready');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const transferredState = this.transferState.get(TENANT_SSR_STATE_KEY, null);
|
||||||
|
|
||||||
|
if (transferredState) {
|
||||||
|
this.transferState.remove(TENANT_SSR_STATE_KEY);
|
||||||
|
this.applyState(transferredState);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { domain, path } = this.resolveRequestContext();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await firstValueFrom(
|
||||||
|
this.http
|
||||||
|
.get<TenantBootstrapResponse>(`${environment.url}tenants/bootstrap`, {
|
||||||
|
params: {
|
||||||
|
dominio: domain,
|
||||||
|
path,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.pipe(timeout(this.bootstrapRequestTimeoutMs)),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response?.data) {
|
||||||
|
throw new Error(`Tenant bootstrap returned no data for domain "${domain}".`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setReady(response.data);
|
||||||
|
this.persistSsrState({
|
||||||
|
status: 'ready',
|
||||||
|
tenant: response.data,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (this.isNotFoundError(error)) {
|
||||||
|
this.setNotFound();
|
||||||
|
this.persistSsrState({ status: 'not-found' });
|
||||||
|
|
||||||
|
if (this.responseInit) {
|
||||||
|
this.responseInit.status = 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveRequestContext(): { domain: string; path: string } {
|
||||||
|
if (isPlatformBrowser(this.platformId)) {
|
||||||
|
const domain = window.location.hostname;
|
||||||
|
|
||||||
|
if (!domain) {
|
||||||
|
throw new Error('Tenant bootstrap could not resolve the current domain.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
domain: this.normalizeHost(domain),
|
||||||
|
path: this.normalizePath(window.location.pathname),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestHost =
|
||||||
|
this.request?.headers.get('x-forwarded-host') ??
|
||||||
|
this.request?.headers.get('host') ??
|
||||||
|
(this.request ? new URL(this.request.url).host : null);
|
||||||
|
|
||||||
|
if (!requestHost) {
|
||||||
throw new Error('Tenant bootstrap could not resolve the current domain.');
|
throw new Error('Tenant bootstrap could not resolve the current domain.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return domain;
|
const forwardedPath =
|
||||||
|
this.request?.headers.get('x-forwarded-uri') ?? this.request?.headers.get('x-original-uri');
|
||||||
|
const requestPath = forwardedPath ?? (this.request ? new URL(this.request.url).pathname : '/');
|
||||||
|
|
||||||
|
return {
|
||||||
|
domain: this.normalizeHost(requestHost),
|
||||||
|
path: this.normalizePath(requestPath),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeHost(host: string): string {
|
||||||
|
const [firstHost = ''] = host.split(',');
|
||||||
|
const normalizedHost = firstHost.trim().toLowerCase();
|
||||||
|
|
||||||
|
if (!normalizedHost) {
|
||||||
|
throw new Error('Tenant bootstrap could not resolve the current domain.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedHost.startsWith('[')) {
|
||||||
|
const closingBracketIndex = normalizedHost.indexOf(']');
|
||||||
|
|
||||||
|
if (closingBracketIndex >= 0) {
|
||||||
|
return normalizedHost.slice(1, closingBracketIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedHost.replace(/:\d+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizePath(path: string): string {
|
||||||
|
const pathname = path.split(/[?#]/, 1)[0]?.trim() ?? '';
|
||||||
|
|
||||||
|
if (!pathname || pathname === '/') {
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `/${pathname.replace(/^\/+|\/+$/g, '')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyState(state: TenantSsrState): void {
|
||||||
|
if (state.status === 'ready') {
|
||||||
|
this.setReady(state.tenant);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
private setReady(tenant: Tenant): void {
|
||||||
|
this.tenantState.set(tenant);
|
||||||
|
this.statusState.set('ready');
|
||||||
|
}
|
||||||
|
|
||||||
|
private setNotFound(): void {
|
||||||
|
this.tenantState.set(null);
|
||||||
|
this.statusState.set('not-found');
|
||||||
|
}
|
||||||
|
|
||||||
|
private persistSsrState(state: TenantSsrState): void {
|
||||||
|
if (isPlatformServer(this.platformId)) {
|
||||||
|
this.transferState.set(TENANT_SSR_STATE_KEY, state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private isNotFoundError(error: unknown): error is HttpErrorResponse {
|
||||||
|
return error instanceof HttpErrorResponse && error.status === 404;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
101
src/app/core/services/toast.service.spec.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
import { ToastService } from './toast.service';
|
||||||
|
|
||||||
|
describe('ToastService', () => {
|
||||||
|
let service: ToastService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [ToastService]
|
||||||
|
});
|
||||||
|
service = TestBed.inject(ToastService);
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be created', () => {
|
||||||
|
expect(service).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should initialize with an empty list of toasts', () => {
|
||||||
|
expect(service.toasts()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show a toast and assign an id', () => {
|
||||||
|
const id = service.show('Hello world', 'info');
|
||||||
|
expect(id).toBeTruthy();
|
||||||
|
expect(service.toasts().length).toBe(1);
|
||||||
|
expect(service.toasts()[0]).toEqual({
|
||||||
|
id,
|
||||||
|
message: 'Hello world',
|
||||||
|
type: 'info',
|
||||||
|
duration: 3000
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add specific variant toasts via helper methods', () => {
|
||||||
|
const infoId = service.info('Info message');
|
||||||
|
const dangerId = service.danger('Danger message', 5000);
|
||||||
|
const successId = service.success('Success message', 0);
|
||||||
|
|
||||||
|
const toasts = service.toasts();
|
||||||
|
expect(toasts.length).toBe(3);
|
||||||
|
|
||||||
|
expect(toasts.find((t) => t.id === infoId)).toEqual({
|
||||||
|
id: infoId,
|
||||||
|
message: 'Info message',
|
||||||
|
type: 'info',
|
||||||
|
duration: 3000
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(toasts.find((t) => t.id === dangerId)).toEqual({
|
||||||
|
id: dangerId,
|
||||||
|
message: 'Danger message',
|
||||||
|
type: 'danger',
|
||||||
|
duration: 5000
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(toasts.find((t) => t.id === successId)).toEqual({
|
||||||
|
id: successId,
|
||||||
|
message: 'Success message',
|
||||||
|
type: 'success',
|
||||||
|
duration: 0
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should dismiss a toast manually by id', () => {
|
||||||
|
const id1 = service.show('Toast 1');
|
||||||
|
const id2 = service.show('Toast 2');
|
||||||
|
|
||||||
|
expect(service.toasts().length).toBe(2);
|
||||||
|
|
||||||
|
service.dismiss(id1);
|
||||||
|
|
||||||
|
expect(service.toasts().length).toBe(1);
|
||||||
|
expect(service.toasts()[0].id).toBe(id2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should automatically dismiss a toast after the duration', () => {
|
||||||
|
service.show('Auto dismiss toast', 'info', 2000);
|
||||||
|
|
||||||
|
expect(service.toasts().length).toBe(1);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(2000);
|
||||||
|
|
||||||
|
expect(service.toasts().length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not dismiss a toast automatically if duration is 0', () => {
|
||||||
|
service.show('Persistent toast', 'info', 0);
|
||||||
|
|
||||||
|
expect(service.toasts().length).toBe(1);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(10000);
|
||||||
|
|
||||||
|
expect(service.toasts().length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
47
src/app/core/services/toast.service.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { Injectable, signal } from '@angular/core';
|
||||||
|
|
||||||
|
export interface Toast {
|
||||||
|
id: string;
|
||||||
|
message: string;
|
||||||
|
type: 'success' | 'danger' | 'info';
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class ToastService {
|
||||||
|
private readonly toastsSignal = signal<Toast[]>([]);
|
||||||
|
readonly toasts = this.toastsSignal.asReadonly();
|
||||||
|
|
||||||
|
show(message: string, type: 'success' | 'danger' | 'info' = 'info', duration = 3000): string {
|
||||||
|
const id = Math.random().toString(36).substring(2, 9);
|
||||||
|
const newToast: Toast = { id, message, type, duration };
|
||||||
|
|
||||||
|
this.toastsSignal.update((toasts) => [...toasts, newToast]);
|
||||||
|
|
||||||
|
if (duration > 0) {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.dismiss(id);
|
||||||
|
}, duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
info(message: string, duration = 3000): string {
|
||||||
|
return this.show(message, 'info', duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
danger(message: string, duration = 3000): string {
|
||||||
|
return this.show(message, 'danger', duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
success(message: string, duration = 3000): string {
|
||||||
|
return this.show(message, 'success', duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
dismiss(id: string): void {
|
||||||
|
this.toastsSignal.update((toasts) => toasts.filter((t) => t.id !== id));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { of, throwError } from 'rxjs';
|
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
import { useMutation } from './use-mutation';
|
|
||||||
|
|
||||||
describe('useMutation', () => {
|
|
||||||
it('stores the resolved value and clears loading after a successful execution', async () => {
|
|
||||||
const mutation = useMutation<{ id: number }, { ok: boolean; id: number }>();
|
|
||||||
const factory = vi.fn((payload: { id: number }) => of({ ok: true, id: payload.id }));
|
|
||||||
|
|
||||||
await expect(mutation.execute(factory, { id: 7 })).resolves.toEqual({ ok: true, id: 7 });
|
|
||||||
|
|
||||||
expect(factory).toHaveBeenCalledWith({ id: 7 });
|
|
||||||
expect(mutation.loading()).toBe(false);
|
|
||||||
expect(mutation.error()).toBeNull();
|
|
||||||
expect(mutation.data()).toEqual({ ok: true, id: 7 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stores the thrown error and clears loading after a failed execution', async () => {
|
|
||||||
const mutation = useMutation<{ id: number }, { ok: boolean }>();
|
|
||||||
const failure = new Error('request failed');
|
|
||||||
const factory = vi.fn(() => throwError(() => failure));
|
|
||||||
|
|
||||||
await expect(mutation.execute(factory, { id: 7 })).rejects.toThrow('request failed');
|
|
||||||
|
|
||||||
expect(factory).toHaveBeenCalledWith({ id: 7 });
|
|
||||||
expect(mutation.loading()).toBe(false);
|
|
||||||
expect(mutation.error()).toBe(failure);
|
|
||||||
expect(mutation.data()).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { signal, WritableSignal } from '@angular/core';
|
|
||||||
import { Observable, firstValueFrom } from 'rxjs';
|
|
||||||
|
|
||||||
export interface UseMutationResult<TData, TResult> {
|
|
||||||
loading: WritableSignal<boolean>;
|
|
||||||
error: WritableSignal<unknown | null>;
|
|
||||||
data: WritableSignal<TResult | null>;
|
|
||||||
execute: (factory: (payload: TData) => Observable<TResult>, payload: TData) => Promise<TResult>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useMutation<TData, TResult>(): UseMutationResult<TData, TResult> {
|
|
||||||
const loading = signal(false);
|
|
||||||
const error = signal<unknown | null>(null);
|
|
||||||
const data = signal<TResult | null>(null);
|
|
||||||
|
|
||||||
async function execute(
|
|
||||||
factory: (payload: TData) => Observable<TResult>,
|
|
||||||
payload: TData
|
|
||||||
): Promise<TResult> {
|
|
||||||
loading.set(true);
|
|
||||||
error.set(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await firstValueFrom(factory(payload));
|
|
||||||
data.set(result);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
} catch (err) {
|
|
||||||
error.set(err);
|
|
||||||
throw err;
|
|
||||||
} finally {
|
|
||||||
loading.set(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
data,
|
|
||||||
execute
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -3,9 +3,7 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<span class="eyebrow">Componentes reutilizables</span>
|
<span class="eyebrow">Componentes reutilizables</span>
|
||||||
<h1>Button + Input</h1>
|
<h1>Button + Input</h1>
|
||||||
<p class="lead mb-4">
|
<p class="lead mb-4">Pagina dedicada a probar variantes de los componentes compartidos.</p>
|
||||||
Pagina dedicada a probar variantes de los componentes compartidos.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="button-showcase">
|
<div class="button-showcase">
|
||||||
<div class="button-group">
|
<div class="button-group">
|
||||||
@@ -24,8 +22,9 @@
|
|||||||
<div class="button-grid">
|
<div class="button-grid">
|
||||||
<app-button [disabled]="true">Primary</app-button>
|
<app-button [disabled]="true">Primary</app-button>
|
||||||
<app-button variant="secondary" [disabled]="true">Secondary</app-button>
|
<app-button variant="secondary" [disabled]="true">Secondary</app-button>
|
||||||
|
|
||||||
<app-button variant="danger" [disabled]="true">Danger</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>
|
<app-button variant="cancel" [disabled]="true">Cancel</app-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -40,10 +39,140 @@
|
|||||||
<app-button variant="cancel">E</app-button>
|
<app-button variant="cancel">E</app-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="button-group">
|
||||||
|
<h2>Toasts</h2>
|
||||||
|
<div class="button-grid">
|
||||||
|
<app-button (click)="triggerToast('info')">Trigger Info</app-button>
|
||||||
|
<app-button variant="secondary" (click)="triggerToast('success')">
|
||||||
|
Trigger Success
|
||||||
|
</app-button>
|
||||||
|
<app-button variant="danger" (click)="triggerToast('danger')">
|
||||||
|
Trigger Danger
|
||||||
|
</app-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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')">
|
||||||
|
Success Persistente
|
||||||
|
</app-button>
|
||||||
|
<app-button variant="danger" (click)="triggerPersistentToast('danger')">
|
||||||
|
Danger Persistente
|
||||||
|
</app-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="button-group">
|
||||||
|
<h2>Modal</h2>
|
||||||
|
<p class="text-muted mb-0">
|
||||||
|
Demo del modal global con servicio, host y devolucion de resultado.
|
||||||
|
</p>
|
||||||
|
<div class="button-grid">
|
||||||
|
<app-button (click)="openBasicModal()">Abrir confirm modal</app-button>
|
||||||
|
<app-button variant="danger" (click)="openConfirmDeleteModal()">
|
||||||
|
Abrir confirm delete
|
||||||
|
</app-button>
|
||||||
|
<app-button variant="secondary" (click)="openLockedModal()">
|
||||||
|
Abrir modal bloqueado
|
||||||
|
</app-button>
|
||||||
|
<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 }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr class="section-divider" />
|
<hr class="section-divider" />
|
||||||
|
|
||||||
|
<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).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="icon-button-showcase__grid">
|
||||||
|
<div class="icon-button-showcase__row">
|
||||||
|
<span class="icon-button-showcase__label">Disabled</span>
|
||||||
|
<div class="icon-button-showcase__items">
|
||||||
|
<app-icon-button variant="user" [disabled]="true" />
|
||||||
|
<app-icon-button variant="trash" [disabled]="true" />
|
||||||
|
<app-icon-button variant="angle-left" [disabled]="true" />
|
||||||
|
<app-icon-button variant="angle-right" [disabled]="true" />
|
||||||
|
<app-icon-button variant="angle-up" [disabled]="true" />
|
||||||
|
<app-icon-button variant="angle-down" [disabled]="true" />
|
||||||
|
<app-icon-button variant="copy" [disabled]="true" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="icon-button-showcase__row">
|
||||||
|
<span class="icon-button-showcase__label">Normal</span>
|
||||||
|
<div class="icon-button-showcase__items">
|
||||||
|
<app-icon-button variant="user" />
|
||||||
|
<app-icon-button variant="trash" />
|
||||||
|
<app-icon-button variant="angle-left" />
|
||||||
|
<app-icon-button variant="angle-right" />
|
||||||
|
<app-icon-button variant="angle-up" />
|
||||||
|
<app-icon-button variant="angle-down" />
|
||||||
|
<app-icon-button variant="copy" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="icon-button-showcase__row">
|
||||||
|
<span class="icon-button-showcase__label">Active / Hover</span>
|
||||||
|
<div class="icon-button-showcase__items">
|
||||||
|
<app-icon-button variant="user" class="hover-preview" />
|
||||||
|
<app-icon-button variant="trash" class="hover-preview" />
|
||||||
|
<app-icon-button variant="angle-left" class="hover-preview" />
|
||||||
|
<app-icon-button variant="angle-right" class="hover-preview" />
|
||||||
|
<app-icon-button variant="angle-up" class="hover-preview" />
|
||||||
|
<app-icon-button variant="angle-down" class="hover-preview" />
|
||||||
|
<app-icon-button variant="copy" class="hover-preview" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div class="icon-button-showcase__grid">
|
||||||
|
<div class="icon-button-showcase__row">
|
||||||
|
<span class="icon-button-showcase__label">Disabled</span>
|
||||||
|
<div class="icon-button-showcase__items">
|
||||||
|
<app-cart-icon [disabled]="true" [quantity]="3" />
|
||||||
|
<app-cart-icon [disabled]="true" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="icon-button-showcase__row">
|
||||||
|
<span class="icon-button-showcase__label">Normal</span>
|
||||||
|
<div class="icon-button-showcase__items">
|
||||||
|
<app-cart-icon [quantity]="3" />
|
||||||
|
<app-cart-icon />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="icon-button-showcase__row">
|
||||||
|
<span class="icon-button-showcase__label">Active / Hover</span>
|
||||||
|
<div class="icon-button-showcase__items">
|
||||||
|
<app-cart-icon [quantity]="3" class="hover-preview" />
|
||||||
|
<app-cart-icon class="hover-preview" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="input-grid">
|
<div class="input-grid">
|
||||||
<div class="input-field">
|
<div class="input-field">
|
||||||
<label for="search-input">Texto</label>
|
<label for="search-input">Texto</label>
|
||||||
@@ -58,12 +187,7 @@
|
|||||||
|
|
||||||
<div class="input-field">
|
<div class="input-field">
|
||||||
<label for="number-input">Numero</label>
|
<label for="number-input">Numero</label>
|
||||||
<app-input
|
<app-input id="number-input" type="number" placeholder="0" [(value)]="numberValue" />
|
||||||
id="number-input"
|
|
||||||
type="number"
|
|
||||||
placeholder="0"
|
|
||||||
[(value)]="numberValue"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="input-field">
|
<div class="input-field">
|
||||||
@@ -78,12 +202,7 @@
|
|||||||
|
|
||||||
<div class="input-field">
|
<div class="input-field">
|
||||||
<label for="user-input">Usuario</label>
|
<label for="user-input">Usuario</label>
|
||||||
<app-input
|
<app-input id="user-input" type="user" placeholder="Nombre de usuario" [(value)]="user" />
|
||||||
id="user-input"
|
|
||||||
type="user"
|
|
||||||
placeholder="Nombre de usuario"
|
|
||||||
[(value)]="user"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="input-field">
|
<div class="input-field">
|
||||||
@@ -108,22 +227,12 @@
|
|||||||
|
|
||||||
<div class="input-field">
|
<div class="input-field">
|
||||||
<label for="amount-input">Moneda</label>
|
<label for="amount-input">Moneda</label>
|
||||||
<app-input
|
<app-input id="amount-input" type="currency" placeholder="0" [(value)]="amount" />
|
||||||
id="amount-input"
|
|
||||||
type="currency"
|
|
||||||
placeholder="0"
|
|
||||||
[(value)]="amount"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="input-field">
|
<div class="input-field">
|
||||||
<label for="percent-input">Porcentaje</label>
|
<label for="percent-input">Porcentaje</label>
|
||||||
<app-input
|
<app-input id="percent-input" type="percent" placeholder="0" [(value)]="percent" />
|
||||||
id="percent-input"
|
|
||||||
type="percent"
|
|
||||||
placeholder="0"
|
|
||||||
[(value)]="percent"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="input-field">
|
<div class="input-field">
|
||||||
@@ -171,9 +280,519 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<app-button variant="secondary" (click)="clearFile()">Limpiar archivo</app-button>
|
<app-button variant="secondary" (click)="clearFile()"> Limpiar archivo </app-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="section-divider" />
|
||||||
|
|
||||||
|
<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.
|
||||||
|
</p>
|
||||||
|
<app-paginator
|
||||||
|
[page]="currentPage"
|
||||||
|
[totalPages]="paginatorTotalPages"
|
||||||
|
(pageChange)="currentPage = $event"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<h2>Carrito mock</h2>
|
||||||
|
<p class="text-muted mb-0">
|
||||||
|
Demo visual aislada del carrito con scroll a partir del cuarto item.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!cartVisible) {
|
||||||
|
<app-button variant="secondary" (click)="showCart()">
|
||||||
|
Volver a mostrar carrito
|
||||||
|
</app-button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (cartVisible) {
|
||||||
|
<div class="cart-showcase__viewport">
|
||||||
|
<app-cart
|
||||||
|
[showClose]="true"
|
||||||
|
[items]="cartMockItems"
|
||||||
|
[subtotal]="366000"
|
||||||
|
[discount]="66000"
|
||||||
|
[total]="300000"
|
||||||
|
[backgroundColor]="cartBackgroundColor"
|
||||||
|
(closed)="hideCart()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="component-demo card shadow-sm mt-4 configuracion-tennant">
|
||||||
|
<div class="card-body">
|
||||||
|
<span class="eyebrow">Tenant Configuration</span>
|
||||||
|
<h2 class="mb-4">Configuracion Tenant</h2>
|
||||||
|
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-6 mb-3 mb-md-0">
|
||||||
|
<div class="tenant-logo-card">
|
||||||
|
<div class="tenant-logo-card__label">Logo del Header</div>
|
||||||
|
<div class="tenant-logo-card__container header-bg">
|
||||||
|
@if (tenant()?.header_logo) {
|
||||||
|
<img
|
||||||
|
[src]="tenant()?.header_logo"
|
||||||
|
alt="Header Logo"
|
||||||
|
class="tenant-logo-card__img"
|
||||||
|
/>
|
||||||
|
} @else {
|
||||||
|
<div class="tenant-logo-card__placeholder">
|
||||||
|
<i class="fa-solid fa-image mb-2"></i>
|
||||||
|
<span>Sin Logo de Header</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="tenant-logo-card">
|
||||||
|
<div class="tenant-logo-card__label">Logo del Footer</div>
|
||||||
|
<div class="tenant-logo-card__container footer-bg">
|
||||||
|
@if (tenant()?.footer_logo) {
|
||||||
|
<img
|
||||||
|
[src]="tenant()?.footer_logo"
|
||||||
|
alt="Footer Logo"
|
||||||
|
class="tenant-logo-card__img"
|
||||||
|
/>
|
||||||
|
} @else {
|
||||||
|
<div class="tenant-logo-card__placeholder">
|
||||||
|
<i class="fa-solid fa-image mb-2"></i>
|
||||||
|
<span>Sin Logo de Footer</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="section-divider" />
|
||||||
|
|
||||||
|
<div class="colors-section">
|
||||||
|
<h3 class="mb-3">Colores de Marca</h3>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<div class="color-swatch-card shadow-sm">
|
||||||
|
<div
|
||||||
|
class="color-swatch-card__preview"
|
||||||
|
[style.background-color]="'var(--tenant-primary)'"
|
||||||
|
></div>
|
||||||
|
<div class="color-swatch-card__body">
|
||||||
|
<span class="color-swatch-card__title">Primario</span>
|
||||||
|
<code class="color-swatch-card__value">
|
||||||
|
{{ tenant()?.primary_color || 'Default' }}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<div class="color-swatch-card shadow-sm">
|
||||||
|
<div
|
||||||
|
class="color-swatch-card__preview"
|
||||||
|
[style.background-color]="'var(--tenant-secondary)'"
|
||||||
|
></div>
|
||||||
|
<div class="color-swatch-card__body">
|
||||||
|
<span class="color-swatch-card__title">Secundario</span>
|
||||||
|
<code class="color-swatch-card__value">
|
||||||
|
{{ tenant()?.secondary_color || 'Default' }}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<div class="color-swatch-card shadow-sm">
|
||||||
|
<div
|
||||||
|
class="color-swatch-card__preview"
|
||||||
|
[style.background-color]="'var(--tenant-danger)'"
|
||||||
|
></div>
|
||||||
|
<div class="color-swatch-card__body">
|
||||||
|
<span class="color-swatch-card__title">Danger</span>
|
||||||
|
<code class="color-swatch-card__value">
|
||||||
|
{{ tenant()?.danger_color || 'Default' }}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<div class="color-swatch-card shadow-sm">
|
||||||
|
<div
|
||||||
|
class="color-swatch-card__preview"
|
||||||
|
[style.background-color]="'var(--tenant-success)'"
|
||||||
|
></div>
|
||||||
|
<div class="color-swatch-card__body">
|
||||||
|
<span class="color-swatch-card__title">Success</span>
|
||||||
|
<code class="color-swatch-card__value">
|
||||||
|
{{ tenant()?.success_color || 'Default' }}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<div class="color-swatch-card shadow-sm">
|
||||||
|
<div
|
||||||
|
class="color-swatch-card__preview"
|
||||||
|
[style.background-color]="'var(--tenant-header-bg)'"
|
||||||
|
></div>
|
||||||
|
<div class="color-swatch-card__body">
|
||||||
|
<span class="color-swatch-card__title">Header BG</span>
|
||||||
|
<code class="color-swatch-card__value">
|
||||||
|
{{ tenant()?.header_bg_color || 'Default' }}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<div class="color-swatch-card shadow-sm">
|
||||||
|
<div
|
||||||
|
class="color-swatch-card__preview"
|
||||||
|
[style.background-color]="'var(--tenant-footer-bg)'"
|
||||||
|
></div>
|
||||||
|
<div class="color-swatch-card__body">
|
||||||
|
<span class="color-swatch-card__title">Footer BG</span>
|
||||||
|
<code class="color-swatch-card__value">
|
||||||
|
{{ tenant()?.footer_bg_color || 'Default' }}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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 (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.
|
||||||
|
</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-column-with-image
|
||||||
|
[imageUrl]="product.imageUrl"
|
||||||
|
[title]="product.title"
|
||||||
|
[originalPrice]="product.originalPrice"
|
||||||
|
[discount]="product.discount"
|
||||||
|
[transferPrice]="product.transferPrice"
|
||||||
|
(buy)="onProductBuy(product.title)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="component-demo card shadow-sm mt-4" data-testid="location-map-demo">
|
||||||
|
<div class="card-body">
|
||||||
|
<span class="eyebrow">Reusable Component</span>
|
||||||
|
<h2 class="mb-2">Mapa de ubicaciones</h2>
|
||||||
|
<p class="text-muted mb-4">
|
||||||
|
Prueba de Leaflet con múltiples puntos, encuadre automático y acceso a indicaciones.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<app-location-map
|
||||||
|
[locations]="mapLocations"
|
||||||
|
[markerColor]="tenant()?.primary_color ?? '#24a9e0'"
|
||||||
|
mapStyle="minimal-light"
|
||||||
|
ariaLabel="Mapa de puntos de atención en Rosario"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="component-demo card shadow-sm mt-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<span class="eyebrow">Reusable Component</span>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<section
|
||||||
|
class="component-demo component-demo--ticket-selector card shadow-sm mt-4"
|
||||||
|
data-testid="ticket-selector-demo"
|
||||||
|
>
|
||||||
|
<div class="card-body">
|
||||||
|
<span class="eyebrow">Reusable Component</span>
|
||||||
|
<h2 class="mb-2">Selector de entradas (Ticket Selector Layout)</h2>
|
||||||
|
<p class="text-muted mb-4">
|
||||||
|
Demo de selección encadenada por tipo, sector, fila y asiento. Las combinaciones sin stock
|
||||||
|
no aparecen como disponibles.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<app-product-ticket-selector
|
||||||
|
[productId]="1"
|
||||||
|
[title]="testTicketSelectorProduct.title"
|
||||||
|
[description]="testTicketSelectorProduct.description"
|
||||||
|
[price]="testTicketSelectorProduct.price"
|
||||||
|
[imageUrl]="testTicketSelectorProduct.imageUrl"
|
||||||
|
(buy)="onTicketBuy($event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<app-store-section title="PRODUCTOS" class="component-demo store-section-demo mt-5">
|
||||||
|
<div class="row">
|
||||||
|
@for (product of testProducts; track product.title) {
|
||||||
|
<div
|
||||||
|
class="col-12 col-md-6 col-lg-4 mb-4 d-flex align-items-stretch store-section-demo__item"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</app-store-section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -15,6 +15,40 @@
|
|||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.component-demo--ticket-selector {
|
||||||
|
width: min(100%, 76rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-section-demo {
|
||||||
|
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 {
|
.eyebrow {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
@@ -45,16 +79,92 @@ h1 {
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-showcase__result {
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
border: 1px dashed var(--border-color);
|
||||||
|
border-radius: 0.875rem;
|
||||||
|
background: linear-gradient(180deg, rgba(248, 248, 248, 0.92), rgba(255, 255, 255, 0.98));
|
||||||
|
color: #495057;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .modal-demo-content {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .modal-demo-content__eyebrow {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .modal-demo-content__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #202020;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .modal-demo-content__description {
|
||||||
|
margin: 0;
|
||||||
|
color: #505050;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .modal-demo-content__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
.button-group h2 {
|
.button-group h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.paginator-showcase {
|
||||||
|
display: grid;
|
||||||
|
justify-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-showcase {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-showcase__viewport {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 504px;
|
||||||
|
height: 640px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-showcase__header {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.button-grid--single-char app-button {
|
.button-grid--single-char app-button {
|
||||||
min-width: 3rem;
|
min-width: 3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.store-section-demo__item {
|
||||||
|
app-product-column-with-image {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.section-divider {
|
.section-divider {
|
||||||
margin: 2rem 0;
|
margin: 2rem 0;
|
||||||
}
|
}
|
||||||
@@ -75,6 +185,11 @@ h1 {
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.file-field {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 991.98px) {
|
@media (max-width: 991.98px) {
|
||||||
.input-grid {
|
.input-grid {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
@@ -82,7 +197,177 @@ h1 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 767.98px) {
|
@media (max-width: 767.98px) {
|
||||||
|
.carousel-product {
|
||||||
|
width: min(18rem, calc(100vw - 5rem));
|
||||||
|
}
|
||||||
|
|
||||||
.input-grid {
|
.input-grid {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cart-showcase__header {
|
||||||
|
align-items: start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-showcase__result {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.configuracion-tennant {
|
||||||
|
margin-top: 2rem;
|
||||||
|
|
||||||
|
.tenant-logo-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 120px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
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;
|
||||||
|
|
||||||
|
&: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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__img {
|
||||||
|
max-height: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.1));
|
||||||
|
}
|
||||||
|
|
||||||
|
&__placeholder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #6c757d;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
|
||||||
|
i {
|
||||||
|
font-size: 2rem;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-bg {
|
||||||
|
background-color: var(--tenant-header-bg, #f8f9fa);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-bg {
|
||||||
|
background-color: var(--tenant-footer-bg, #f8f9fa);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-swatch-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: #ffffff;
|
||||||
|
transition:
|
||||||
|
transform 0.2s ease,
|
||||||
|
box-shadow 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__preview {
|
||||||
|
height: 80px;
|
||||||
|
width: 100%;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.95;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__body {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__value {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #212529;
|
||||||
|
font-family: var(--bs-font-monospace);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button-showcase {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.5rem;
|
||||||
|
|
||||||
|
&__grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.5rem;
|
||||||
|
background-color: #eaeaea;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 160px 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
|
||||||
|
@media (max-width: 575.98px) {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #495057;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__items {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,345 @@
|
|||||||
|
import { signal } from '@angular/core';
|
||||||
|
import { TestBed, getTestBed } from '@angular/core/testing';
|
||||||
|
import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { ModalService } from '../../../../core/services/modal.service';
|
||||||
|
import { Tenant } from '../../../../core/services/tenant.interface';
|
||||||
|
import { TenantService } from '../../../../core/services/tenant.service';
|
||||||
|
import { ToastService } from '../../../../core/services/toast.service';
|
||||||
|
import { ReutilizablesTestPageComponent } from './reutilizables-test-page.component';
|
||||||
|
|
||||||
|
const tenant: Tenant = {
|
||||||
|
id: 1,
|
||||||
|
codigo: 'test',
|
||||||
|
nombre: 'Test Tenant',
|
||||||
|
dominio: 'localhost',
|
||||||
|
primary_color: '#6376F3',
|
||||||
|
secondary_color: '#A0A0A0',
|
||||||
|
danger_color: '#FF8888',
|
||||||
|
success_color: '#198754',
|
||||||
|
header_bg_color: '#313131',
|
||||||
|
footer_bg_color: '#313131',
|
||||||
|
header_logo: 'https://example.com/header.png',
|
||||||
|
footer_logo: 'https://example.com/footer.png',
|
||||||
|
categories: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function createTenantServiceStub(currentTenant: Tenant | null) {
|
||||||
|
const tenantState = signal(currentTenant);
|
||||||
|
const statusState = signal<'ready' | 'not-found'>('ready');
|
||||||
|
|
||||||
|
return {
|
||||||
|
tenant: tenantState.asReadonly(),
|
||||||
|
status: statusState.asReadonly(),
|
||||||
|
getTenant: () => tenantState(),
|
||||||
|
bootstrap: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createToastServiceStub() {
|
||||||
|
return {
|
||||||
|
success: vi.fn(),
|
||||||
|
danger: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createModalServiceStub() {
|
||||||
|
return {
|
||||||
|
open: vi.fn(),
|
||||||
|
openConfirm: vi.fn().mockReturnValue(of(true)),
|
||||||
|
openConfirmDelete: vi.fn().mockReturnValue(of(false)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ReutilizablesTestPageComponent', () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
try {
|
||||||
|
getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
|
||||||
|
} catch {
|
||||||
|
// Test environment may already be initialized by another setup entrypoint.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the tenant configuration section with logo sources and swatch colors', 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 sectionTitle = element.querySelector('.configuracion-tennant h2');
|
||||||
|
|
||||||
|
expect(sectionTitle?.textContent).toContain('Configuracion Tenant');
|
||||||
|
|
||||||
|
const headerImg = element.querySelector(
|
||||||
|
'.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"]',
|
||||||
|
) as HTMLImageElement;
|
||||||
|
expect(footerImg?.src).toBe(tenant.footer_logo);
|
||||||
|
|
||||||
|
expect(element.textContent).toContain(tenant.primary_color);
|
||||||
|
expect(element.textContent).toContain(tenant.secondary_color);
|
||||||
|
expect(element.textContent).toContain(tenant.danger_color);
|
||||||
|
expect(element.textContent).toContain(tenant.success_color);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the paginator demo with the initial page status', 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;
|
||||||
|
|
||||||
|
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(
|
||||||
|
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 () => {
|
||||||
|
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 buttons = Array.from(element.querySelectorAll('.button-group app-button button'));
|
||||||
|
const confirmButton = buttons.find((button) =>
|
||||||
|
button.textContent?.includes('Abrir confirm modal'),
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
const deleteButton = buttons.find((button) =>
|
||||||
|
button.textContent?.includes('Abrir confirm delete'),
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
|
||||||
|
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).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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,14 +1,76 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component, inject } from '@angular/core';
|
||||||
import { ButtonComponent } from '../../../../shared/components/button/button.component';
|
import { ButtonComponent } from '../../../../shared/components/button/button.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 { InputComponent } from '../../../../shared/components/input/input.component';
|
||||||
|
import { PaginatorComponent } from '../../../../shared/components/paginator/paginator.component';
|
||||||
|
import { ProductColumnWithImageComponent } from '../../../../shared/components/product-column-with-image/product-column-with-image.component';
|
||||||
|
import { ProductRowCardComponent } from '../../../../shared/components/product-row-card/product-row-card.component';
|
||||||
|
import { ProductTicketSelectorComponent } from '../../../../shared/components/product-ticket-selector/product-ticket-selector.component';
|
||||||
|
import { ProductVerticalWithCartCardComponent } from '../../../../shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component';
|
||||||
|
import { StoreSectionComponent } from '../../../../shared/components/store-section/store-section.component';
|
||||||
|
import { ModalService } from '../../../../core/services/modal.service';
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ticketOption = (value: string, label: string = value): { value: string; label: string } => ({
|
||||||
|
value,
|
||||||
|
label,
|
||||||
|
});
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-reutilizables-test-page',
|
selector: 'app-reutilizables-test-page',
|
||||||
imports: [ButtonComponent, InputComponent],
|
imports: [
|
||||||
|
ButtonComponent,
|
||||||
|
CartComponent,
|
||||||
|
InputComponent,
|
||||||
|
PaginatorComponent,
|
||||||
|
ProductColumnWithImageComponent,
|
||||||
|
ProductRowCardComponent,
|
||||||
|
ProductTicketSelectorComponent,
|
||||||
|
ProductVerticalWithCartCardComponent,
|
||||||
|
StoreSectionComponent,
|
||||||
|
IconButtonComponent,
|
||||||
|
CartIconComponent,
|
||||||
|
StepperComponent,
|
||||||
|
StepComponent,
|
||||||
|
CarouselComponent,
|
||||||
|
MainCarouselComponent,
|
||||||
|
AccordionComponent,
|
||||||
|
AccordionItemComponent,
|
||||||
|
LocationMapComponent,
|
||||||
|
],
|
||||||
templateUrl: './reutilizables-test-page.component.html',
|
templateUrl: './reutilizables-test-page.component.html',
|
||||||
styleUrl: './reutilizables-test-page.component.scss'
|
styleUrl: './reutilizables-test-page.component.scss',
|
||||||
})
|
})
|
||||||
export class ReutilizablesTestPageComponent {
|
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 textValue = 'Auriculares';
|
||||||
protected numberValue = '24';
|
protected numberValue = '24';
|
||||||
protected email = 'usuario@empresa.com';
|
protected email = 'usuario@empresa.com';
|
||||||
@@ -24,6 +86,323 @@ export class ReutilizablesTestPageComponent {
|
|||||||
protected fileName = '';
|
protected fileName = '';
|
||||||
protected clearTrigger = 0;
|
protected clearTrigger = 0;
|
||||||
protected editableDisabled = true;
|
protected editableDisabled = true;
|
||||||
|
protected currentPage = 1;
|
||||||
|
protected cartVisible = true;
|
||||||
|
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 = [
|
||||||
|
{
|
||||||
|
title: 'Pantalon recto de scuba negro',
|
||||||
|
originalPrice: 95000,
|
||||||
|
discount: 20,
|
||||||
|
transferPrice: 74800,
|
||||||
|
imageUrl: '/images/pantalon-scuba.png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Zapatillas de running azul',
|
||||||
|
originalPrice: 120000,
|
||||||
|
discount: 15,
|
||||||
|
transferPrice: 98000,
|
||||||
|
imageUrl: '/images/zapatillas-running.png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Remera basica blanca (Sin Descuento)',
|
||||||
|
originalPrice: 32000,
|
||||||
|
discount: null,
|
||||||
|
transferPrice: 30000,
|
||||||
|
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: [
|
||||||
|
{ id: '10-oct-manana', values: { fecha: '10 de Octubre', turno: 'Mañana' } },
|
||||||
|
{ id: '10-oct-tarde', values: { fecha: '10 de Octubre', turno: 'Tarde' } },
|
||||||
|
{ id: '11-oct-tarde', values: { fecha: '11 de Octubre', turno: 'Tarde' } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
protected readonly testVerticalWithCartProduct = {
|
||||||
|
title: 'PANCHO',
|
||||||
|
description: 'Pancho con aderezo a elección.',
|
||||||
|
price: 11500,
|
||||||
|
};
|
||||||
|
|
||||||
|
protected readonly testTicketSelectorProduct = {
|
||||||
|
title: 'ENTRADAS DESFILE PURA TENDENCIA',
|
||||||
|
description: '',
|
||||||
|
price: 40000,
|
||||||
|
imageUrl: '/images/ticket-selector-entrada-pasarela.png',
|
||||||
|
variants: [
|
||||||
|
{
|
||||||
|
id: 1001,
|
||||||
|
precio: 250000,
|
||||||
|
maximum_addable_quantity: 1,
|
||||||
|
values: {
|
||||||
|
tipo: ticketOption('vip_lunch', 'VIP + Lunch'),
|
||||||
|
sector: ticketOption('a', 'Sector A'),
|
||||||
|
fila: ticketOption('1', 'Fila 1'),
|
||||||
|
asiento: ticketOption('1', 'Asiento 1'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1002,
|
||||||
|
precio: 250000,
|
||||||
|
maximum_addable_quantity: 1,
|
||||||
|
values: {
|
||||||
|
tipo: ticketOption('vip_lunch', 'VIP + Lunch'),
|
||||||
|
sector: ticketOption('a', 'Sector A'),
|
||||||
|
fila: ticketOption('1', 'Fila 1'),
|
||||||
|
asiento: ticketOption('2', 'Asiento 2'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1003,
|
||||||
|
precio: 250000,
|
||||||
|
maximum_addable_quantity: 1,
|
||||||
|
values: {
|
||||||
|
tipo: ticketOption('vip_lunch', 'VIP + Lunch'),
|
||||||
|
sector: ticketOption('c', 'Sector C'),
|
||||||
|
fila: ticketOption('1', 'Fila 1'),
|
||||||
|
asiento: ticketOption('1', 'Asiento 1'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1004,
|
||||||
|
precio: 200000,
|
||||||
|
maximum_addable_quantity: 1,
|
||||||
|
values: {
|
||||||
|
tipo: ticketOption('vip_lunch', 'VIP + Lunch'),
|
||||||
|
sector: ticketOption('a', 'Sector A'),
|
||||||
|
fila: ticketOption('2', 'Fila 2'),
|
||||||
|
asiento: ticketOption('1', 'Asiento 1'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1005,
|
||||||
|
precio: 200000,
|
||||||
|
maximum_addable_quantity: 0,
|
||||||
|
values: {
|
||||||
|
tipo: ticketOption('vip_lunch', 'VIP + Lunch'),
|
||||||
|
sector: ticketOption('a', 'Sector A'),
|
||||||
|
fila: ticketOption('2', 'Fila 2'),
|
||||||
|
asiento: ticketOption('2', 'Asiento 2'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1006,
|
||||||
|
precio: 100000,
|
||||||
|
maximum_addable_quantity: 1,
|
||||||
|
values: {
|
||||||
|
tipo: ticketOption('general', 'General'),
|
||||||
|
sector: ticketOption('b', 'Sector B'),
|
||||||
|
fila: ticketOption('3', 'Fila 3'),
|
||||||
|
asiento: ticketOption('1', 'Asiento 1'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1007,
|
||||||
|
precio: 100000,
|
||||||
|
maximum_addable_quantity: 1,
|
||||||
|
values: {
|
||||||
|
tipo: ticketOption('general', 'General'),
|
||||||
|
sector: ticketOption('b', 'Sector B'),
|
||||||
|
fila: ticketOption('3', 'Fila 3'),
|
||||||
|
asiento: ticketOption('2', 'Asiento 2'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1008,
|
||||||
|
precio: 90000,
|
||||||
|
maximum_addable_quantity: 1,
|
||||||
|
values: {
|
||||||
|
tipo: ticketOption('general', 'General'),
|
||||||
|
sector: ticketOption('d', 'Sector D'),
|
||||||
|
fila: ticketOption('3', 'Fila 3'),
|
||||||
|
asiento: ticketOption('1', 'Asiento 1'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1009,
|
||||||
|
precio: 65000,
|
||||||
|
maximum_addable_quantity: 1,
|
||||||
|
values: {
|
||||||
|
tipo: ticketOption('general', 'General'),
|
||||||
|
sector: ticketOption('d', 'Sector D'),
|
||||||
|
fila: ticketOption('4', 'Fila 4'),
|
||||||
|
asiento: ticketOption('1', 'Asiento 1'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1010,
|
||||||
|
precio: 40000,
|
||||||
|
maximum_addable_quantity: 1,
|
||||||
|
values: {
|
||||||
|
tipo: ticketOption('general', 'General'),
|
||||||
|
sector: ticketOption('d', 'Sector D'),
|
||||||
|
fila: ticketOption('5', 'Fila 5'),
|
||||||
|
asiento: ticketOption('1', 'Asiento 1'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
protected readonly cartMockItems: CartItemMock[] = [
|
||||||
|
{
|
||||||
|
imageUrl: null,
|
||||||
|
product: 'Calza termica unisex con proceso sense y cintura con mayor agarre',
|
||||||
|
originalPrice: 95000,
|
||||||
|
discountedPrice: 76000,
|
||||||
|
discountPercentage: 20,
|
||||||
|
quantity: 1,
|
||||||
|
attributes: [
|
||||||
|
{ label: 'Color', value: 'Beige' },
|
||||||
|
{ label: 'Talle', value: 'S' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
imageUrl: null,
|
||||||
|
product: 'Pantalon recto de scuba negro',
|
||||||
|
originalPrice: null,
|
||||||
|
discountedPrice: 89000,
|
||||||
|
discountPercentage: null,
|
||||||
|
quantity: 1,
|
||||||
|
attributes: [
|
||||||
|
{ label: 'Color', value: 'Beige' },
|
||||||
|
{ label: 'Talle', value: 'S' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
imageUrl: null,
|
||||||
|
product: 'Top de supplex color habano',
|
||||||
|
originalPrice: 98000,
|
||||||
|
discountedPrice: 85000,
|
||||||
|
discountPercentage: 20,
|
||||||
|
quantity: 1,
|
||||||
|
attributes: [
|
||||||
|
{ label: 'Color', value: 'Negro' },
|
||||||
|
{ label: 'Talle', value: 'XL' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
imageUrl: null,
|
||||||
|
product: 'Buzo oversize canguro gris',
|
||||||
|
originalPrice: 72000,
|
||||||
|
discountedPrice: 69000,
|
||||||
|
discountPercentage: 5,
|
||||||
|
quantity: 2,
|
||||||
|
attributes: [
|
||||||
|
{ label: 'Color', value: 'Gris' },
|
||||||
|
{ label: 'Talle', value: 'M' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
protected onProductBuy(productTitle: string): void {
|
||||||
|
console.log(`Comprando: ${productTitle}`);
|
||||||
|
alert(`Comprando: ${productTitle}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onTicketBuy(variantIds: number[]): void {
|
||||||
|
this.toastService.success(
|
||||||
|
`Compra iniciada para ${variantIds.length} entrada/s: ${variantIds.join(', ')}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
protected onFileChange(file: File | null): void {
|
protected onFileChange(file: File | null): void {
|
||||||
this.fileName = file?.name ?? '';
|
this.fileName = file?.name ?? '';
|
||||||
@@ -33,4 +412,96 @@ export class ReutilizablesTestPageComponent {
|
|||||||
this.fileName = '';
|
this.fileName = '';
|
||||||
this.clearTrigger += 1;
|
this.clearTrigger += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected showCart(): void {
|
||||||
|
this.cartVisible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected hideCart(): void {
|
||||||
|
this.cartVisible = false;
|
||||||
|
this.toastService.info('Se disparo el evento de cerrado del carrito mock.');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected triggerToast(type: 'success' | 'danger' | 'info'): void {
|
||||||
|
if (type === 'success') {
|
||||||
|
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.');
|
||||||
|
} else if (type === 'info') {
|
||||||
|
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);
|
||||||
|
} else if (type === 'danger') {
|
||||||
|
this.toastService.danger(
|
||||||
|
'Error persistente. Por favor, atienda este problema y cierrelo manualmente.',
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
} else if (type === 'info') {
|
||||||
|
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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected openConfirmDeleteModal(): void {
|
||||||
|
this.openConfirmDelete({
|
||||||
|
title: 'Eliminar producto',
|
||||||
|
content:
|
||||||
|
'Esta accion eliminara el producto del catalogo. Podras volver a crearlo manualmente.',
|
||||||
|
confirmLabel: 'Eliminar',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected openLockedModal(): void {
|
||||||
|
this.openConfirmModal({
|
||||||
|
title: 'Modal bloqueado',
|
||||||
|
content: 'Este modal no se cierra tocando el backdrop ni con la tecla Escape.',
|
||||||
|
confirmLabel: 'Entendido',
|
||||||
|
closeOnBackdrop: false,
|
||||||
|
closeOnEscape: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected openWideModal(): void {
|
||||||
|
this.openConfirmModal({
|
||||||
|
title: 'Modal ancho',
|
||||||
|
content: 'Demuestra una variante visual mas amplia para contenido mas pesado.',
|
||||||
|
confirmLabel: 'Seguir',
|
||||||
|
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}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<section class="menu-content-section" [attr.aria-labelledby]="titleId()">
|
||||||
|
<header class="menu-content-section__header">
|
||||||
|
<div class="menu-content-section__prefix">
|
||||||
|
<ng-content select="[menuContentPrefix]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="menu-content-section__title" [id]="titleId()">
|
||||||
|
{{ title() | uppercase }}
|
||||||
|
</h2>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="menu-content-section__body">
|
||||||
|
<ng-content />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-content-section {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-content-section__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-content-section__prefix:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-content-section__title {
|
||||||
|
margin: 0;
|
||||||
|
color: #888888;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-content-section__body {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
.menu-content-section {
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-content-section__header {
|
||||||
|
position: relative;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-content-section__prefix:not(:empty) {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-content-section__title {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { Component, signal } from '@angular/core';
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { Tenant } from '../../../../core/services/tenant.interface';
|
||||||
|
import { TenantService } from '../../../../core/services/tenant.service';
|
||||||
|
import { MenuContentSectionComponent } from './menu-content-section.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
imports: [MenuContentSectionComponent],
|
||||||
|
template: `
|
||||||
|
<app-menu-content-section menuCode="help.contact" titleId="contact-title">
|
||||||
|
<a menuContentPrefix href="/ayuda">Volver</a>
|
||||||
|
<p>Contenido proyectado</p>
|
||||||
|
</app-menu-content-section>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
class TestHostComponent {}
|
||||||
|
|
||||||
|
describe('MenuContentSectionComponent', () => {
|
||||||
|
it('renders an accessible title and projects its content', async () => {
|
||||||
|
const tenant = signal({
|
||||||
|
menues: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
code: 'help.contact',
|
||||||
|
label: 'Contacto del comercio',
|
||||||
|
parent_menu_code: 'help',
|
||||||
|
content_type: 'static',
|
||||||
|
route: '/ayuda/contacto',
|
||||||
|
submenues: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as Tenant);
|
||||||
|
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [TestHostComponent],
|
||||||
|
providers: [{ provide: TenantService, useValue: { tenant: tenant.asReadonly() } }],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(TestHostComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
const section = element.querySelector('section');
|
||||||
|
|
||||||
|
expect(section?.getAttribute('aria-labelledby')).toBe('contact-title');
|
||||||
|
expect(element.querySelector('#contact-title')?.textContent?.trim()).toBe(
|
||||||
|
'CONTACTO DEL COMERCIO',
|
||||||
|
);
|
||||||
|
expect(element.querySelector('.menu-content-section__prefix a')?.textContent).toContain(
|
||||||
|
'Volver',
|
||||||
|
);
|
||||||
|
expect(element.querySelector('.menu-content-section__body')?.textContent).toContain(
|
||||||
|
'Contenido proyectado',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { UpperCasePipe } from '@angular/common';
|
||||||
|
import { Component, computed, inject, input } from '@angular/core';
|
||||||
|
|
||||||
|
import { findMenu } from '../../../../core/services/menu.utils';
|
||||||
|
import { TenantService } from '../../../../core/services/tenant.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-menu-content-section',
|
||||||
|
imports: [UpperCasePipe],
|
||||||
|
templateUrl: './menu-content-section.component.html',
|
||||||
|
styleUrl: './menu-content-section.component.scss',
|
||||||
|
})
|
||||||
|
export class MenuContentSectionComponent {
|
||||||
|
private readonly tenantService = inject(TenantService);
|
||||||
|
|
||||||
|
readonly menuCode = input.required<string>();
|
||||||
|
readonly titleId = input.required<string>();
|
||||||
|
|
||||||
|
protected readonly title = computed(
|
||||||
|
() => findMenu(this.tenantService.tenant()?.menues ?? [], this.menuCode())?.label ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<div class="menu-page-layout">
|
||||||
|
<aside class="menu-page-layout__sidebar">
|
||||||
|
<h1 class="menu-page-layout__title">{{ menu()?.label }}</h1>
|
||||||
|
<div class="menu-page-layout__divider"></div>
|
||||||
|
|
||||||
|
<nav class="menu-page-layout__nav" [attr.aria-label]="navigationLabel">
|
||||||
|
@for (submenu of submenues(); track submenu.code) {
|
||||||
|
<a
|
||||||
|
class="menu-page-layout__link"
|
||||||
|
[routerLink]="submenu.route"
|
||||||
|
routerLinkActive="menu-page-layout__link--active"
|
||||||
|
>
|
||||||
|
{{ submenu.label }}
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="menu-page-layout__content">
|
||||||
|
<router-outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-page-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(190px, 250px) minmax(0, 1fr);
|
||||||
|
gap: clamp(3rem, 8vw, 6.25rem);
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&__sidebar,
|
||||||
|
&__content {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
color: #1a1a1a;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.25;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__divider {
|
||||||
|
height: 1px;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
background: #dedede;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__nav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__link {
|
||||||
|
color: #888888;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.35;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 150ms ease;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus-visible {
|
||||||
|
color: #5a5a5a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
border-radius: 2px;
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--active {
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
.menu-page-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 2rem;
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__nav {
|
||||||
|
flex-flow: row wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.75rem 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__content {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { signal } from '@angular/core';
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { ActivatedRoute, provideRouter } from '@angular/router';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { Tenant } from '../../../../core/services/tenant.interface';
|
||||||
|
import { TenantService } from '../../../../core/services/tenant.service';
|
||||||
|
import { MenuPageLayoutComponent } from './menu-page-layout.component';
|
||||||
|
|
||||||
|
function createTenant(): Tenant {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
codigo: 'test',
|
||||||
|
nombre: 'Test',
|
||||||
|
dominio: 'localhost',
|
||||||
|
primary_color: '#000000',
|
||||||
|
secondary_color: '#000000',
|
||||||
|
danger_color: '#000000',
|
||||||
|
success_color: '#000000',
|
||||||
|
header_bg_color: '#ffffff',
|
||||||
|
footer_bg_color: '#ffffff',
|
||||||
|
header_logo: '',
|
||||||
|
footer_logo: '',
|
||||||
|
categories: [],
|
||||||
|
menues: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
code: 'account',
|
||||||
|
label: 'Mi cuenta',
|
||||||
|
parent_menu_code: null,
|
||||||
|
content_type: 'dynamic',
|
||||||
|
route: '/mi-cuenta',
|
||||||
|
submenues: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
code: 'account.profile',
|
||||||
|
label: 'Datos personales',
|
||||||
|
parent_menu_code: 'account',
|
||||||
|
content_type: 'dynamic',
|
||||||
|
route: '/mi-cuenta/datos-personales',
|
||||||
|
submenues: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
code: 'account.purchases',
|
||||||
|
label: 'Mis compras',
|
||||||
|
parent_menu_code: 'account',
|
||||||
|
content_type: 'dynamic',
|
||||||
|
route: '/mi-cuenta/compras',
|
||||||
|
submenues: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
code: 'help',
|
||||||
|
label: 'Centro de ayuda',
|
||||||
|
parent_menu_code: null,
|
||||||
|
content_type: 'dynamic',
|
||||||
|
route: '/ayuda',
|
||||||
|
submenues: [
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
code: 'help.faq',
|
||||||
|
label: 'Consultas habituales',
|
||||||
|
parent_menu_code: 'help',
|
||||||
|
content_type: 'dynamic',
|
||||||
|
route: '/ayuda/preguntas-frecuentes',
|
||||||
|
submenues: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderLayout(menuCode: string, navigationLabel: string): Promise<HTMLElement> {
|
||||||
|
const tenant = signal(createTenant());
|
||||||
|
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [MenuPageLayoutComponent],
|
||||||
|
providers: [
|
||||||
|
provideRouter([]),
|
||||||
|
{
|
||||||
|
provide: ActivatedRoute,
|
||||||
|
useValue: {
|
||||||
|
snapshot: {
|
||||||
|
data: { menuCode, navigationLabel },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: TenantService,
|
||||||
|
useValue: { tenant: tenant.asReadonly() },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(MenuPageLayoutComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
return fixture.nativeElement as HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MenuPageLayoutComponent', () => {
|
||||||
|
it('renders the configured account menu and its navigation label', async () => {
|
||||||
|
const element = await renderLayout('account', 'Secciones de mi cuenta');
|
||||||
|
const links = Array.from(
|
||||||
|
element.querySelectorAll<HTMLAnchorElement>('.menu-page-layout__link'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(element.querySelector('.menu-page-layout__title')?.textContent?.trim()).toBe(
|
||||||
|
'Mi cuenta',
|
||||||
|
);
|
||||||
|
expect(element.querySelector('nav')?.getAttribute('aria-label')).toBe('Secciones de mi cuenta');
|
||||||
|
expect(links.map((link) => link.textContent?.trim())).toEqual([
|
||||||
|
'Datos personales',
|
||||||
|
'Mis compras',
|
||||||
|
]);
|
||||||
|
expect(links.map((link) => link.getAttribute('href'))).toEqual([
|
||||||
|
'/mi-cuenta/datos-personales',
|
||||||
|
'/mi-cuenta/compras',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the same component for the help menu', async () => {
|
||||||
|
const element = await renderLayout('help', 'Secciones de ayuda');
|
||||||
|
|
||||||
|
expect(element.querySelector('.menu-page-layout__title')?.textContent?.trim()).toBe(
|
||||||
|
'Centro de ayuda',
|
||||||
|
);
|
||||||
|
expect(element.querySelector('.menu-page-layout__link')?.textContent?.trim()).toBe(
|
||||||
|
'Consultas habituales',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Component, computed, inject } from '@angular/core';
|
||||||
|
import { ActivatedRoute, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
|
||||||
|
|
||||||
|
import { findMenu } from '../../../../core/services/menu.utils';
|
||||||
|
import { TenantService } from '../../../../core/services/tenant.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-menu-page-layout',
|
||||||
|
imports: [RouterLink, RouterLinkActive, RouterOutlet],
|
||||||
|
templateUrl: './menu-page-layout.component.html',
|
||||||
|
styleUrl: './menu-page-layout.component.scss',
|
||||||
|
})
|
||||||
|
export class MenuPageLayoutComponent {
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
private readonly tenantService = inject(TenantService);
|
||||||
|
|
||||||
|
protected readonly navigationLabel =
|
||||||
|
(this.route.snapshot.data['navigationLabel'] as string | undefined) ?? 'Secciones';
|
||||||
|
|
||||||
|
private readonly menuCode = this.route.snapshot.data['menuCode'] as string | undefined;
|
||||||
|
|
||||||
|
protected readonly menu = computed(() =>
|
||||||
|
findMenu(this.tenantService.tenant()?.menues ?? [], this.menuCode ?? ''),
|
||||||
|
);
|
||||||
|
protected readonly submenues = computed(() => this.menu()?.submenues ?? []);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<div class="attribute-selector">
|
||||||
|
@for (attribute of visibleAttributes(); track attribute.codigo) {
|
||||||
|
<div class="attribute-selector__row">
|
||||||
|
<span class="attribute-selector__label">{{ attribute.nombre }}:</span>
|
||||||
|
|
||||||
|
<div class="attribute-selector__options">
|
||||||
|
@for (option of attribute.options; track option.id) {
|
||||||
|
@if (isColorOption(option)) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="attribute-selector__swatch"
|
||||||
|
[class.attribute-selector__swatch--selected]="hasSelectedOption(attribute, option)"
|
||||||
|
[class.attribute-selector__swatch--disabled]="
|
||||||
|
!availableOptions()[attribute.codigo][option.id]
|
||||||
|
"
|
||||||
|
[style.background-color]="getOptionSwatchColor(option)"
|
||||||
|
[attr.aria-label]="option.label"
|
||||||
|
[attr.aria-pressed]="hasSelectedOption(attribute, option)"
|
||||||
|
[title]="option.label"
|
||||||
|
[disabled]="!availableOptions()[attribute.codigo][option.id]"
|
||||||
|
(click)="selectAttributeOption(attribute, option)"
|
||||||
|
>
|
||||||
|
<span class="visually-hidden">{{ option.label }}</span>
|
||||||
|
</button>
|
||||||
|
} @else {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="attribute-selector__text-option"
|
||||||
|
[class.attribute-selector__text-option--selected]="
|
||||||
|
hasSelectedOption(attribute, option)
|
||||||
|
"
|
||||||
|
[class.attribute-selector__text-option--disabled]="
|
||||||
|
!availableOptions()[attribute.codigo][option.id]
|
||||||
|
"
|
||||||
|
[attr.aria-pressed]="hasSelectedOption(attribute, option)"
|
||||||
|
[disabled]="!availableOptions()[attribute.codigo][option.id]"
|
||||||
|
(click)="selectAttributeOption(attribute, option)"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
.attribute-selector {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
|
||||||
|
&__row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.875rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: #A0A0A0;
|
||||||
|
min-width: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__options {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__swatch {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--selected {
|
||||||
|
border-color: var(--tenant-primary);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2px rgba(255, 255, 255, 1),
|
||||||
|
0 0 0 3px var(--tenant-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: not-allowed;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__text-option {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: #ffffff;
|
||||||
|
color: #666666;
|
||||||
|
transition: border-color 0.2s ease, color 0.2s ease, background-color 0.2s ease;
|
||||||
|
min-width: 34px;
|
||||||
|
min-height: 34px;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.35rem 0.6rem;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
|
||||||
|
&--selected {
|
||||||
|
background: var(--tenant-primary);
|
||||||
|
border-color: var(--tenant-primary);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
color: #b0b0b0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||