Compare commits
509 Commits
1bff88e92a
...
feature/on
| Author | SHA1 | Date | |
|---|---|---|---|
| 2162ae3963 | |||
| 92cc1c0275 | |||
| ed806abb9f | |||
| 321f2c08cd | |||
| 9ba3b24b7e | |||
| b8b5292200 | |||
| e8602cca13 | |||
| 36fda9f7ea | |||
| 230e5b01ac | |||
| 85f1b820b4 | |||
| 0fffdc5c5a | |||
| 0e6c949513 | |||
| fc04992ebb | |||
| 7d27d77629 | |||
| b2ffa7bb6b | |||
| 3465352218 | |||
| 8adbfecc35 | |||
| 1f35a6d709 | |||
| 32f86d14d6 | |||
| 49eaf0ad65 | |||
| db97de6711 | |||
| c876b2b171 | |||
| f6ff91ff3b | |||
| acac2858c3 | |||
| cd1c99f487 | |||
| 607892173d | |||
| 52f7b98162 | |||
| a06f8146e5 | |||
| fd60880812 | |||
| e0b1f27b97 | |||
| e7d24607a6 | |||
| 865893b96b | |||
| 14bda25c56 | |||
| 6997550767 | |||
| 657ef1d89f | |||
| 018b1d8ec7 | |||
| 93a9ca7e5c | |||
| 6ce734b2c2 | |||
| 7f4aa81d4b | |||
| 6927da6342 | |||
| 2f98fd6234 | |||
| de5c533d85 | |||
| 90a0ac3e36 | |||
| de10de2376 | |||
| 4923010afb | |||
| f87f7f55a9 | |||
| 17dbf52181 | |||
| a7e1d8995f | |||
| b5fae81795 | |||
| 8664c86915 | |||
| 9e72ec0c60 | |||
| e9aae7a4c3 | |||
| 6545be498f | |||
| 8c15ad7cc1 | |||
| f859eba8a8 | |||
| 8ba34c6c7a | |||
| d7c015e71e | |||
| d8d608c435 | |||
| 985316d3b4 | |||
| d601cc1984 | |||
| 70d47ac310 | |||
| 5d1d7086c7 | |||
| 9a6ec3d1f2 | |||
| 38ba5f4195 | |||
| 4bd74d95cb | |||
| 6f87aae939 | |||
| 71ef09b413 | |||
| 1d54cb1c48 | |||
| 7d52e11bca | |||
| ebae063a0f | |||
| f0c1e74c1b | |||
| 709859e731 | |||
| e95a7612c2 | |||
| 8bc0e165c7 | |||
| 158b28ec1a | |||
| 8c22485d49 | |||
| 2462f25f2f | |||
| 63b01f366a | |||
| ddd0b1b56d | |||
| 2cad0c39b4 | |||
| 3089873232 | |||
| 3fa0235163 | |||
| 0910d90363 | |||
| f3d446d012 | |||
| 71c462aaba | |||
| 481eb30795 | |||
| afebc7f639 | |||
| 1ec48f7e21 | |||
| 07972760fc | |||
| 848c3b280f | |||
| c387a01d62 | |||
| 5d9adc0494 | |||
| 593580d648 | |||
| eb524c7d99 | |||
| 65e8436ae1 | |||
| 07500f699d | |||
| 4997665da6 | |||
| dbfadf417a | |||
| dddb086cd8 | |||
| a825b3693b | |||
| 679342ec8d | |||
| a5420e6a42 | |||
| 91b8788c91 | |||
| b6a52ceb8c | |||
| 51d051733c | |||
| f62cd08807 | |||
| d61b1e4b0a | |||
| 1bffefece3 | |||
| b0d2cd05bc | |||
| 520397baa5 | |||
| 591f53c102 | |||
| 85529f40f2 | |||
| 4a952af784 | |||
| b1d4c4d13d | |||
| 8861e42fff | |||
| 7fbdd3844f | |||
| f2c1542e46 | |||
| def86405dc | |||
| 870de3ca6e | |||
| 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 | |||
| 7ee103b77b | |||
| 4e2dccd2e3 | |||
| da01745732 | |||
| ac9ba12045 | |||
| 23e8da345a | |||
| 236a3772a4 | |||
| bccb1b565f | |||
| 0d46172325 | |||
| f1a4f51695 | |||
| d58445e199 | |||
| 455b7c8506 | |||
| a0c35c76a7 | |||
| 6086070891 | |||
| 3c163e672b | |||
| def3a04548 | |||
| a6a9f4e5e3 | |||
| 5e25c00240 | |||
| 6b4fccbdbb | |||
| e608b31b93 | |||
| d108cf1ee2 |
2
.vscode/launch.json
vendored
@@ -7,7 +7,7 @@
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "npm: start",
|
||||
"url": "http://localhost:4200/"
|
||||
"url": "http://localhost:4300/"
|
||||
},
|
||||
{
|
||||
"name": "ng test",
|
||||
|
||||
60
angular.json
@@ -2,7 +2,8 @@
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"cli": {
|
||||
"packageManager": "npm"
|
||||
"packageManager": "npm",
|
||||
"analytics": false
|
||||
},
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
@@ -29,13 +30,16 @@
|
||||
"input": "public"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.scss"
|
||||
],
|
||||
"styles": ["node_modules/leaflet/dist/leaflet.css", "src/styles.scss"],
|
||||
"server": "src/main.server.ts",
|
||||
"outputMode": "server",
|
||||
"security": {
|
||||
"allowedHosts": ["localhost", "127.0.0.1"]
|
||||
"allowedHosts": [
|
||||
"localhost",
|
||||
"localhost:4300",
|
||||
"127.0.0.1",
|
||||
"127.0.0.1:4300"
|
||||
]
|
||||
},
|
||||
"ssr": {
|
||||
"entry": "src/server.ts"
|
||||
@@ -43,6 +47,9 @@
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"optimization": true,
|
||||
"extractLicenses": true,
|
||||
"sourceMap": false,
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
@@ -55,27 +62,64 @@
|
||||
"maximumError": "8kB"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all"
|
||||
"outputHashing": "all",
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "src/environments/environment.ts",
|
||||
"with": "src/environments/environment.production.ts"
|
||||
}
|
||||
]
|
||||
},
|
||||
"development": {
|
||||
"optimization": false,
|
||||
"optimization": true,
|
||||
"extractLicenses": false,
|
||||
"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"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular/build:dev-server",
|
||||
"options": {
|
||||
"port": 4300
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "shopit-front:build:production"
|
||||
},
|
||||
"development": {
|
||||
"buildTarget": "shopit-front:build:development"
|
||||
},
|
||||
"local": {
|
||||
"buildTarget": "shopit-front:build:local"
|
||||
},
|
||||
"homo": {
|
||||
"buildTarget": "shopit-front:build:homo"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
"defaultConfiguration": "local"
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular/build:unit-test"
|
||||
|
||||
333
package-lock.json
generated
@@ -16,8 +16,11 @@
|
||||
"@angular/platform-server": "^22.0.0",
|
||||
"@angular/router": "^22.0.0",
|
||||
"@angular/ssr": "^22.0.4",
|
||||
"@fortawesome/fontawesome-free": "^7.2.0",
|
||||
"bootstrap": "^5.3.8",
|
||||
"express": "^5.1.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"qrcode": "1.5.4",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
@@ -26,6 +29,7 @@
|
||||
"@angular/cli": "^22.0.4",
|
||||
"@angular/compiler-cli": "^22.0.0",
|
||||
"@types/express": "^5.0.1",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/node": "^20.17.19",
|
||||
"jsdom": "^28.0.0",
|
||||
"prettier": "^3.8.1",
|
||||
@@ -1616,6 +1620,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@fortawesome/fontawesome-free": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-7.2.0.tgz",
|
||||
"integrity": "sha512-3DguDv/oUE+7vjMeTSOjCSG+KeawgVQOHrKRnvUuqYh1mfArrh7s+s8hXW3e4RerBA1+Wh+hBqf8sJNpqNrBWg==",
|
||||
"license": "(CC-BY-4.0 AND OFL-1.1 AND MIT)",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@gar/promise-retry": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@gar/promise-retry/-/promise-retry-1.0.3.tgz",
|
||||
@@ -3805,6 +3818,13 @@
|
||||
"@types/send": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/http-errors": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
||||
@@ -3812,6 +3832,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/leaflet": {
|
||||
"version": "1.9.21",
|
||||
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
|
||||
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.43",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
|
||||
@@ -4384,6 +4414,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001799",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz",
|
||||
@@ -4568,6 +4607,24 @@
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
|
||||
@@ -4749,6 +4806,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||
@@ -4776,6 +4842,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dijkstrajs": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dom-serializer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||
@@ -5227,6 +5299,19 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
@@ -5296,7 +5381,6 @@
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
@@ -5871,6 +5955,12 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/leaflet": {
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/listr2": {
|
||||
"version": "10.2.1",
|
||||
"resolved": "https://registry.npmjs.org/listr2/-/listr2-10.2.1.tgz",
|
||||
@@ -5917,6 +6007,18 @@
|
||||
"@lmdb/lmdb-win32-x64": "3.5.4"
|
||||
}
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/log-symbols": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz",
|
||||
@@ -6693,6 +6795,33 @@
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/p-limit": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-locate": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/p-map": {
|
||||
"version": "7.0.4",
|
||||
"resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz",
|
||||
@@ -6706,6 +6835,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-try": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/pacote": {
|
||||
"version": "21.5.1",
|
||||
"resolved": "https://registry.npmjs.org/pacote/-/pacote-21.5.1.tgz",
|
||||
@@ -6814,6 +6952,15 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
@@ -6911,6 +7058,15 @@
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
@@ -7023,6 +7179,154 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
"yargs": "^15.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"qrcode": "bin/qrcode"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/qrcode/node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/qrcode/node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^4.1.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^4.2.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^18.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/yargs-parser": {
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
||||
@@ -7083,6 +7387,15 @@
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
@@ -7093,6 +7406,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/restore-cursor": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz",
|
||||
@@ -7315,6 +7634,12 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
@@ -8680,6 +9005,12 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
|
||||
@@ -20,8 +20,11 @@
|
||||
"@angular/platform-server": "^22.0.0",
|
||||
"@angular/router": "^22.0.0",
|
||||
"@angular/ssr": "^22.0.4",
|
||||
"@fortawesome/fontawesome-free": "^7.2.0",
|
||||
"bootstrap": "^5.3.8",
|
||||
"express": "^5.1.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"qrcode": "1.5.4",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
@@ -30,6 +33,7 @@
|
||||
"@angular/cli": "^22.0.4",
|
||||
"@angular/compiler-cli": "^22.0.0",
|
||||
"@types/express": "^5.0.1",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/node": "^20.17.19",
|
||||
"jsdom": "^28.0.0",
|
||||
"prettier": "^3.8.1",
|
||||
|
||||
BIN
public/fonts/Gotham/Gotham Black.otf
Normal file
BIN
public/fonts/Gotham/Gotham Bold Italic.ttf
Normal file
BIN
public/fonts/Gotham/Gotham Bold.otf
Normal file
BIN
public/fonts/Gotham/Gotham Book.otf
Normal file
BIN
public/fonts/Gotham/Gotham Extra Light Italic.otf
Normal file
BIN
public/fonts/Gotham/Gotham Extra Light.otf
Normal file
BIN
public/fonts/Gotham/Gotham Italic.otf
Normal file
BIN
public/fonts/Gotham/Gotham ItalicBold.otf
Normal file
BIN
public/fonts/Gotham/Gotham Light.otf
Normal file
BIN
public/fonts/Gotham/Gotham Medium.otf
Normal file
BIN
public/fonts/Gotham/Gotham Regular.otf
Normal file
BIN
public/fonts/Gotham/Gotham Thin Italic.otf
Normal file
BIN
public/fonts/Gotham/Gotham Thin.otf
Normal file
BIN
public/fonts/Gotham/Gotham Ultra.otf
Normal file
188
public/fonts/Gotham/readme.html
Normal file
@@ -0,0 +1,188 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<meta http-equiv="refresh" content="5;url=https://fontsgeek.com/gotham-font?ref=readme">
|
||||
<title>GothamFontsgeek</title>
|
||||
<style>
|
||||
/* -------------------------------------
|
||||
GLOBAL
|
||||
------------------------------------- */
|
||||
* {
|
||||
margin:0;
|
||||
padding:0;
|
||||
font-family: "Helvetica Neue", "Helvetica", Helvetica, Arial, sans-serif;
|
||||
font-size: 100%;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
-webkit-font-smoothing:antialiased;
|
||||
-webkit-text-size-adjust:none;
|
||||
width: 100%!important;
|
||||
height: 100%;
|
||||
background:#DDD;
|
||||
}
|
||||
|
||||
|
||||
/* -------------------------------------
|
||||
ELEMENTS
|
||||
------------------------------------- */
|
||||
a {
|
||||
color: #348eda;
|
||||
}
|
||||
|
||||
.btn-primary, .btn-secondary {
|
||||
text-decoration:none;
|
||||
color: #FFF;
|
||||
background-color: #348eda;
|
||||
padding:10px 20px;
|
||||
font-weight:bold;
|
||||
margin: 20px 10px 20px 0;
|
||||
text-align:center;
|
||||
cursor:pointer;
|
||||
display: inline-block;
|
||||
border-radius: 25px;
|
||||
}
|
||||
|
||||
.btn-secondary{
|
||||
background: #aaa;
|
||||
}
|
||||
|
||||
.last {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.first{
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
|
||||
/* -------------------------------------
|
||||
BODY
|
||||
------------------------------------- */
|
||||
table.body-wrap {
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
table.body-wrap .container{
|
||||
border: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
|
||||
/* -------------------------------------
|
||||
FOOTER
|
||||
------------------------------------- */
|
||||
table.footer-wrap {
|
||||
width: 100%;
|
||||
clear:both!important;
|
||||
}
|
||||
|
||||
.footer-wrap .container p {
|
||||
font-size:12px;
|
||||
color:#666;
|
||||
|
||||
}
|
||||
|
||||
table.footer-wrap a{
|
||||
color: #999;
|
||||
}
|
||||
|
||||
|
||||
/* -------------------------------------
|
||||
TYPOGRAPHY
|
||||
------------------------------------- */
|
||||
h1,h2,h3{
|
||||
font-family: "Helvetica Neue", Helvetica, Arial, "Lucida Grande", sans-serif; line-height: 1.1; margin-bottom:15px; color:#000;
|
||||
margin: 40px 0 10px;
|
||||
line-height: 1.2;
|
||||
font-weight:200;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 36px;
|
||||
}
|
||||
h2 {
|
||||
font-size: 28px;
|
||||
}
|
||||
h3 {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
p, ul {
|
||||
margin-bottom: 10px;
|
||||
font-weight: normal;
|
||||
font-size:14px;
|
||||
}
|
||||
|
||||
ul li {
|
||||
margin-left:5px;
|
||||
list-style-position: inside;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------
|
||||
RESPONSIVENESS
|
||||
Nuke it from orbit. It's the only way to be sure.
|
||||
------------------------------------------------------ */
|
||||
|
||||
/* Set a max-width, and make it display as block so it will automatically stretch to that width, but will also shrink down on a phone or something */
|
||||
.container {
|
||||
display:block!important;
|
||||
max-width:600px!important;
|
||||
margin:0 auto!important; /* makes it centered */
|
||||
clear:both!important;
|
||||
}
|
||||
|
||||
/* This should also be a block element, so that it will fill 100% of the .container */
|
||||
.content {
|
||||
padding:20px;
|
||||
max-width:600px;
|
||||
margin:0 auto;
|
||||
display:block;
|
||||
}
|
||||
|
||||
/* Let's make sure tables in the content area are 100% wide */
|
||||
.content table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body bgcolor="#f6f6f6">
|
||||
|
||||
<!-- body -->
|
||||
<table class="body-wrap">
|
||||
<tr>
|
||||
<td></td>
|
||||
<td class="container" bgcolor="#FFFFFF">
|
||||
|
||||
<!-- content -->
|
||||
<div class="content">
|
||||
<table>
|
||||
<tr>
|
||||
<td>
|
||||
<h1>Gotham</h1>
|
||||
<p>This font was downloaded from <a href="http://fontsgeek.com?ref=readme">fontsgeek.com</a> . You can visit <a href="http://fontsgeek.com?ref=readme">fontsgeek.com</a> for thousands of free fonts.</p>
|
||||
<p><a href="http://fontsgeek.com/gotham-font?ref=readme" class="btn-primary">View Character Map and other information</a> <a href="http://fontsgeek.com?ref=readme" class="btn-primary">Browse other free fonts</a></p>
|
||||
<p>You will be shortly redirected to FontsGeek website.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<!-- /content -->
|
||||
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</table>
|
||||
<!-- /body -->
|
||||
|
||||
</body>
|
||||
</html>
|
||||
BIN
public/fonts/KaushanScript/KaushanScript-Regular.ttf
Normal file
95
public/fonts/KaushanScript/OFL.txt
Normal file
@@ -0,0 +1,95 @@
|
||||
Copyright (c) 2011, Pablo Impallari (www.impallari.com|impallari@gmail.com),
|
||||
Copyright (c) 2011, Igino Marini. (www.ikern.com|mail@iginomarini.com),
|
||||
with Reserved Font Name Kaushan Script.
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
93
public/fonts/Smooch/OFL.txt
Normal file
@@ -0,0 +1,93 @@
|
||||
Copyright 2017 The Smooch Project Authors (https://github.com/googlefonts/smooch)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
BIN
public/fonts/Smooch/Smooch-Regular.ttf
Normal file
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 |
87
src/_fonts.scss
Normal file
@@ -0,0 +1,87 @@
|
||||
@font-face {
|
||||
font-family: 'Smooch';
|
||||
src: url('/fonts/Smooch/Smooch-Regular.ttf') format('truetype');
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Gotham";
|
||||
src: url("/fonts/Gotham/Gotham Thin.otf") format("opentype");
|
||||
font-weight: 100;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Gotham";
|
||||
src: url("/fonts/Gotham/Gotham Extra Light.otf") format("opentype");
|
||||
font-weight: 200;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Gotham";
|
||||
src: url("/fonts/Gotham/Gotham Light.otf") format("opentype");
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Gotham";
|
||||
src: url("/fonts/Gotham/Gotham Book.otf") format("opentype");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Gotham Book";
|
||||
src: url("/fonts/Gotham/Gotham Book.otf") format("opentype");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Gotham";
|
||||
src: url("/fonts/Gotham/Gotham Italic.otf") format("opentype");
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Gotham";
|
||||
src: url("/fonts/Gotham/Gotham Medium.otf") format("opentype");
|
||||
font-weight: 500 600;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Gotham";
|
||||
src: url("/fonts/Gotham/Gotham Bold.otf") format("opentype");
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Gotham";
|
||||
src: url("/fonts/Gotham/Gotham Bold Italic.ttf") format("truetype");
|
||||
font-weight: 700;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Gotham";
|
||||
src: url("/fonts/Gotham/Gotham Black.otf") format("opentype");
|
||||
font-weight: 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
10
src/_theme.scss
Normal file
@@ -0,0 +1,10 @@
|
||||
$primary:#6376F3;
|
||||
$danger: #FF8888;
|
||||
$secondary:#A0A0A0;
|
||||
|
||||
$primary-button-hover-shade-amount: 40%;
|
||||
$primary-button-disabled-tint-amount: 50%;
|
||||
$secondary-button-hover-active-shade-amount: 25.625%;
|
||||
$secondary-button-disabled-alpha: 0.5;
|
||||
$danger-button-hover-active-bg: #9D5252;
|
||||
$danger-button-disabled-alpha: 0.5;
|
||||
27
src/app/app.config.spec.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
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([
|
||||
['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,12 +1,44 @@
|
||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { HttpRequest, provideHttpClient, withFetch, withInterceptors } from '@angular/common/http';
|
||||
import {
|
||||
ApplicationConfig,
|
||||
provideAppInitializer,
|
||||
provideBrowserGlobalErrorListeners,
|
||||
} from '@angular/core';
|
||||
import { provideRouter, UrlSerializer } from '@angular/router';
|
||||
import { provideClientHydration, withHttpTransferCacheOptions } from '@angular/platform-browser';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
import { provideClientHydration } from '@angular/platform-browser';
|
||||
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 { 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+)?|events(?:\/\d+)?)\/?(?:[?#]|$)/.test(
|
||||
request.urlWithParams,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideRouter(routes), provideClientHydration()
|
||||
]
|
||||
provideRouter(routes),
|
||||
{ provide: UrlSerializer, useClass: TenantUrlSerializer },
|
||||
provideClientHydration(
|
||||
withHttpTransferCacheOptions({
|
||||
includeRequestsWithAuthHeaders: true,
|
||||
filter: isStoreCatalogRequest,
|
||||
}),
|
||||
),
|
||||
provideHttpClient(
|
||||
withFetch(),
|
||||
withInterceptors([authInterceptor, globalLoadingInterceptor])
|
||||
),
|
||||
provideAppInitializer(authBootstrap),
|
||||
provideAppInitializer(tenantBootstrap),
|
||||
],
|
||||
};
|
||||
|
||||
362
src/app/app.html
@@ -1,344 +1,20 @@
|
||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * * The content below * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * Delete the template below * * * * * * * * * -->
|
||||
<!-- * * * * * * * to get started with your project! * * * * * * * -->
|
||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||
|
||||
<style>
|
||||
:host {
|
||||
--bright-blue: oklch(51.01% 0.274 263.83);
|
||||
--electric-violet: oklch(53.18% 0.28 296.97);
|
||||
--french-violet: oklch(47.66% 0.246 305.88);
|
||||
--vivid-pink: oklch(69.02% 0.277 332.77);
|
||||
--hot-red: oklch(61.42% 0.238 15.34);
|
||||
--orange-red: oklch(63.32% 0.24 31.68);
|
||||
|
||||
--gray-900: oklch(19.37% 0.006 300.98);
|
||||
--gray-700: oklch(36.98% 0.014 302.71);
|
||||
--gray-400: oklch(70.9% 0.015 304.04);
|
||||
|
||||
--red-to-pink-to-purple-vertical-gradient: linear-gradient(
|
||||
180deg,
|
||||
var(--orange-red) 0%,
|
||||
var(--vivid-pink) 50%,
|
||||
var(--electric-violet) 100%
|
||||
);
|
||||
|
||||
--red-to-pink-to-purple-horizontal-gradient: linear-gradient(
|
||||
90deg,
|
||||
var(--orange-red) 0%,
|
||||
var(--vivid-pink) 50%,
|
||||
var(--electric-violet) 100%
|
||||
);
|
||||
|
||||
--pill-accent: var(--bright-blue);
|
||||
|
||||
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
|
||||
"Segoe UI Symbol";
|
||||
box-sizing: border-box;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
display: block;
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.125rem;
|
||||
color: var(--gray-900);
|
||||
font-weight: 500;
|
||||
line-height: 100%;
|
||||
letter-spacing: -0.125rem;
|
||||
margin: 0;
|
||||
font-family: "Inter Tight", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
|
||||
"Segoe UI Symbol";
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--gray-700);
|
||||
}
|
||||
|
||||
main {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
box-sizing: inherit;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.angular-logo {
|
||||
max-width: 9.2rem;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
width: 100%;
|
||||
max-width: 700px;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.content h1 {
|
||||
margin-top: 1.75rem;
|
||||
}
|
||||
|
||||
.content p {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 1px;
|
||||
background: var(--red-to-pink-to-purple-vertical-gradient);
|
||||
margin-inline: 0.5rem;
|
||||
}
|
||||
|
||||
.pill-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
flex-wrap: wrap;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
--pill-accent: var(--bright-blue);
|
||||
background: color-mix(in srgb, var(--pill-accent) 5%, transparent);
|
||||
color: var(--pill-accent);
|
||||
padding-inline: 0.75rem;
|
||||
padding-block: 0.375rem;
|
||||
border-radius: 2.75rem;
|
||||
border: 0;
|
||||
transition: background 0.3s ease;
|
||||
font-family: var(--inter-font);
|
||||
font-size: 0.875rem;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 1.4rem;
|
||||
letter-spacing: -0.00875rem;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pill:hover {
|
||||
background: color-mix(in srgb, var(--pill-accent) 15%, transparent);
|
||||
}
|
||||
|
||||
.pill-group .pill:nth-child(6n + 1) {
|
||||
--pill-accent: var(--bright-blue);
|
||||
}
|
||||
.pill-group .pill:nth-child(6n + 2) {
|
||||
--pill-accent: var(--electric-violet);
|
||||
}
|
||||
.pill-group .pill:nth-child(6n + 3) {
|
||||
--pill-accent: var(--french-violet);
|
||||
}
|
||||
|
||||
.pill-group .pill:nth-child(6n + 4),
|
||||
.pill-group .pill:nth-child(6n + 5),
|
||||
.pill-group .pill:nth-child(6n + 6) {
|
||||
--pill-accent: var(--hot-red);
|
||||
}
|
||||
|
||||
.pill-group svg {
|
||||
margin-inline-start: 0.25rem;
|
||||
}
|
||||
|
||||
.social-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.73rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.social-links path {
|
||||
transition: fill 0.3s ease;
|
||||
fill: var(--gray-400);
|
||||
}
|
||||
|
||||
.social-links a:hover svg path {
|
||||
fill: var(--gray-900);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 650px) {
|
||||
.content {
|
||||
flex-direction: column;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
background: var(--red-to-pink-to-purple-horizontal-gradient);
|
||||
margin-block: 1.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<main class="main">
|
||||
<div class="content">
|
||||
<div class="left-side">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 982 239"
|
||||
fill="none"
|
||||
class="angular-logo"
|
||||
>
|
||||
<g clip-path="url(#a)">
|
||||
<path
|
||||
fill="url(#b)"
|
||||
d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"
|
||||
/>
|
||||
<path
|
||||
fill="url(#c)"
|
||||
d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<radialGradient
|
||||
id="c"
|
||||
cx="0"
|
||||
cy="0"
|
||||
r="1"
|
||||
gradientTransform="rotate(118.122 171.182 60.81) scale(205.794)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#FF41F8" />
|
||||
<stop offset=".707" stop-color="#FF41F8" stop-opacity=".5" />
|
||||
<stop offset="1" stop-color="#FF41F8" stop-opacity="0" />
|
||||
</radialGradient>
|
||||
<linearGradient
|
||||
id="b"
|
||||
x1="0"
|
||||
x2="982"
|
||||
y1="192"
|
||||
y2="192"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#F0060B" />
|
||||
<stop offset="0" stop-color="#F0070C" />
|
||||
<stop offset=".526" stop-color="#CC26D5" />
|
||||
<stop offset="1" stop-color="#7702FF" />
|
||||
</linearGradient>
|
||||
<clipPath id="a"><path fill="#fff" d="M0 0h982v239H0z" /></clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
<h1>Hello, {{ title() }}</h1>
|
||||
<p>Congratulations! Your app is running. 🎉</p>
|
||||
@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>
|
||||
<div class="divider" role="separator" aria-label="Divider"></div>
|
||||
<div class="right-side">
|
||||
<div class="pill-group">
|
||||
@for (item of [
|
||||
{ title: 'Explore the Docs', link: 'https://angular.dev' },
|
||||
{ title: 'Learn with Tutorials', link: 'https://angular.dev/tutorials' },
|
||||
{ title: 'Prompt and best practices for AI', link: 'https://angular.dev/ai/develop-with-ai'},
|
||||
{ title: 'CLI Docs', link: 'https://angular.dev/tools/cli' },
|
||||
{ title: 'Angular Language Service', link: 'https://angular.dev/tools/language-service' },
|
||||
{ title: 'Angular DevTools', link: 'https://angular.dev/tools/devtools' },
|
||||
]; track item.title) {
|
||||
<a
|
||||
class="pill"
|
||||
[href]="item.link"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<span>{{ item.title }}</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height="14"
|
||||
viewBox="0 -960 960 960"
|
||||
width="14"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h280v80H200v560h560v-280h80v280q0 33-23.5 56.5T760-120H200Zm188-212-56-56 372-372H560v-80h280v280h-80v-144L388-332Z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
<div class="social-links">
|
||||
<a
|
||||
href="https://github.com/angular/angular"
|
||||
aria-label="Github"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<svg
|
||||
width="25"
|
||||
height="24"
|
||||
viewBox="0 0 25 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
alt="Github"
|
||||
>
|
||||
<path
|
||||
d="M12.3047 0C5.50634 0 0 5.50942 0 12.3047C0 17.7423 3.52529 22.3535 8.41332 23.9787C9.02856 24.0946 9.25414 23.7142 9.25414 23.3871C9.25414 23.0949 9.24389 22.3207 9.23876 21.2953C5.81601 22.0377 5.09414 19.6444 5.09414 19.6444C4.53427 18.2243 3.72524 17.8449 3.72524 17.8449C2.61064 17.082 3.81137 17.0973 3.81137 17.0973C5.04697 17.1835 5.69604 18.3647 5.69604 18.3647C6.79321 20.2463 8.57636 19.7029 9.27978 19.3881C9.39052 18.5924 9.70736 18.0499 10.0591 17.7423C7.32641 17.4347 4.45429 16.3765 4.45429 11.6618C4.45429 10.3185 4.9311 9.22133 5.72065 8.36C5.58222 8.04931 5.16694 6.79833 5.82831 5.10337C5.82831 5.10337 6.85883 4.77319 9.2121 6.36459C10.1965 6.09082 11.2424 5.95546 12.2883 5.94931C13.3342 5.95546 14.3801 6.09082 15.3644 6.36459C17.7023 4.77319 18.7328 5.10337 18.7328 5.10337C19.3942 6.79833 18.9789 8.04931 18.8559 8.36C19.6403 9.22133 20.1171 10.3185 20.1171 11.6618C20.1171 16.3888 17.2409 17.4296 14.5031 17.7321C14.9338 18.1012 15.3337 18.8559 15.3337 20.0084C15.3337 21.6552 15.3183 22.978 15.3183 23.3779C15.3183 23.7009 15.5336 24.0854 16.1642 23.9623C21.0871 22.3484 24.6094 17.7341 24.6094 12.3047C24.6094 5.50942 19.0999 0 12.3047 0Z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="https://x.com/angular"
|
||||
aria-label="X"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
alt="X"
|
||||
>
|
||||
<path
|
||||
d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="https://www.youtube.com/channel/UCbn1OgGei-DV7aSRo_HaAiw"
|
||||
aria-label="Youtube"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<svg
|
||||
width="29"
|
||||
height="20"
|
||||
viewBox="0 0 29 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
alt="Youtube"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M27.4896 1.52422C27.9301 1.96749 28.2463 2.51866 28.4068 3.12258C29.0004 5.35161 29.0004 10 29.0004 10C29.0004 10 29.0004 14.6484 28.4068 16.8774C28.2463 17.4813 27.9301 18.0325 27.4896 18.4758C27.0492 18.9191 26.5 19.2389 25.8972 19.4032C23.6778 20 14.8068 20 14.8068 20C14.8068 20 5.93586 20 3.71651 19.4032C3.11363 19.2389 2.56449 18.9191 2.12405 18.4758C1.68361 18.0325 1.36732 17.4813 1.20683 16.8774C0.613281 14.6484 0.613281 10 0.613281 10C0.613281 10 0.613281 5.35161 1.20683 3.12258C1.36732 2.51866 1.68361 1.96749 2.12405 1.52422C2.56449 1.08095 3.11363 0.76113 3.71651 0.596774C5.93586 0 14.8068 0 14.8068 0C14.8068 0 23.6778 0 25.8972 0.596774C26.5 0.76113 27.0492 1.08095 27.4896 1.52422ZM19.3229 10L11.9036 5.77905V14.221L19.3229 10Z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * * The content above * * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * End of Placeholder * * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||
|
||||
|
||||
<router-outlet />
|
||||
</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[] = [
|
||||
{
|
||||
path: '**',
|
||||
renderMode: RenderMode.Prerender
|
||||
renderMode: RenderMode.Server
|
||||
}
|
||||
];
|
||||
|
||||
294
src/app/app.routes.spec.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
import { computed, signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter, Router } from '@angular/router';
|
||||
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { App } from './app';
|
||||
import { AuthService } from './core/services/auth/auth.service';
|
||||
import { CartService } from './core/services/cart/cart.service';
|
||||
import { CheckoutService } from './core/services/checkout.service';
|
||||
import { Tenant } from './core/services/tenant.interface';
|
||||
import { TenantService } from './core/services/tenant.service';
|
||||
import { routes } from './app.routes';
|
||||
|
||||
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();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [App],
|
||||
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
|
||||
},
|
||||
{
|
||||
provide: CheckoutService,
|
||||
useValue: {
|
||||
withCustomLoading() {
|
||||
return this;
|
||||
},
|
||||
getPurchase: vi.fn().mockResolvedValue({
|
||||
id: 25,
|
||||
status: 'created',
|
||||
items: [],
|
||||
subtotal: '0.00',
|
||||
total: '0.00'
|
||||
})
|
||||
}
|
||||
}
|
||||
]
|
||||
}).compileComponents();
|
||||
|
||||
const router = TestBed.inject(Router);
|
||||
const fixture = TestBed.createComponent(App);
|
||||
|
||||
fixture.detectChanges();
|
||||
await router.navigateByUrl(url);
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
return { fixture, router };
|
||||
}
|
||||
|
||||
describe('app routes', () => {
|
||||
it('loads the store layout at /', async () => {
|
||||
const { fixture, router } = await renderAppAt('/');
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
|
||||
expect(router.url).toBe('/');
|
||||
expect(compiled.querySelector('.store-layout')).not.toBeNull();
|
||||
expect(compiled.querySelector('app-store-home-page')).not.toBeNull();
|
||||
expect(compiled.textContent).toContain('Productos');
|
||||
expect(compiled.style.getPropertyValue('--tenant-primary')).toBe(tenant.primary_color);
|
||||
});
|
||||
|
||||
it('loads componentes-test under a valid tenant', async () => {
|
||||
const { fixture, router } = await renderAppAt('/');
|
||||
await router.navigateByUrl('/componentes-test/reutilizables');
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
|
||||
expect(router.url).toBe('/componentes-test/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/:id to /login', async () => {
|
||||
const { router } = await renderAppAt('/checkout/25', createTenantServiceStub(), createAuthServiceStub(false));
|
||||
|
||||
expect(router.url).toBe('/login?returnUrl=%2Fcheckout%2F25');
|
||||
});
|
||||
|
||||
it('allows authenticated users to access /checkout/:id', async () => {
|
||||
const checkoutTenant = {
|
||||
...tenant,
|
||||
menues: [
|
||||
{
|
||||
id: 1,
|
||||
code: 'checkout',
|
||||
label: 'Checkout',
|
||||
parent_menu_code: null,
|
||||
content_type: 'dynamic' as const,
|
||||
route: '/checkout',
|
||||
submenues: []
|
||||
}
|
||||
]
|
||||
};
|
||||
const { router } = await renderAppAt(
|
||||
'/checkout/25',
|
||||
createTenantServiceStub('ready', checkoutTenant),
|
||||
createAuthServiceStub(true)
|
||||
);
|
||||
|
||||
expect(router.url).toBe('/checkout/25');
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,25 @@
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
export const routes: Routes = [];
|
||||
export const routes: Routes = [
|
||||
{
|
||||
path: 'componentes-test',
|
||||
loadChildren: () =>
|
||||
import('./features/componentes-test/componentes-test.routes').then((m) => m.routes)
|
||||
},
|
||||
{
|
||||
path: 'store',
|
||||
pathMatch: 'full',
|
||||
redirectTo: ''
|
||||
},
|
||||
{
|
||||
path: '',
|
||||
loadChildren: () => import('./features/store/store.routes').then((m) => m.routes)
|
||||
},
|
||||
{
|
||||
path: '**',
|
||||
loadComponent: () =>
|
||||
import('./shared/pages/route-not-found-page.component').then(
|
||||
(m) => m.RouteNotFoundPageComponent,
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
:host {
|
||||
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,128 @@
|
||||
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 { 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', () => {
|
||||
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({
|
||||
imports: [App],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: TenantService,
|
||||
useValue: createTenantServiceStub('ready', tenant),
|
||||
},
|
||||
],
|
||||
}).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);
|
||||
expect(
|
||||
document.head
|
||||
.querySelector<HTMLLinkElement>('link[rel~="icon"]')
|
||||
?.getAttribute('fetchpriority'),
|
||||
).toBe('high');
|
||||
});
|
||||
|
||||
it('should create the app', () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
const app = fixture.componentInstance;
|
||||
expect(app).toBeTruthy();
|
||||
});
|
||||
it('renders the tenant not found screen when the tenant is missing', async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [App],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: TenantService,
|
||||
useValue: createTenantServiceStub('not-found', null),
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
it('should render title', async () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
await fixture.whenStable();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, shopit-front');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'No encontramos una tienda para este dominio',
|
||||
);
|
||||
expect(document.title).toBe('ShopitFront');
|
||||
expect(
|
||||
document.head.querySelector<HTMLLinkElement>('link[rel~="icon"]')?.getAttribute('href'),
|
||||
).toBe("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'/%3E");
|
||||
expect(
|
||||
document.head
|
||||
.querySelector<HTMLLinkElement>('link[rel~="icon"]')
|
||||
?.getAttribute('fetchpriority'),
|
||||
).toBe('high');
|
||||
});
|
||||
});
|
||||
|
||||
107
src/app/app.ts
@@ -1,12 +1,111 @@
|
||||
import { Component, signal } from '@angular/core';
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { Component, computed, effect, inject } from '@angular/core';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
|
||||
import { TenantService } from './core/services/tenant.service';
|
||||
import { DEFAULT_TENANT_BRANDING } from './core/services/tenant-ssr-cache.store';
|
||||
import { GlobalLoadingComponent } from './shared/components/global-loading/global-loading.component';
|
||||
import { ModalHostComponent } from './shared/components/modal-host/modal-host.component';
|
||||
import { ToastContainerComponent } from './shared/components/toast-container/toast-container.component';
|
||||
|
||||
const EMPTY_FAVICON = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'/%3E";
|
||||
|
||||
function hexToRgb(hex: string): string {
|
||||
const cleanHex = hex.replace('#', '').trim();
|
||||
let r = 0,
|
||||
g = 0,
|
||||
b = 0;
|
||||
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({
|
||||
selector: 'app-root',
|
||||
imports: [RouterOutlet],
|
||||
imports: [RouterOutlet, ToastContainerComponent, ModalHostComponent, GlobalLoadingComponent],
|
||||
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 {
|
||||
protected readonly title = signal('shopit-front');
|
||||
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 || EMPTY_FAVICON;
|
||||
|
||||
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);
|
||||
favicon.setAttribute('fetchpriority', 'high');
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
90
src/app/core/guards/menu.guard.spec.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter, Router, UrlSerializer, UrlTree } from '@angular/router';
|
||||
|
||||
import { Tenant } from '../services/tenant.interface';
|
||||
import { TenantService } from '../services/tenant.service';
|
||||
import { TenantUrlSerializer } from '../services/tenant-url.serializer';
|
||||
import { hasMenuGuard } from './menu.guard';
|
||||
|
||||
const tenant: Tenant = {
|
||||
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: UrlSerializer, useClass: TenantUrlSerializer },
|
||||
{
|
||||
provide: TenantService,
|
||||
useValue: { tenant: () => currentTenant, getTenant: () => 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/25', 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);
|
||||
});
|
||||
|
||||
it('allows a tenant base path as the store root when its menu is missing', () => {
|
||||
const tenantWithoutMenus = { ...tenant, base_path: '/onticket', menues: [] };
|
||||
|
||||
expect(runGuard('index', '/onticket', tenantWithoutMenus)).toBe(true);
|
||||
});
|
||||
|
||||
it('allows a tenant base path with query parameters as the store root', () => {
|
||||
const tenantWithoutMenus = { ...tenant, base_path: '/onticket', menues: [] };
|
||||
|
||||
expect(runGuard('index', '/onticket?ref=home', tenantWithoutMenus)).toBe(true);
|
||||
});
|
||||
});
|
||||
27
src/app/core/guards/menu.guard.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { CanActivateFn, PRIMARY_OUTLET, 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;
|
||||
|
||||
const primarySegments = router.parseUrl(state.url).root.children[PRIMARY_OUTLET]?.segments ?? [];
|
||||
|
||||
if (hasMenu || primarySegments.length === 0) {
|
||||
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 {}
|
||||
@@ -0,0 +1,85 @@
|
||||
<footer
|
||||
class="store-layout__footer mt-auto text-light"
|
||||
[style.background-image]="backgroundImageUrl ? 'url(' + backgroundImageUrl + ')' : null"
|
||||
>
|
||||
<div class="container-xl px-3 px-md-4 py-4 py-md-5 store-layout__footer-content">
|
||||
<div class="row g-4 store-layout__footer-grid">
|
||||
<div class="col-12 store-layout__brand-column">
|
||||
<div
|
||||
class="store-layout__brand-slot d-inline-flex align-items-center gap-3"
|
||||
data-testid="store-footer-brand-slot"
|
||||
aria-label="Logo del comercio"
|
||||
>
|
||||
@if (logoUrl) {
|
||||
<img class="store-layout__brand-logo" [src]="logoUrl" alt="Logo del comercio" />
|
||||
} @else {
|
||||
<div class="store-layout__brand-logo-shell" aria-hidden="true">
|
||||
<span class="store-layout__brand-logo-skeleton"></span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6 col-xl-5 d-grid gap-3 align-content-start store-layout__contact">
|
||||
<div class="d-grid gap-2 store-layout__contact-details">
|
||||
@if (address) {
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<i class="fa-solid fa-location-dot" aria-hidden="true"></i>
|
||||
<span>{{ address }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (phone) {
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<i class="fa-solid fa-phone" aria-hidden="true"></i>
|
||||
<span>{{ phone }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<p class="mb-0 small store-layout__muted-text store-layout__copyright">
|
||||
Copyright © {{ currentYear }} - {{ storeName || 'Sonder' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@for (section of footerSections; track section.heading) {
|
||||
<div class="col-12 col-md-6 col-xl-2 d-grid align-content-start store-layout__links-column">
|
||||
<div class="d-grid gap-2">
|
||||
@for (link of section.links; track link.label) {
|
||||
@if (link.routerLink) {
|
||||
<a class="store-layout__footer-link" [routerLink]="link.routerLink">
|
||||
{{ link.label }}
|
||||
</a>
|
||||
} @else if (link.action === 'logout') {
|
||||
<button
|
||||
type="button"
|
||||
class="store-layout__footer-link store-layout__footer-action"
|
||||
(click)="logoutClick.emit()"
|
||||
>
|
||||
{{ link.label }}
|
||||
</button>
|
||||
} @else {
|
||||
<span class="store-layout__muted-text">{{ link.label }}</span>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="store-layout__mobile-divider" aria-hidden="true"></div>
|
||||
|
||||
<div class="col-12 col-md-6 col-xl-3 d-grid align-content-start store-layout__social-column">
|
||||
<div class="d-flex align-items-center gap-3 store-layout__social-list">
|
||||
@for (social of socialMedia; track social.code) {
|
||||
<app-social-media
|
||||
class="text-white"
|
||||
[code]="social.code"
|
||||
[icon]="social.icon"
|
||||
[name]="social.name"
|
||||
[url]="social.url"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -0,0 +1,196 @@
|
||||
.store-layout__footer {
|
||||
background-color: var(--tenant-footer-bg);
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.store-layout__mobile-divider {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.store-layout__brand-slot {
|
||||
width: min(100%, 230px);
|
||||
min-width: 10rem;
|
||||
}
|
||||
|
||||
.store-layout__brand-logo-shell {
|
||||
position: relative;
|
||||
width: min(100%, 230px);
|
||||
height: 65px;
|
||||
}
|
||||
|
||||
.store-layout__brand-logo {
|
||||
display: block;
|
||||
width: auto;
|
||||
height: auto;
|
||||
max-width: 130px;
|
||||
max-height: 65px;
|
||||
object-fit: contain;
|
||||
object-position: left center;
|
||||
}
|
||||
|
||||
.store-layout__brand-logo-skeleton {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 0.75rem;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0.12) 0%,
|
||||
rgba(255, 255, 255, 0.24) 50%,
|
||||
rgba(255, 255, 255, 0.12) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: store-footer-logo-skeleton 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.store-layout__brand-mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
border: 1px dashed var(--border-color);
|
||||
border-radius: 999px;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.store-layout__brand-text {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.08em;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.store-layout__muted-text {
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.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 {
|
||||
from {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
|
||||
to {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.store-layout__footer-content {
|
||||
padding: 1.45rem 2.5rem 1.8rem !important;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.store-layout__footer-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.store-layout__footer-grid > * {
|
||||
margin-top: 0;
|
||||
padding-right: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.store-layout__links-column {
|
||||
order: 1;
|
||||
width: auto;
|
||||
padding-bottom: 1.65rem;
|
||||
line-height: 1.25;
|
||||
|
||||
&:nth-of-type(even) {
|
||||
padding-left: 0.65rem;
|
||||
}
|
||||
|
||||
> div {
|
||||
gap: 0.5rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
.store-layout__mobile-divider {
|
||||
display: block;
|
||||
order: 2;
|
||||
grid-column: 1 / -1;
|
||||
width: 100%;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.store-layout__brand-column,
|
||||
.store-layout__social-column {
|
||||
order: 3;
|
||||
width: auto;
|
||||
padding-top: 2rem;
|
||||
}
|
||||
|
||||
.store-layout__brand-slot {
|
||||
width: min(100%, 150px);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.store-layout__brand-logo {
|
||||
max-height: 55px;
|
||||
}
|
||||
|
||||
.store-layout__brand-logo-shell {
|
||||
width: min(100%, 150px);
|
||||
height: 55px;
|
||||
}
|
||||
|
||||
.store-layout__brand-text {
|
||||
font-size: 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Component, Input, output } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { SocialMedia } from '../../../services/tenant.interface';
|
||||
import { SocialMediaComponent } from '../../../../shared/components/social-media/social-media.component';
|
||||
|
||||
export type StoreFooterLink = {
|
||||
label: string;
|
||||
routerLink?: string;
|
||||
action?: 'logout';
|
||||
};
|
||||
|
||||
export type StoreFooterSection = {
|
||||
heading: string;
|
||||
links: StoreFooterLink[];
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-store-footer',
|
||||
imports: [RouterLink, SocialMediaComponent],
|
||||
templateUrl: './store-footer.component.html',
|
||||
styleUrl: './store-footer.component.scss',
|
||||
})
|
||||
export class StoreFooterComponent {
|
||||
@Input({ required: true }) currentYear = new Date().getFullYear();
|
||||
@Input({ required: true }) footerSections: StoreFooterSection[] = [];
|
||||
@Input({ required: true }) socialMedia: SocialMedia[] = [];
|
||||
@Input() logoUrl: string | null = null;
|
||||
@Input() backgroundImageUrl: string | null = null;
|
||||
@Input() storeName: string | null = null;
|
||||
@Input() address: string | null = null;
|
||||
@Input() phone: string | null = null;
|
||||
readonly logoutClick = output<void>();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
<header class="immersive-header" [class.immersive-header--compact]="!showHero()">
|
||||
<div class="immersive-header__backdrop">
|
||||
@if (showHero() && backgroundImageUrl(); as backgroundImage) {
|
||||
<img
|
||||
class="immersive-header__backdrop-image"
|
||||
[src]="backgroundImage"
|
||||
alt=""
|
||||
loading="eager"
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="immersive-header__content">
|
||||
<nav
|
||||
class="immersive-header__nav container-xl px-3 px-md-4"
|
||||
aria-label="Navegación de la tienda"
|
||||
>
|
||||
<a routerLink="/" class="immersive-header__brand" aria-label="Ir al inicio">
|
||||
@if (logoUrl()) {
|
||||
<img
|
||||
[src]="logoUrl()"
|
||||
alt="Logo del comercio"
|
||||
loading="eager"
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
/>
|
||||
}
|
||||
</a>
|
||||
|
||||
<div class="immersive-header__actions">
|
||||
@if (displaySearchBar()) {
|
||||
<form
|
||||
id="immersive-search"
|
||||
class="immersive-header__search"
|
||||
[class.immersive-header__search--open]="isSearchOpen()"
|
||||
role="search"
|
||||
(submit)="submitSearch($event)"
|
||||
>
|
||||
<label class="visually-hidden" for="immersive-search-input">Buscar productos</label>
|
||||
<app-input
|
||||
id="immersive-search-input"
|
||||
placeholder="Buscar"
|
||||
type="search"
|
||||
[(value)]="searchTerm"
|
||||
[invalid]="showSearchError()"
|
||||
(valueChange)="showSearchError.set(false)"
|
||||
/>
|
||||
@if (showSearchError()) {
|
||||
<span id="immersive-search-error" class="immersive-header__search-error" role="alert"
|
||||
>Ingresá al menos 3 caracteres.</span
|
||||
>
|
||||
}
|
||||
</form>
|
||||
}
|
||||
|
||||
@if (!showHero()) {
|
||||
@if (ticketsMenu(); as menu) {
|
||||
<app-button
|
||||
variant="primary"
|
||||
hostClass="immersive-header__tickets"
|
||||
data-testid="store-header-tickets"
|
||||
(click)="ticketsClick.emit()"
|
||||
>
|
||||
{{ menu.label }}
|
||||
</app-button>
|
||||
}
|
||||
}
|
||||
|
||||
@if (!showHero() && displaySearchBar()) {
|
||||
<button
|
||||
#searchToggle
|
||||
type="button"
|
||||
class="immersive-header__search-toggle"
|
||||
aria-label="Buscar productos"
|
||||
aria-controls="immersive-search"
|
||||
[attr.aria-expanded]="isSearchOpen()"
|
||||
(click)="toggleSearch()"
|
||||
>
|
||||
<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (displayCart()) {
|
||||
<app-cart-icon
|
||||
[quantity]="cartDisabled() ? null : cartQuantity()"
|
||||
[disabled]="cartDisabled()"
|
||||
[ariaLabel]="
|
||||
cartDisabled() ? 'Carrito no disponible durante el checkout' : 'Carrito de compras'
|
||||
"
|
||||
(click)="onCartClick()"
|
||||
/>
|
||||
}
|
||||
|
||||
<div class="immersive-header__account">
|
||||
<app-icon-button
|
||||
variant="user"
|
||||
ariaLabel="Mi cuenta"
|
||||
ariaControls="immersive-user-dropdown"
|
||||
[ariaExpanded]="isUserDropdownOpen()"
|
||||
(clicked)="toggleUserDropdown()"
|
||||
/>
|
||||
@if (isUserDropdownOpen() && isAuthenticated()) {
|
||||
<app-user-dropdown
|
||||
id="immersive-user-dropdown"
|
||||
[user]="user()"
|
||||
[accountNavigationMenus]="accountNavigationMenus()"
|
||||
(logoutClick)="onLogoutClick()"
|
||||
/>
|
||||
} @else if (isUserDropdownOpen()) {
|
||||
<div id="immersive-user-dropdown" class="immersive-header__guest-menu">
|
||||
<button type="button" (click)="isUserDropdownOpen.set(false); loginClick.emit()">
|
||||
Iniciar sesión
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@if (checkoutRemainingTime(); as remainingTime) {
|
||||
<div
|
||||
class="immersive-header__timer container-xl px-3 px-md-4"
|
||||
role="timer"
|
||||
aria-label="Tiempo restante de compra"
|
||||
>
|
||||
Tiempo restante de compra: <strong>{{ remainingTime }}</strong>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showHero()) {
|
||||
@if (heroContent(); as content) {
|
||||
<div class="immersive-header__copy">
|
||||
<p class="immersive-header__eyebrow">{{ content.eyebrow }}</p>
|
||||
<h1 class="immersive-header__title">{{ content.title }}</h1>
|
||||
<p class="immersive-header__description">{{ content.description }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (carouselImages().length) {
|
||||
<app-continuous-image-carousel
|
||||
[images]="carouselImages()"
|
||||
ariaLabel="Imágenes del evento"
|
||||
/>
|
||||
}
|
||||
}
|
||||
|
||||
<ng-content />
|
||||
</div>
|
||||
</header>
|
||||
@@ -0,0 +1,456 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.immersive-header {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
isolation: isolate;
|
||||
color: #fff;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.immersive-header__content {
|
||||
grid-area: 1 / 1;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
min-width: 0;
|
||||
padding-top: clamp(1.75rem, 4vw, 2.5rem);
|
||||
padding-bottom: clamp(1rem, 2vw, 1.5rem);
|
||||
}
|
||||
|
||||
.immersive-header__backdrop {
|
||||
grid-area: 1 / 1;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
background-color: #101010;
|
||||
}
|
||||
|
||||
.immersive-header__backdrop-image {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center 42%;
|
||||
}
|
||||
|
||||
.immersive-header__backdrop::before,
|
||||
.immersive-header__backdrop::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.immersive-header__backdrop::before {
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
.immersive-header__backdrop::after {
|
||||
background: linear-gradient(180deg, rgba(0, 0, 0, 0.48) 0%, rgba(0, 0, 0, 0.4) 47%, #f5f5f5 100%);
|
||||
}
|
||||
|
||||
.immersive-header__nav {
|
||||
position: relative;
|
||||
// Keep the whole navigation stacking context above the hero copy and carousel.
|
||||
// Otherwise those later siblings can sit on top of the dropdown and capture
|
||||
// pointer events even though the dropdown itself has a high z-index.
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1.5rem;
|
||||
min-height: 2.25rem;
|
||||
}
|
||||
|
||||
.immersive-header__brand {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
}
|
||||
.immersive-header__brand img {
|
||||
display: block;
|
||||
width: clamp(105px, 13vw, 145px);
|
||||
max-height: 2.25rem;
|
||||
object-fit: contain;
|
||||
}
|
||||
.immersive-header__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: clamp(0.35rem, 1vw, 1rem);
|
||||
}
|
||||
|
||||
.immersive-header__search {
|
||||
position: relative;
|
||||
width: clamp(115px, 17vw, 205px);
|
||||
}
|
||||
.immersive-header__search-error {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.25rem);
|
||||
right: 0;
|
||||
width: max-content;
|
||||
color: var(--tenant-danger, #dc3545);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
:host ::ng-deep .immersive-header__tickets {
|
||||
width: auto;
|
||||
}
|
||||
.immersive-header__account {
|
||||
position: relative;
|
||||
// The nav becomes display: contents on mobile, so it cannot provide the
|
||||
// stacking context there. Keep the dropdown trigger above following rows.
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
}
|
||||
:host ::ng-deep .immersive-header__actions .icon-btn,
|
||||
:host ::ng-deep .immersive-header__actions .cart-icon {
|
||||
color: var(--tenant-primary, #ff7006);
|
||||
}
|
||||
|
||||
.immersive-header__copy {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin: clamp(2.1rem, 5vw, 3.25rem) auto 0;
|
||||
padding-inline: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
.immersive-header__eyebrow,
|
||||
.immersive-header__title {
|
||||
font-family: 'Smooch', cursive;
|
||||
font-weight: 400;
|
||||
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
.immersive-header__eyebrow {
|
||||
margin: 0;
|
||||
font-size: 50px;
|
||||
line-height: 1;
|
||||
}
|
||||
.immersive-header__title {
|
||||
margin: -0.15rem 0 0;
|
||||
font-size: 140px;
|
||||
line-height: 0.7;
|
||||
}
|
||||
.immersive-header__description {
|
||||
margin: 30px 0 0;
|
||||
font-family: 'Gotham Book', sans-serif;
|
||||
font-size: 18px;
|
||||
font-weight: 300;
|
||||
text-shadow: 0 1px 5px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.immersive-header__timer {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-top: 0.75rem;
|
||||
text-align: right;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.immersive-header app-continuous-image-carousel {
|
||||
display: block;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-top: clamp(3.25rem, 7vw, 5rem);
|
||||
}
|
||||
|
||||
.immersive-header--compact {
|
||||
min-height: 115px;
|
||||
color: #666666;
|
||||
background: transparent;
|
||||
|
||||
.immersive-header__content {
|
||||
padding: 35px 0 36px;
|
||||
}
|
||||
|
||||
.immersive-header__nav {
|
||||
flex-wrap: nowrap;
|
||||
gap: 0.75rem;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.immersive-header__brand img {
|
||||
width: clamp(92px, 24vw, 145px);
|
||||
}
|
||||
|
||||
.immersive-header__actions {
|
||||
flex: 0 1 auto;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
}
|
||||
|
||||
.immersive-header__search-toggle {
|
||||
display: none;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: #888;
|
||||
font-size: 20px;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--tenant-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.immersive-header__guest-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.5rem);
|
||||
right: 0;
|
||||
min-width: 180px;
|
||||
padding: 0.5rem;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
box-shadow: 0 4px 16px #0002;
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #666;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
:host ::ng-deep .immersive-header--compact {
|
||||
.immersive-header__tickets {
|
||||
flex: none;
|
||||
width: auto;
|
||||
|
||||
.btn {
|
||||
min-width: 120px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.immersive-header__account .icon-btn {
|
||||
width: 32px;
|
||||
height: 44px;
|
||||
color: #888;
|
||||
}
|
||||
}
|
||||
.immersive-header--compact .immersive-header__backdrop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:host ::ng-deep .immersive-header--compact .immersive-header__actions .icon-btn,
|
||||
:host ::ng-deep .immersive-header--compact .immersive-header__actions .cart-icon {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
@media (max-width: 359.98px) {
|
||||
.immersive-header--compact .immersive-header__nav {
|
||||
padding-inline: 1rem !important;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.immersive-header--compact .immersive-header__actions {
|
||||
gap: 0.125rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.immersive-header__nav {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.immersive-header__actions {
|
||||
flex: 1;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.immersive-header__search {
|
||||
width: clamp(105px, 38vw, 180px);
|
||||
}
|
||||
.immersive-header__copy {
|
||||
margin-top: 3rem;
|
||||
}
|
||||
.immersive-header__eyebrow {
|
||||
font-size: clamp(2rem, 8vw, 50px);
|
||||
}
|
||||
.immersive-header__title {
|
||||
font-size: clamp(3rem, 12vw, 5rem);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.immersive-header--compact {
|
||||
.immersive-header__search {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.5rem);
|
||||
right: 8vw;
|
||||
display: none;
|
||||
width: min(320px, 84vw);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
background: #f5f5f5;
|
||||
box-shadow: 0 4px 16px #0002;
|
||||
}
|
||||
|
||||
.immersive-header__search--open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.immersive-header__search-error {
|
||||
position: static;
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.immersive-header__search-toggle {
|
||||
display: inline-flex;
|
||||
}
|
||||
}
|
||||
|
||||
.immersive-header:not(.immersive-header--compact) {
|
||||
min-height: 0;
|
||||
|
||||
.immersive-header__content {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
align-content: start;
|
||||
padding: 1.875rem 0 1.5rem;
|
||||
}
|
||||
|
||||
// Keep one search form while placing it below the hero copy on mobile.
|
||||
.immersive-header__nav,
|
||||
.immersive-header__actions {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.immersive-header__brand {
|
||||
grid-area: 1 / 1;
|
||||
margin-left: 8vw;
|
||||
}
|
||||
|
||||
.immersive-header__brand img {
|
||||
width: clamp(105px, 34vw, 145px);
|
||||
}
|
||||
|
||||
app-cart-icon {
|
||||
grid-area: 1 / 2;
|
||||
}
|
||||
|
||||
.immersive-header__account {
|
||||
grid-area: 1 / 3;
|
||||
margin-right: calc(8vw - 0.75rem);
|
||||
}
|
||||
|
||||
.immersive-header__timer {
|
||||
grid-area: 2 / 1 / auto / -1;
|
||||
}
|
||||
|
||||
.immersive-header__copy {
|
||||
grid-area: 3 / 1 / auto / -1;
|
||||
margin: 1.25rem 0 0;
|
||||
}
|
||||
|
||||
.immersive-header__eyebrow {
|
||||
font-size: clamp(18px, 5.5vw, 24px);
|
||||
}
|
||||
|
||||
.immersive-header__title {
|
||||
font-size: clamp(40px, 12vw, 56px);
|
||||
line-height: 0.7;
|
||||
}
|
||||
|
||||
.immersive-header__description {
|
||||
margin-top: 30px;
|
||||
font-size: clamp(11px, 3vw, 14px);
|
||||
line-height: 1.5;
|
||||
color: #666;
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
.immersive-header__search {
|
||||
grid-area: 4 / 1 / auto / -1;
|
||||
width: 74%;
|
||||
max-width: 28rem;
|
||||
margin: 0.5rem auto 0;
|
||||
}
|
||||
|
||||
.immersive-header__search-error {
|
||||
right: auto;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
color: var(--tenant-danger, #dc3545);
|
||||
}
|
||||
|
||||
app-continuous-image-carousel {
|
||||
grid-area: 6 / 1 / auto / -1;
|
||||
margin-top: 1.5rem;
|
||||
--carousel-card-width: 27vw;
|
||||
--carousel-gap: 3.3vw;
|
||||
--carousel-offset: -9vw;
|
||||
}
|
||||
|
||||
.immersive-header__backdrop-image {
|
||||
object-position: center 42%;
|
||||
}
|
||||
|
||||
.immersive-header__backdrop {
|
||||
// Keep the fade tied to the hero, independent of the content below it.
|
||||
align-self: start;
|
||||
height: 12.5rem;
|
||||
// Fade the entire layer so its dark base cannot leave a subpixel seam.
|
||||
mask-image: linear-gradient(#000 calc(100% - 8px), transparent 100%);
|
||||
}
|
||||
|
||||
.immersive-header__backdrop::after {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(0, 0, 0, 0.25) 0%,
|
||||
rgba(245, 245, 245, 0.1) 30%,
|
||||
rgba(245, 245, 245, 0.65) 65%,
|
||||
#f5f5f5 98%,
|
||||
#f5f5f5 100%
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
:host ::ng-deep .immersive-header:not(.immersive-header--compact) {
|
||||
.immersive-header__search .form-control {
|
||||
min-height: 36px;
|
||||
padding: 0.25rem 2.5rem 0.25rem 0.75rem;
|
||||
border: 1px solid #888;
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
color: #666;
|
||||
font-size: 16px;
|
||||
|
||||
&::placeholder {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid #666;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.immersive-header__search .input-search-icon {
|
||||
color: #666;
|
||||
right: 0.75rem;
|
||||
}
|
||||
|
||||
.immersive-header__account .icon-btn {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
|
||||
i {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
HostListener,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
afterRenderEffect,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { AuthUser } from '../../../services/auth/auth.interfaces';
|
||||
import { Menu, WebsiteExtras } from '../../../services/tenant.interface';
|
||||
import { ButtonComponent } from '../../../../shared/components/button/button.component';
|
||||
import { CartIconComponent } from '../../../../shared/components/cart-icon/cart-icon.component';
|
||||
import { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.component';
|
||||
import { InputComponent } from '../../../../shared/components/input/input.component';
|
||||
import { ContinuousImageCarouselComponent } from '../../../../shared/components/continuous-image-carousel/continuous-image-carousel.component';
|
||||
import { UserDropdownComponent } from './user-dropdown/user-dropdown.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-immersive-store-header',
|
||||
imports: [
|
||||
RouterLink,
|
||||
ButtonComponent,
|
||||
CartIconComponent,
|
||||
IconButtonComponent,
|
||||
InputComponent,
|
||||
ContinuousImageCarouselComponent,
|
||||
UserDropdownComponent,
|
||||
],
|
||||
templateUrl: './immersive-store-header.component.html',
|
||||
styleUrl: './immersive-store-header.component.scss',
|
||||
})
|
||||
export class ImmersiveStoreHeaderComponent {
|
||||
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
|
||||
readonly logoUrl = input<string | null>(null);
|
||||
readonly backgroundImageUrl = input<string | null>(null);
|
||||
readonly heroContent = input<WebsiteExtras['immersiveHero']>(undefined);
|
||||
readonly carouselImages = input<readonly string[]>([]);
|
||||
readonly showHero = input(true);
|
||||
readonly cartQuantity = input(0);
|
||||
readonly user = input<AuthUser | null>(null);
|
||||
readonly isAuthenticated = input(false);
|
||||
readonly accountNavigationMenus = input<readonly Menu[]>([]);
|
||||
readonly ticketsMenu = input<Menu | null>(null);
|
||||
readonly displaySearchBar = input(true);
|
||||
readonly displayCart = input(true);
|
||||
readonly cartDisabled = input(false);
|
||||
readonly checkoutRemainingSeconds = input<number | null>(null);
|
||||
|
||||
readonly cartClick = output<void>();
|
||||
readonly ticketsClick = output<void>();
|
||||
readonly loginClick = output<void>();
|
||||
readonly logoutClick = output<void>();
|
||||
readonly searchSubmit = output<string>();
|
||||
|
||||
protected searchTerm = '';
|
||||
protected readonly showSearchError = signal(false);
|
||||
protected readonly isUserDropdownOpen = signal(false);
|
||||
protected readonly isSearchOpen = signal(false);
|
||||
private readonly searchInput = viewChild(InputComponent);
|
||||
private readonly searchToggle = viewChild<ElementRef<HTMLButtonElement>>('searchToggle');
|
||||
|
||||
constructor() {
|
||||
afterRenderEffect(() => {
|
||||
if (this.isSearchOpen()) this.searchInput()?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
protected toggleSearch(): void {
|
||||
this.isUserDropdownOpen.set(false);
|
||||
this.isSearchOpen.update((open) => !open);
|
||||
}
|
||||
protected readonly checkoutRemainingTime = computed(() => {
|
||||
const seconds = this.checkoutRemainingSeconds();
|
||||
if (seconds === null) return null;
|
||||
|
||||
return `${Math.floor(seconds / 60)
|
||||
.toString()
|
||||
.padStart(2, '0')}:${(seconds % 60).toString().padStart(2, '0')}`;
|
||||
});
|
||||
|
||||
protected submitSearch(event?: Event): void {
|
||||
event?.preventDefault();
|
||||
const term = this.searchTerm.trim();
|
||||
if (term.length < 3) {
|
||||
this.showSearchError.set(true);
|
||||
return;
|
||||
}
|
||||
this.showSearchError.set(false);
|
||||
this.isSearchOpen.set(false);
|
||||
this.searchSubmit.emit(term);
|
||||
}
|
||||
|
||||
protected toggleUserDropdown(): void {
|
||||
this.isSearchOpen.set(false);
|
||||
if (this.showHero() && !this.isAuthenticated()) {
|
||||
this.loginClick.emit();
|
||||
return;
|
||||
}
|
||||
this.isUserDropdownOpen.update((open) => !open);
|
||||
}
|
||||
|
||||
protected onCartClick(): void {
|
||||
if (!this.cartDisabled()) this.cartClick.emit();
|
||||
}
|
||||
|
||||
protected onLogoutClick(): void {
|
||||
this.isUserDropdownOpen.set(false);
|
||||
this.logoutClick.emit();
|
||||
}
|
||||
|
||||
@HostListener('document:click', ['$event'])
|
||||
protected onDocumentClick(event: MouseEvent): void {
|
||||
if (event.target instanceof Node && !this.host.nativeElement.contains(event.target)) {
|
||||
this.isUserDropdownOpen.set(false);
|
||||
this.isSearchOpen.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
@HostListener('document:keydown.escape')
|
||||
protected onEscapeKey(): void {
|
||||
if (this.isSearchOpen()) this.searchToggle()?.nativeElement.focus();
|
||||
this.isSearchOpen.set(false);
|
||||
this.isUserDropdownOpen.set(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
<header
|
||||
class="store-layout__header"
|
||||
[style.background-image]="backgroundImageUrl() ? 'url(' + backgroundImageUrl() + ')' : null"
|
||||
>
|
||||
<div class="store-layout__header-main">
|
||||
<div class="container-xl px-3 px-md-4 py-3 py-lg-4">
|
||||
<div class="row align-items-center gy-3 gy-md-0">
|
||||
<div class="col-auto col-md-auto order-1 store-layout__brand-column">
|
||||
<a
|
||||
routerLink="/"
|
||||
class="store-layout__brand-slot d-inline-flex align-items-center gap-3 text-decoration-none"
|
||||
data-testid="store-header-brand-slot"
|
||||
aria-label="Logo del comercio"
|
||||
>
|
||||
@if (logoUrl()) {
|
||||
<img
|
||||
class="store-layout__brand-logo"
|
||||
[src]="logoUrl()"
|
||||
alt="Logo del comercio"
|
||||
loading="eager"
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
/>
|
||||
} @else {
|
||||
<div class="store-layout__brand-logo-shell" aria-hidden="true">
|
||||
<span class="store-layout__brand-logo-skeleton"></span>
|
||||
</div>
|
||||
}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@if (displaySeachBar()) {
|
||||
<div class="col-12 px-0 d-md-none order-3">
|
||||
<hr class="store-layout__mobile-separator" />
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md order-4 order-md-2">
|
||||
<div class="store-layout__search-wrapper ms-md-auto">
|
||||
<div class="input-group store-layout__search" role="search">
|
||||
<label class="visually-hidden" for="store-search-input">Buscar productos</label>
|
||||
<input
|
||||
id="store-search-input"
|
||||
type="search"
|
||||
class="form-control store-layout__search-input border-end-0 rounded-start"
|
||||
placeholder="Buscar productos"
|
||||
autocomplete="off"
|
||||
[attr.aria-describedby]="showSearchError() ? 'store-search-error' : null"
|
||||
[attr.aria-invalid]="showSearchError()"
|
||||
[minlength]="minSearchLength"
|
||||
[formControl]="searchControl"
|
||||
(input)="clearSearchError()"
|
||||
(keydown.enter)="submitSearch($event)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn store-layout__search-button border-start-0 rounded-end px-3"
|
||||
aria-label="Buscar"
|
||||
(click)="submitSearch()"
|
||||
>
|
||||
<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (showSearchError()) {
|
||||
<p id="store-search-error" class="store-layout__search-error mb-0" role="alert">
|
||||
Ingresá al menos {{ minSearchLength }} caracteres para buscar.
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div
|
||||
class="store-layout__actions-column col-auto col-md-auto order-2 order-md-3 ms-auto d-flex flex-row align-items-center justify-content-end gap-2 gap-md-3"
|
||||
>
|
||||
@if (ticketsMenu(); as menu) {
|
||||
<app-button
|
||||
variant="primary"
|
||||
hostClass="store-layout__tickets-btn-host"
|
||||
data-testid="store-header-tickets"
|
||||
(click)="ticketsClick.emit()"
|
||||
>
|
||||
{{ menu.label }}
|
||||
</app-button>
|
||||
}
|
||||
|
||||
@if (displayCart()) {
|
||||
<app-cart-icon
|
||||
[quantity]="cartDisabled() ? null : cartQuantity()"
|
||||
[disabled]="cartDisabled()"
|
||||
[ariaLabel]="
|
||||
cartDisabled() ? 'Carrito no disponible durante el checkout' : 'Carrito de compras'
|
||||
"
|
||||
[title]="cartDisabled() ? 'Carrito no disponible durante el checkout' : 'Carrito'"
|
||||
(click)="onCartClick()"
|
||||
/>
|
||||
}
|
||||
|
||||
<div class="store-layout__user-menu d-none d-md-inline-flex position-relative">
|
||||
<app-icon-button
|
||||
variant="user"
|
||||
ariaLabel="Mi cuenta"
|
||||
ariaControls="store-user-dropdown"
|
||||
[ariaExpanded]="isUserDropdownOpen()"
|
||||
title="Mi cuenta"
|
||||
(clicked)="toggleUserDropdown()"
|
||||
/>
|
||||
|
||||
@if (isUserDropdownOpen()) {
|
||||
<app-user-dropdown
|
||||
id="store-user-dropdown"
|
||||
[user]="user()"
|
||||
[accountNavigationMenus]="accountNavigationMenus()"
|
||||
(logoutClick)="onLogoutClick()"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="store-layout__mobile-menu d-inline-flex d-md-none position-relative">
|
||||
<app-icon-button
|
||||
variant="bars"
|
||||
ariaLabel="Menu"
|
||||
ariaControls="store-mobile-menu"
|
||||
[ariaExpanded]="isMobileMenuOpen()"
|
||||
title="Menu"
|
||||
(clicked)="toggleMobileMenu()"
|
||||
/>
|
||||
|
||||
@if (isMobileMenuOpen()) {
|
||||
<div
|
||||
id="store-mobile-menu"
|
||||
class="position-absolute end-0 bg-white rounded shadow mt-2 py-2 store-layout__mobile-dropdown-menu"
|
||||
>
|
||||
<a
|
||||
class="dropdown-item d-flex align-items-center gap-2 px-3 py-2 text-decoration-none store-layout__mobile-dropdown-item"
|
||||
(click)="onMobileAccountClick()"
|
||||
>
|
||||
<i class="fa-regular fa-circle-user fa-lg"></i>
|
||||
<span class="fw-medium">Mi cuenta</span>
|
||||
</a>
|
||||
|
||||
@if (displayCategories()) {
|
||||
<hr class="my-1 border-secondary opacity-25" />
|
||||
|
||||
@for (category of categories(); track category.id) {
|
||||
<a
|
||||
class="dropdown-item px-3 py-2 text-decoration-none store-layout__mobile-dropdown-item"
|
||||
(click)="onCategorySelect(category)"
|
||||
>
|
||||
<span class="fw-medium">{{ category.nombre }}</span>
|
||||
</a>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="store-layout__categories d-none d-md-block">
|
||||
<div class="container-xl h-100 px-3 px-md-4 d-flex align-items-center">
|
||||
@if (displayCategories()) {
|
||||
<div class="store-layout__category-menu">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-link d-inline-flex align-items-center gap-2 px-0 py-3 text-uppercase fw-bold text-decoration-none store-layout__category-trigger"
|
||||
aria-label="Categorias"
|
||||
aria-controls="store-category-dropdown"
|
||||
[attr.aria-expanded]="isCategoryDropdownOpen()"
|
||||
(click)="toggleCategoryDropdown()"
|
||||
>
|
||||
<span>Categorias</span>
|
||||
<i class="fa-solid fa-chevron-down" aria-hidden="true"></i>
|
||||
</button>
|
||||
|
||||
<app-category-dropdown
|
||||
id="store-category-dropdown"
|
||||
[categories]="categories()"
|
||||
[open]="isCategoryDropdownOpen()"
|
||||
(categorySelect)="onCategorySelect($event)"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (checkoutRemainingTime(); as remainingTime) {
|
||||
<div
|
||||
class="store-layout__checkout-timer ms-auto d-none d-md-flex align-items-baseline gap-3"
|
||||
role="timer"
|
||||
aria-label="Tiempo restante de compra"
|
||||
>
|
||||
<span class="store-layout__checkout-timer-label">Tiempo restante de compra:</span>
|
||||
<span class="store-layout__checkout-timer-value">{{ remainingTime }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -0,0 +1,242 @@
|
||||
.store-layout__header {
|
||||
color: #ffffff;
|
||||
background-color: var(--tenant-header-bg);
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.store-layout__categories {
|
||||
height: 3.5rem;
|
||||
}
|
||||
|
||||
.store-layout__checkout-timer {
|
||||
color: var(--tenant-primary);
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.store-layout__checkout-timer-label {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.store-layout__checkout-timer-value {
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.store-layout__brand-slot {
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.store-layout__brand-column {
|
||||
max-width: 12rem;
|
||||
}
|
||||
|
||||
.store-layout__brand-logo-shell {
|
||||
position: relative;
|
||||
width: min(100%, 12rem);
|
||||
height: 2.75rem;
|
||||
}
|
||||
|
||||
.store-layout__brand-logo {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.store-layout__brand-logo-skeleton {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 0.75rem;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0.12) 0%,
|
||||
rgba(255, 255, 255, 0.22) 50%,
|
||||
rgba(255, 255, 255, 0.12) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: store-header-logo-skeleton 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.store-layout__search-wrapper {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.store-layout__search-wrapper {
|
||||
width: min(100%, 260px);
|
||||
}
|
||||
|
||||
.store-layout__actions-column {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
::ng-deep .store-layout__tickets-btn-host {
|
||||
flex: 0 0 clamp(8rem, 15vw, 13.5rem);
|
||||
width: clamp(8rem, 15vw, 13.5rem);
|
||||
}
|
||||
}
|
||||
|
||||
.store-layout__search {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.store-layout__search-error {
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.2;
|
||||
color: var(--tenant-danger, #dc3545);
|
||||
}
|
||||
|
||||
.store-layout__search-input {
|
||||
color: #666666;
|
||||
border-color: var(--border-color);
|
||||
background-color: rgba(255, 255, 255, 0.96);
|
||||
}
|
||||
|
||||
.store-layout__search-input::placeholder {
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.store-layout__search-input:focus {
|
||||
border-color: var(--border-color);
|
||||
box-shadow: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.store-layout__search-button {
|
||||
color: #a0a0a0;
|
||||
border-color: var(--border-color);
|
||||
background-color: rgba(255, 255, 255, 0.96);
|
||||
}
|
||||
|
||||
.store-layout__search-button:hover,
|
||||
.store-layout__search-button:focus-visible {
|
||||
color: #8a8a8a;
|
||||
border-color: var(--border-color);
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.store-layout__action-button {
|
||||
color: #a0a0a0;
|
||||
}
|
||||
|
||||
.store-layout__action-button:hover,
|
||||
.store-layout__action-button:focus-visible {
|
||||
color: #8a8a8a;
|
||||
}
|
||||
|
||||
.store-layout__user-menu {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.store-layout__category-trigger {
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.02em;
|
||||
color: #a0a0a0;
|
||||
}
|
||||
|
||||
.store-layout__category-trigger:hover,
|
||||
.store-layout__category-trigger:focus-visible {
|
||||
color: #8a8a8a;
|
||||
}
|
||||
|
||||
.store-layout__category-menu {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.store-layout__category-menu app-category-dropdown {
|
||||
position: absolute;
|
||||
z-index: 1050;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
@keyframes store-header-logo-skeleton {
|
||||
from {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
|
||||
to {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.store-layout__brand-slot {
|
||||
min-width: 0;
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
.store-layout__actions-column {
|
||||
flex: 1 1 auto;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
::ng-deep .store-layout__tickets-btn-host {
|
||||
flex: 1 1 0;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
max-width: 50vw;
|
||||
}
|
||||
|
||||
.store-layout__mobile-separator {
|
||||
border-color: var(--border-color);
|
||||
opacity: 1;
|
||||
margin: 0 -1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 399.98px) {
|
||||
.store-layout__brand-column {
|
||||
flex: 1 1 0;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
|
||||
.store-layout__brand-slot {
|
||||
width: 100%;
|
||||
max-width: 6.5rem;
|
||||
}
|
||||
|
||||
.store-layout__actions-column {
|
||||
flex: 1 1 auto;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
margin-left: 0 !important;
|
||||
padding-left: 0.25rem;
|
||||
gap: 0.25rem !important;
|
||||
}
|
||||
|
||||
::ng-deep .store-layout__tickets-btn-host .btn {
|
||||
padding-inline: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.store-layout__mobile-dropdown-menu {
|
||||
top: 100%;
|
||||
min-width: 220px;
|
||||
z-index: 1050;
|
||||
}
|
||||
|
||||
.store-layout__mobile-dropdown-item {
|
||||
cursor: pointer;
|
||||
color: #8a8a8a;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
color: #666666;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
ElementRef,
|
||||
HostListener,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { AuthUser } from '../../../services/auth/auth.interfaces';
|
||||
import { CartIconComponent } from '../../../../shared/components/cart-icon/cart-icon.component';
|
||||
import { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.component';
|
||||
import { UserDropdownComponent } from './user-dropdown/user-dropdown.component';
|
||||
import { Menu } from '../../../services/tenant.interface';
|
||||
import { ButtonComponent } from '../../../../shared/components/button/button.component';
|
||||
import { ReactiveFormsModule, FormControl } from '@angular/forms';
|
||||
import { Category } from '../../../services/tenant.interface';
|
||||
import { CategoryDropdownComponent } from './category-dropdown/category-dropdown.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-standard-store-header',
|
||||
imports: [
|
||||
ButtonComponent,
|
||||
CartIconComponent,
|
||||
IconButtonComponent,
|
||||
RouterLink,
|
||||
ReactiveFormsModule,
|
||||
UserDropdownComponent,
|
||||
CategoryDropdownComponent,
|
||||
],
|
||||
templateUrl: './standard-store-header.component.html',
|
||||
styleUrl: './standard-store-header.component.scss',
|
||||
})
|
||||
export class StandardStoreHeaderComponent {
|
||||
private readonly elementRef = inject(ElementRef<HTMLElement>);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
readonly logoUrl = input<string | null>(null);
|
||||
readonly backgroundImageUrl = input<string | null>(null);
|
||||
readonly cartQuantity = input<number>(0);
|
||||
readonly user = input<AuthUser | null>(null);
|
||||
readonly isAuthenticated = input<boolean>(false);
|
||||
readonly accountNavigationMenus = input<readonly Menu[]>([]);
|
||||
readonly ticketsMenu = input<Menu | null>(null);
|
||||
readonly categories = input<readonly Category[]>([]);
|
||||
readonly displayCategories = input(true);
|
||||
readonly displaySeachBar = input(true);
|
||||
readonly displayCart = input(true);
|
||||
readonly cartDisabled = input(false);
|
||||
readonly checkoutRemainingSeconds = input<number | null>(null);
|
||||
readonly cartClick = output<void>();
|
||||
readonly ticketsClick = output<void>();
|
||||
readonly loginClick = output<void>();
|
||||
readonly logoutClick = output<void>();
|
||||
readonly searchSubmit = output<string>();
|
||||
readonly categorySelect = output<Category>();
|
||||
|
||||
protected readonly isUserDropdownOpen = signal(false);
|
||||
protected readonly isCategoryDropdownOpen = signal(false);
|
||||
protected readonly isMobileMenuOpen = signal(false);
|
||||
protected readonly minSearchLength = 3;
|
||||
protected readonly showSearchError = signal(false);
|
||||
protected readonly searchControl = new FormControl('', { nonNullable: true });
|
||||
protected readonly checkoutRemainingTime = computed(() => {
|
||||
const remainingSeconds = this.checkoutRemainingSeconds();
|
||||
|
||||
if (remainingSeconds === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const minutes = Math.floor(remainingSeconds / 60);
|
||||
const seconds = remainingSeconds % 60;
|
||||
|
||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
});
|
||||
|
||||
protected onCartClick(): void {
|
||||
if (this.cartDisabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.cartClick.emit();
|
||||
}
|
||||
|
||||
@HostListener('document:click', ['$event'])
|
||||
protected onDocumentClick(event: MouseEvent): void {
|
||||
if (!this.isUserDropdownOpen() && !this.isCategoryDropdownOpen() && !this.isMobileMenuOpen()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = event.target;
|
||||
if (target instanceof Node && !this.elementRef.nativeElement.contains(target)) {
|
||||
this.isUserDropdownOpen.set(false);
|
||||
this.isCategoryDropdownOpen.set(false);
|
||||
this.isMobileMenuOpen.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
@HostListener('document:keydown.escape')
|
||||
protected onEscapeKey(): void {
|
||||
this.isUserDropdownOpen.set(false);
|
||||
this.isCategoryDropdownOpen.set(false);
|
||||
this.isMobileMenuOpen.set(false);
|
||||
}
|
||||
|
||||
protected toggleUserDropdown(): void {
|
||||
if (!this.isAuthenticated()) {
|
||||
this.loginClick.emit();
|
||||
return;
|
||||
}
|
||||
|
||||
this.isUserDropdownOpen.update((isOpen) => !isOpen);
|
||||
}
|
||||
|
||||
protected onLoginClick(): void {
|
||||
this.isUserDropdownOpen.set(false);
|
||||
this.loginClick.emit();
|
||||
}
|
||||
|
||||
protected onLogoutClick(): void {
|
||||
this.isUserDropdownOpen.set(false);
|
||||
this.logoutClick.emit();
|
||||
}
|
||||
|
||||
protected submitSearch(event?: Event): void {
|
||||
event?.preventDefault();
|
||||
|
||||
const term = this.searchControl.value.trim();
|
||||
|
||||
if (term.length < this.minSearchLength) {
|
||||
this.showSearchError.set(true);
|
||||
return;
|
||||
}
|
||||
|
||||
this.showSearchError.set(false);
|
||||
this.searchSubmit.emit(term);
|
||||
}
|
||||
|
||||
protected clearSearchError(): void {
|
||||
this.showSearchError.set(false);
|
||||
}
|
||||
|
||||
protected toggleCategoryDropdown(): void {
|
||||
this.isCategoryDropdownOpen.update((isOpen) => !isOpen);
|
||||
}
|
||||
|
||||
protected onCategorySelect(category: Category): void {
|
||||
this.isCategoryDropdownOpen.set(false);
|
||||
this.isMobileMenuOpen.set(false);
|
||||
this.categorySelect.emit(category);
|
||||
}
|
||||
|
||||
protected toggleMobileMenu(): void {
|
||||
this.isMobileMenuOpen.update((isOpen) => !isOpen);
|
||||
}
|
||||
|
||||
protected onMobileAccountClick(): void {
|
||||
this.isMobileMenuOpen.set(false);
|
||||
if (!this.isAuthenticated()) {
|
||||
this.loginClick.emit();
|
||||
} else {
|
||||
const menus = this.accountNavigationMenus();
|
||||
if (menus.length > 0) {
|
||||
this.router.navigate([menus[0].route]);
|
||||
} else {
|
||||
this.router.navigate(['/cuenta']);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
@switch (headerType()) {
|
||||
@case ('immersive') {
|
||||
<app-immersive-store-header
|
||||
[logoUrl]="logoUrl()"
|
||||
[backgroundImageUrl]="backgroundImageUrl()"
|
||||
[heroContent]="heroContent()"
|
||||
[carouselImages]="carouselImages()"
|
||||
[showHero]="showHero()"
|
||||
[cartQuantity]="cartQuantity()"
|
||||
[user]="user()"
|
||||
[isAuthenticated]="isAuthenticated()"
|
||||
[accountNavigationMenus]="accountNavigationMenus()"
|
||||
[ticketsMenu]="ticketsMenu()"
|
||||
[displaySearchBar]="displaySeachBar()"
|
||||
[displayCart]="displayCart()"
|
||||
[cartDisabled]="cartDisabled()"
|
||||
[checkoutRemainingSeconds]="checkoutRemainingSeconds()"
|
||||
(cartClick)="cartClick.emit()"
|
||||
(ticketsClick)="ticketsClick.emit()"
|
||||
(loginClick)="loginClick.emit()"
|
||||
(logoutClick)="logoutClick.emit()"
|
||||
(searchSubmit)="searchSubmit.emit($event)"
|
||||
>
|
||||
@if (showHero() && displayCategories() && categories().length) {
|
||||
<nav class="immersive-categories" aria-label="Categorías">
|
||||
<app-horizontal-carousel
|
||||
[items]="categoryItems()"
|
||||
ariaLabel="Categorías"
|
||||
(itemSelect)="onCategoryItemSelect($event)"
|
||||
/>
|
||||
</nav>
|
||||
}
|
||||
</app-immersive-store-header>
|
||||
}
|
||||
@default {
|
||||
<ng-container *ngTemplateOutlet="standardHeader" />
|
||||
}
|
||||
}
|
||||
|
||||
<ng-template #standardHeader>
|
||||
<app-standard-store-header
|
||||
[logoUrl]="logoUrl()"
|
||||
[backgroundImageUrl]="backgroundImageUrl()"
|
||||
[cartQuantity]="cartQuantity()"
|
||||
[user]="user()"
|
||||
[isAuthenticated]="isAuthenticated()"
|
||||
[accountNavigationMenus]="accountNavigationMenus()"
|
||||
[ticketsMenu]="ticketsMenu()"
|
||||
[categories]="categories()"
|
||||
[displayCategories]="displayCategories()"
|
||||
[displaySeachBar]="displaySeachBar()"
|
||||
[displayCart]="displayCart()"
|
||||
[cartDisabled]="cartDisabled()"
|
||||
[checkoutRemainingSeconds]="checkoutRemainingSeconds()"
|
||||
(cartClick)="cartClick.emit()"
|
||||
(ticketsClick)="ticketsClick.emit()"
|
||||
(loginClick)="loginClick.emit()"
|
||||
(logoutClick)="logoutClick.emit()"
|
||||
(searchSubmit)="searchSubmit.emit($event)"
|
||||
(categorySelect)="categorySelect.emit($event)"
|
||||
/>
|
||||
</ng-template>
|
||||
@@ -0,0 +1,55 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
app-immersive-store-header {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
:host(.immersive-shell--compact) {
|
||||
position: relative;
|
||||
background-color: #f5f5f5;
|
||||
background-image: none !important;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background-image: var(--compact-header-background);
|
||||
background-position: left top, center 42%;
|
||||
background-size: 100% 100%, cover;
|
||||
background-repeat: no-repeat;
|
||||
mask-image: linear-gradient(#000 calc(100% - 12px), transparent calc(100% - 4px));
|
||||
}
|
||||
}
|
||||
|
||||
.immersive-categories {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
margin-top: clamp(5rem, 11vw, 9rem);
|
||||
padding: 0.375rem 1rem;
|
||||
background: transparent;
|
||||
color: #666;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
--horizontal-carousel-gap: 90px;
|
||||
--horizontal-carousel-text-transform: uppercase;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.immersive-categories {
|
||||
grid-area: 5 / 1 / auto / -1;
|
||||
min-width: 0;
|
||||
margin-top: 1.5rem;
|
||||
padding: 0 1rem;
|
||||
font-size: clamp(10px, 2.8vw, 12px);
|
||||
--horizontal-carousel-visible-items: 3;
|
||||
--horizontal-carousel-gap: 0.5rem;
|
||||
--horizontal-carousel-item-width: calc((100cqw - 2 * var(--horizontal-carousel-gap)) / 3);
|
||||
--horizontal-carousel-white-space: normal;
|
||||
--horizontal-carousel-snap: x mandatory;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { NgTemplateOutlet } from '@angular/common';
|
||||
import { Component, computed, input, output } from '@angular/core';
|
||||
import { AuthUser } from '../../../services/auth/auth.interfaces';
|
||||
import { Category, Menu, WebsiteExtras } from '../../../services/tenant.interface';
|
||||
import { ImmersiveStoreHeaderComponent } from './immersive-store-header.component';
|
||||
import { StandardStoreHeaderComponent } from './standard-store-header.component';
|
||||
|
||||
import {
|
||||
HorizontalCarouselComponent,
|
||||
HorizontalCarouselItem,
|
||||
} from '../../../../shared/components/horizontal-carousel/horizontal-carousel.component';
|
||||
|
||||
export type StoreHeaderType = 'standard' | 'immersive';
|
||||
|
||||
@Component({
|
||||
selector: 'app-store-header',
|
||||
imports: [
|
||||
NgTemplateOutlet,
|
||||
HorizontalCarouselComponent,
|
||||
StandardStoreHeaderComponent,
|
||||
ImmersiveStoreHeaderComponent,
|
||||
],
|
||||
templateUrl: './store-header.component.html',
|
||||
styleUrl: './store-header.component.scss',
|
||||
host: {
|
||||
'[class.immersive-shell--compact]': 'isCompactImmersive()',
|
||||
'[style.background-image]': 'null',
|
||||
'[style.--compact-header-background]': 'compactBackground()',
|
||||
},
|
||||
})
|
||||
export class StoreHeaderComponent {
|
||||
readonly headerType = input<StoreHeaderType>('standard');
|
||||
readonly logoUrl = input<string | null>(null);
|
||||
readonly backgroundImageUrl = input<string | null>(null);
|
||||
readonly heroContent = input<WebsiteExtras['immersiveHero']>(undefined);
|
||||
readonly carouselImages = input<readonly string[]>([]);
|
||||
readonly showHero = input(true);
|
||||
protected readonly isCompactImmersive = computed(
|
||||
() => this.headerType() === 'immersive' && !this.showHero(),
|
||||
);
|
||||
protected readonly compactBackground = computed(() => {
|
||||
if (!this.isCompactImmersive()) return null;
|
||||
|
||||
const gradient =
|
||||
'linear-gradient(180deg, rgba(245, 245, 245, 0.72) 0%, rgba(245, 245, 245, 0.88) 58%, #f5f5f5 calc(100% - 4px), #f5f5f5 100%)';
|
||||
const image = this.backgroundImageUrl();
|
||||
return image ? `${gradient}, url(${JSON.stringify(image)})` : gradient;
|
||||
});
|
||||
readonly cartQuantity = input<number>(0);
|
||||
readonly user = input<AuthUser | null>(null);
|
||||
readonly isAuthenticated = input<boolean>(false);
|
||||
readonly accountNavigationMenus = input<readonly Menu[]>([]);
|
||||
readonly ticketsMenu = input<Menu | null>(null);
|
||||
readonly categories = input<readonly Category[]>([]);
|
||||
readonly displayCategories = input(true);
|
||||
readonly displaySeachBar = input(true);
|
||||
readonly displayCart = input(true);
|
||||
readonly cartDisabled = input(false);
|
||||
readonly checkoutRemainingSeconds = input<number | null>(null);
|
||||
|
||||
readonly cartClick = output<void>();
|
||||
readonly ticketsClick = output<void>();
|
||||
readonly loginClick = output<void>();
|
||||
readonly logoutClick = output<void>();
|
||||
readonly searchSubmit = output<string>();
|
||||
readonly categorySelect = output<Category>();
|
||||
|
||||
protected readonly categoryItems = computed(() =>
|
||||
this.categories().map((category) => ({ value: category.id, label: category.nombre })),
|
||||
);
|
||||
|
||||
protected onCategoryItemSelect(item: HorizontalCarouselItem): void {
|
||||
const category = this.categories().find((candidate) => candidate.id === item.value);
|
||||
if (category) this.categorySelect.emit(category);
|
||||
}
|
||||
}
|
||||
@@ -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,77 @@
|
||||
: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;
|
||||
width: min(18rem, calc(100vw - 1rem));
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
80
src/app/core/layout/store-layout/store-layout.component.html
Normal file
@@ -0,0 +1,80 @@
|
||||
<div class="store-layout d-flex flex-column flex-grow-1">
|
||||
<app-store-header
|
||||
ngSkipHydration
|
||||
[headerType]="tenant()?.header_type ?? 'standard'"
|
||||
[heroContent]="tenant()?.extras?.immersiveHero"
|
||||
[carouselImages]="tenant()?.extras?.immersiveHeroCarousel ?? []"
|
||||
[showHero]="isHomeRoute()"
|
||||
[cartQuantity]="cartQuantity()"
|
||||
[logoUrl]="tenant()?.header_logo ?? null"
|
||||
[backgroundImageUrl]="tenant()?.header_bg_image ?? null"
|
||||
[user]="user()"
|
||||
[isAuthenticated]="isAuthenticated()"
|
||||
[accountNavigationMenus]="accountNavigationMenus()"
|
||||
[ticketsMenu]="ticketsMenu() ?? null"
|
||||
[categories]="navigationCategories()"
|
||||
[displayCategories]="displayCategories()"
|
||||
[displaySeachBar]="tenant()?.display_seach_bar ?? true"
|
||||
[displayCart]="displayCart()"
|
||||
[cartDisabled]="isCheckoutRoute()"
|
||||
[checkoutRemainingSeconds]="headerCheckoutRemainingSeconds()"
|
||||
(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">
|
||||
<section class="py-5">
|
||||
<div class="container-xl px-3 px-md-4">
|
||||
<router-outlet />
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<app-store-footer
|
||||
[currentYear]="currentYear"
|
||||
[footerSections]="footerSections()"
|
||||
[socialMedia]="tenant()?.social_media ?? []"
|
||||
[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>
|
||||
67
src/app/core/layout/store-layout/store-layout.component.scss
Normal file
@@ -0,0 +1,67 @@
|
||||
:host {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 100dvh;
|
||||
background: #f5f5f5;
|
||||
color: #202020;
|
||||
}
|
||||
|
||||
.store-layout {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
1086
src/app/core/layout/store-layout/store-layout.component.spec.ts
Normal file
434
src/app/core/layout/store-layout/store-layout.component.ts
Normal file
@@ -0,0 +1,434 @@
|
||||
import { isPlatformBrowser } from '@angular/common';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
DestroyRef,
|
||||
effect,
|
||||
inject,
|
||||
OnInit,
|
||||
PLATFORM_ID,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import {
|
||||
ActivatedRoute,
|
||||
NavigationEnd,
|
||||
PRIMARY_OUTLET,
|
||||
Router,
|
||||
RouterOutlet,
|
||||
} from '@angular/router';
|
||||
import { filter, firstValueFrom } from 'rxjs';
|
||||
import { TenantService } from '../../services/tenant.service';
|
||||
import { CartService } from '../../services/cart/cart.service';
|
||||
import { StoreFooterComponent, StoreFooterSection } from './store-footer/store-footer.component';
|
||||
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,
|
||||
isExpiredStockReservationResponse,
|
||||
} from '../../services/checkout.service';
|
||||
import { ToastService } from '../../services/toast.service';
|
||||
import { Category } from '../../services/tenant.interface';
|
||||
import { CheckoutCountdownService } from '../../services/checkout-countdown.service';
|
||||
import { EventDateNotice, EventDateNoticeService } from '../../services/event-date-notice.service';
|
||||
import { ModalService } from '../../services/modal.service';
|
||||
import { EventDateNoticeModalComponent } from '../../../shared/components/event-date-notice-modal/event-date-notice-modal.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-store-layout',
|
||||
imports: [
|
||||
RouterOutlet,
|
||||
StoreHeaderComponent,
|
||||
StoreFooterComponent,
|
||||
CartComponent,
|
||||
ButtonComponent,
|
||||
],
|
||||
templateUrl: './store-layout.component.html',
|
||||
styleUrl: './store-layout.component.scss',
|
||||
})
|
||||
export class StoreLayoutComponent implements OnInit {
|
||||
private readonly tenantService = inject(TenantService);
|
||||
private readonly cartService = inject(CartService);
|
||||
private readonly authService = inject(AuthService);
|
||||
private readonly checkoutService = inject(CheckoutService);
|
||||
private readonly checkoutCountdownService = inject(CheckoutCountdownService);
|
||||
private readonly toastService = inject(ToastService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly platformId = inject(PLATFORM_ID);
|
||||
private readonly eventDateNoticeService = inject(EventDateNoticeService);
|
||||
private readonly modalService = inject(ModalService);
|
||||
private claimedNoticeUserId: number | null = null;
|
||||
|
||||
protected readonly isCartOpen = signal(false);
|
||||
protected readonly isCheckoutRoute = signal(this.isCheckoutUrl(this.router.url));
|
||||
protected readonly isHomeRoute = signal(this.isHomeUrl(this.router.url));
|
||||
protected readonly checkoutRemainingSeconds = this.checkoutCountdownService.remainingSeconds;
|
||||
protected readonly isCreatingPurchase = signal(false);
|
||||
private readonly isMultiEventStorefront = computed(
|
||||
() => this.tenant()?.storefront_website_type_code === 'onticket_multi_event',
|
||||
);
|
||||
protected readonly headerCheckoutRemainingSeconds = computed(() =>
|
||||
this.isCheckoutRoute() && this.tenant()?.checkout_summary_type === 'event'
|
||||
? null
|
||||
: this.checkoutRemainingSeconds(),
|
||||
);
|
||||
protected readonly navigationCategories = computed(() =>
|
||||
this.isMultiEventStorefront()
|
||||
? (this.tenant()?.event_categories ?? [])
|
||||
: (this.tenant()?.categories ?? []),
|
||||
);
|
||||
private readonly categoryMenu = computed(() =>
|
||||
findMenu(
|
||||
this.tenant()?.menues ?? [],
|
||||
this.isMultiEventStorefront() ? 'event.category' : 'product.category',
|
||||
),
|
||||
);
|
||||
protected readonly displayCategories = computed(
|
||||
() => (this.tenant()?.display_categories ?? true) && this.categoryMenu() !== undefined,
|
||||
);
|
||||
protected readonly displayCart = computed(() => this.tenant()?.display_cart ?? true);
|
||||
protected readonly cartEditingPolicy = computed(() => this.tenant()?.cart_editing_policy ?? null);
|
||||
protected readonly canModifyCart = computed(
|
||||
() => this.cartEditingPolicy()?.allow_modify ?? false,
|
||||
);
|
||||
protected readonly canDeleteCartItems = computed(
|
||||
() => this.cartEditingPolicy()?.allow_delete ?? false,
|
||||
);
|
||||
protected readonly canUpdateCartQuantity = computed(
|
||||
() => this.cartEditingPolicy()?.allow_update_quantity ?? false,
|
||||
);
|
||||
protected readonly canUpdateCartVariant = computed(
|
||||
() => this.cartEditingPolicy()?.allow_update_variant ?? false,
|
||||
);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const user = this.authService.user();
|
||||
|
||||
if (!isPlatformBrowser(this.platformId) || !user) {
|
||||
this.claimedNoticeUserId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.claimedNoticeUserId === user.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.claimedNoticeUserId = user.id;
|
||||
this.eventDateNoticeService.claim().subscribe({
|
||||
next: ({ data }) => void this.showEventDateNotices(data, user.id),
|
||||
error: (error) => console.error('Error loading event date notices', error),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
protected readonly cartSubtotal = computed(() => {
|
||||
const cart = this.cartService.cart();
|
||||
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 tenant = this.tenantService.tenant;
|
||||
protected readonly user = this.authService.user;
|
||||
protected readonly isAuthenticated = this.authService.isAuthenticated;
|
||||
protected readonly ticketsMenu = computed(() =>
|
||||
findMenu(this.tenant()?.menues ?? [], 'account.tickets'),
|
||||
);
|
||||
protected readonly accountNavigationMenus = computed(() => {
|
||||
const accountMenu = findMenu(this.tenant()?.menues ?? [], 'account');
|
||||
|
||||
if (!accountMenu) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const profile = accountMenu.submenues.find((menu) => menu.code === 'account.profile');
|
||||
const purchases = accountMenu.submenues.find((menu) => menu.code === 'account.purchases');
|
||||
const tickets = accountMenu.submenues.find((menu) => menu.code === 'account.tickets');
|
||||
|
||||
return [
|
||||
...(profile ? [{ ...accountMenu, route: profile.route }] : []),
|
||||
...(purchases ? [purchases] : []),
|
||||
...(tickets ? [tickets] : []),
|
||||
];
|
||||
});
|
||||
|
||||
protected readonly cartQuantity = computed(() => {
|
||||
const cart = this.cartService.cart();
|
||||
if (!cart || !cart.items) return 0;
|
||||
return cart.items.reduce((total, item) => total + item.cantidad, 0);
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
this.router.events
|
||||
.pipe(
|
||||
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
)
|
||||
.subscribe((event) => {
|
||||
const isCheckoutRoute = this.isCheckoutUrl(event.urlAfterRedirects);
|
||||
this.isCheckoutRoute.set(isCheckoutRoute);
|
||||
this.isHomeRoute.set(this.isHomeUrl(event.urlAfterRedirects));
|
||||
|
||||
if (isCheckoutRoute) {
|
||||
this.isCartOpen.set(false);
|
||||
}
|
||||
});
|
||||
|
||||
this.route.queryParamMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => {
|
||||
if (params.get('openCart') === 'true' && !this.isCheckoutRoute()) {
|
||||
this.isCartOpen.set(true);
|
||||
}
|
||||
});
|
||||
|
||||
this.cartService.loadCart().subscribe({
|
||||
error: (err) => console.error('Error loading cart', err),
|
||||
});
|
||||
}
|
||||
|
||||
private isCheckoutUrl(url: string): boolean {
|
||||
const primarySegments = this.router.parseUrl(url).root.children[PRIMARY_OUTLET]?.segments ?? [];
|
||||
|
||||
return primarySegments[0]?.path === 'checkout';
|
||||
}
|
||||
|
||||
private isHomeUrl(url: string): boolean {
|
||||
const primarySegments = this.router.parseUrl(url).root.children[PRIMARY_OUTLET]?.segments ?? [];
|
||||
return primarySegments.length === 0;
|
||||
}
|
||||
|
||||
protected onLoginClick(): void {
|
||||
void this.router.navigate(['/login']);
|
||||
}
|
||||
|
||||
protected onCartClick(): void {
|
||||
if (this.isCheckoutRoute()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isCartOpen.update((isOpen) => !isOpen);
|
||||
}
|
||||
|
||||
private async showEventDateNotices(notices: EventDateNotice[], userId: number): Promise<void> {
|
||||
for (const notice of notices) {
|
||||
if (this.authService.user()?.id !== userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const modalRef = this.modalService.open<EventDateNoticeModalComponent, void, EventDateNotice>(
|
||||
EventDateNoticeModalComponent,
|
||||
{
|
||||
size: 'sm',
|
||||
data: notice,
|
||||
},
|
||||
);
|
||||
|
||||
await firstValueFrom(modalRef.afterClosed$);
|
||||
|
||||
if (modalRef.dismissReason() === 'replaced') {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected onSearch(term: string): void {
|
||||
void this.router.navigate(['/buscar'], {
|
||||
queryParams: { q: term, page: 1 },
|
||||
});
|
||||
}
|
||||
|
||||
protected onCategorySelect(category: Category): void {
|
||||
const route = this.isMultiEventStorefront() ? '/eventos/categoria' : '/categoria';
|
||||
|
||||
void this.router.navigate([route, 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) {
|
||||
this.toastService.danger('No se pudo cerrar la sesión. Intentá nuevamente.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.authService.logout().subscribe({
|
||||
next: ({ message }) => {
|
||||
this.cartService.clearCart();
|
||||
this.isCartOpen.set(false);
|
||||
this.toastService.info(message || 'Sesión cerrada correctamente.');
|
||||
|
||||
if (!isLeavingCheckout) {
|
||||
void this.router.navigate(['/']);
|
||||
}
|
||||
},
|
||||
error: (error: unknown) => {
|
||||
this.toastService.danger(this.resolveLogoutErrorMessage(error));
|
||||
console.error('Error logging out', error);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private resolveLogoutErrorMessage(error: unknown): string {
|
||||
const payload =
|
||||
typeof error === 'object' && error !== null && 'error' in error
|
||||
? (error as { error?: { message?: unknown } }).error
|
||||
: undefined;
|
||||
|
||||
return typeof payload?.message === 'string' && payload.message.trim()
|
||||
? payload.message
|
||||
: 'No se pudo cerrar la sesión. Intentá nuevamente.';
|
||||
}
|
||||
|
||||
protected async onCheckoutClick(): Promise<void> {
|
||||
if (this.isCreatingPurchase()) {
|
||||
return;
|
||||
}
|
||||
|
||||
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', purchase.id]);
|
||||
} catch (error) {
|
||||
console.error('Failed to create cart purchase:', error);
|
||||
const message =
|
||||
error instanceof HttpErrorResponse && typeof error.error?.message === 'string'
|
||||
? error.error.message
|
||||
: 'No se pudo iniciar la compra.';
|
||||
this.toastService.danger(message);
|
||||
|
||||
if (error instanceof HttpErrorResponse && isExpiredStockReservationResponse(error.error)) {
|
||||
this.cartService.loadCart(true).subscribe({
|
||||
error: (refreshError) => console.error('Error refreshing expired cart', refreshError),
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
this.isCreatingPurchase.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
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/25' } as never),
|
||||
);
|
||||
|
||||
expect(result instanceof UrlTree).toBe(true);
|
||||
expect(TestBed.inject(Router).serializeUrl(result as UrlTree)).toBe(
|
||||
'/login?returnUrl=%2Fcheckout%2F25',
|
||||
);
|
||||
});
|
||||
|
||||
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/25' } 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);
|
||||
};
|
||||
65
src/app/core/services/auth/auth.interfaces.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
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 LogoutResponse {
|
||||
message: string;
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
229
src/app/core/services/auth/auth.service.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
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,
|
||||
LogoutResponse,
|
||||
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<LogoutResponse> {
|
||||
if (!this.tokenState()) {
|
||||
this.clearSession();
|
||||
return of({ message: 'Sesión cerrada correctamente.' });
|
||||
}
|
||||
|
||||
return this.http
|
||||
.post<LogoutResponse>(`${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;
|
||||
}
|
||||
196
src/app/core/services/cart/cart.service.spec.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
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('refreshes catalog availability only after the expired cart has been reloaded', () => {
|
||||
const availabilityChanged = vi.fn();
|
||||
catalogAvailabilityService.availabilityChanged$.subscribe(availabilityChanged);
|
||||
|
||||
service.loadCart(true).subscribe();
|
||||
|
||||
expect(availabilityChanged).not.toHaveBeenCalled();
|
||||
const req = httpMock.expectOne('http://api.test/tenants/acme/cart');
|
||||
req.flush({ data: mockCart });
|
||||
|
||||
expect(service.cart()).toEqual(mockCart);
|
||||
expect(availabilityChanged).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('propagates a custom loading mode to the request context', () => {
|
||||
service.withCustomLoading().loadCart().subscribe();
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
132
src/app/core/services/cart/cart.service.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
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(refreshCatalogAvailability = false): Observable<Cart> {
|
||||
return this.http
|
||||
.get<ApiResponse<Cart>>(`${this.tenantApiUrl}/cart`, {
|
||||
withCredentials: true,
|
||||
})
|
||||
.pipe(
|
||||
map((response) => response.data),
|
||||
tap((cart) => {
|
||||
this.cartState.set(cart);
|
||||
if (refreshCatalogAvailability) {
|
||||
this.catalogAvailabilityService.notifyAvailabilityChanged();
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
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, CatalogVariantValue>;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
116
src/app/core/services/catalog/catalog.service.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
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,
|
||||
} 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();
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
69
src/app/core/services/checkout-countdown.service.spec.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { CheckoutCountdownService } from './checkout-countdown.service';
|
||||
|
||||
describe('CheckoutCountdownService', () => {
|
||||
let service: CheckoutCountdownService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-08-27T12:00:00.000Z'));
|
||||
TestBed.configureTestingModule({ providers: [CheckoutCountdownService] });
|
||||
service = TestBed.inject(CheckoutCountdownService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
service.clear();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('counts down from the checkout server timing without depending on the client clock', () => {
|
||||
service.synchronize({
|
||||
expires_at: '2026-08-27T15:10:00.000Z',
|
||||
expires_in_seconds: 600,
|
||||
server_time: '2026-08-27T15:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(service.remainingSeconds()).toBe(600);
|
||||
|
||||
vi.advanceTimersByTime(1_000);
|
||||
|
||||
expect(service.remainingSeconds()).toBe(599);
|
||||
});
|
||||
|
||||
it('recalculates against the deadline after a delayed browser interval', () => {
|
||||
service.synchronize({
|
||||
expires_at: null,
|
||||
expires_in_seconds: 10,
|
||||
server_time: '2026-08-27T15:00:00.000Z',
|
||||
});
|
||||
|
||||
vi.setSystemTime(new Date('2026-08-27T12:00:07.000Z'));
|
||||
vi.advanceTimersByTime(1_000);
|
||||
|
||||
expect(service.remainingSeconds()).toBe(2);
|
||||
});
|
||||
|
||||
it('clears the countdown when checkout has no expiration', () => {
|
||||
service.synchronize({
|
||||
expires_at: null,
|
||||
expires_in_seconds: null,
|
||||
server_time: '2026-08-27T15:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(service.remainingSeconds()).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the active countdown when a partial checkout response omits timing fields', () => {
|
||||
service.synchronize({
|
||||
expires_at: null,
|
||||
expires_in_seconds: 10,
|
||||
server_time: '2026-08-27T15:00:00.000Z',
|
||||
});
|
||||
|
||||
service.synchronize({});
|
||||
|
||||
expect(service.remainingSeconds()).toBe(10);
|
||||
});
|
||||
});
|
||||
97
src/app/core/services/checkout-countdown.service.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { Injectable, OnDestroy, signal } from '@angular/core';
|
||||
|
||||
export interface CheckoutTiming {
|
||||
expires_at: string | null;
|
||||
expires_in_seconds: number | null;
|
||||
server_time: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class CheckoutCountdownService implements OnDestroy {
|
||||
private readonly remainingSecondsState = signal<number | null>(null);
|
||||
private deadlineMs: number | null = null;
|
||||
private intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
readonly remainingSeconds = this.remainingSecondsState.asReadonly();
|
||||
|
||||
synchronize(timing: Partial<CheckoutTiming>): void {
|
||||
const remainingSeconds = this.resolveRemainingSeconds(timing);
|
||||
|
||||
if (remainingSeconds === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (remainingSeconds === null) {
|
||||
this.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
this.stopInterval();
|
||||
this.deadlineMs = Date.now() + remainingSeconds * 1_000;
|
||||
this.updateRemainingSeconds();
|
||||
|
||||
if (remainingSeconds > 0) {
|
||||
this.intervalId = setInterval(() => this.updateRemainingSeconds(), 1_000);
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.stopInterval();
|
||||
this.deadlineMs = null;
|
||||
this.remainingSecondsState.set(null);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.clear();
|
||||
}
|
||||
|
||||
private resolveRemainingSeconds(timing: Partial<CheckoutTiming>): number | null | undefined {
|
||||
if (timing.expires_at === null && timing.expires_in_seconds === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const expiresAt =
|
||||
typeof timing.expires_at === 'string' ? Date.parse(timing.expires_at) : Number.NaN;
|
||||
const serverTime =
|
||||
typeof timing.server_time === 'string' ? Date.parse(timing.server_time) : Number.NaN;
|
||||
|
||||
if (Number.isFinite(expiresAt) && Number.isFinite(serverTime)) {
|
||||
return Math.max(0, Math.ceil((expiresAt - serverTime) / 1_000));
|
||||
}
|
||||
|
||||
if (
|
||||
typeof timing.expires_in_seconds === 'number' &&
|
||||
Number.isFinite(timing.expires_in_seconds)
|
||||
) {
|
||||
return Math.max(0, Math.ceil(timing.expires_in_seconds));
|
||||
}
|
||||
|
||||
if (Number.isFinite(expiresAt)) {
|
||||
return Math.max(0, Math.ceil((expiresAt - Date.now()) / 1_000));
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private updateRemainingSeconds(): void {
|
||||
if (this.deadlineMs === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const remainingSeconds = Math.max(0, Math.ceil((this.deadlineMs - Date.now()) / 1_000));
|
||||
this.remainingSecondsState.set(remainingSeconds);
|
||||
|
||||
if (remainingSeconds === 0) {
|
||||
this.stopInterval();
|
||||
}
|
||||
}
|
||||
|
||||
private stopInterval(): void {
|
||||
if (this.intervalId !== null) {
|
||||
clearInterval(this.intervalId);
|
||||
this.intervalId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
97
src/app/core/services/checkout.service.spec.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
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, vi } from 'vitest';
|
||||
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { CatalogAvailabilityService } from './catalog/catalog-availability.service';
|
||||
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 });
|
||||
});
|
||||
|
||||
it('refreshes catalog availability when starting checkout fails', async () => {
|
||||
const catalogAvailabilityService = TestBed.inject(CatalogAvailabilityService);
|
||||
const notifyAvailabilityChanged = vi.spyOn(
|
||||
catalogAvailabilityService,
|
||||
'notifyAvailabilityChanged',
|
||||
);
|
||||
const purchasePromise = service.startCheckout('desfile', { cart_id: 12 });
|
||||
const request = httpMock.expectOne(`${environment.url}tenants/desfile/compras/start-checkout`);
|
||||
|
||||
request.flush(
|
||||
{ message: 'No hay stock disponible.' },
|
||||
{ status: 422, statusText: 'Unprocessable Entity' },
|
||||
);
|
||||
|
||||
await expect(purchasePromise).rejects.toBeTruthy();
|
||||
expect(notifyAvailabilityChanged).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('waits for the expired cart refresh before refreshing catalog availability', async () => {
|
||||
const catalogAvailabilityService = TestBed.inject(CatalogAvailabilityService);
|
||||
const notifyAvailabilityChanged = vi.spyOn(
|
||||
catalogAvailabilityService,
|
||||
'notifyAvailabilityChanged',
|
||||
);
|
||||
const purchasePromise = service.startCheckout('desfile', { cart_id: 12 });
|
||||
const request = httpMock.expectOne(`${environment.url}tenants/desfile/compras/start-checkout`);
|
||||
|
||||
request.flush(
|
||||
{
|
||||
code: 'stock_reservation.expired',
|
||||
message: 'La reserva de stock venció.',
|
||||
},
|
||||
{ status: 422, statusText: 'Unprocessable Entity' },
|
||||
);
|
||||
|
||||
await expect(purchasePromise).rejects.toBeTruthy();
|
||||
expect(notifyAvailabilityChanged).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves checkout timing fields when completing a purchase', async () => {
|
||||
const purchasePromise = service.completePurchase('desfile', 55);
|
||||
const request = httpMock.expectOne(`${environment.url}tenants/desfile/compras/55/complete`);
|
||||
const response = {
|
||||
status: 'pending_payment',
|
||||
expires_at: '2026-08-26T20:00:00.000Z',
|
||||
expires_in_seconds: 900,
|
||||
server_time: '2026-08-26T19:45:00.000Z',
|
||||
};
|
||||
|
||||
expect(request.request.method).toBe('POST');
|
||||
request.flush({ data: response });
|
||||
|
||||
await expect(purchasePromise).resolves.toEqual(response);
|
||||
});
|
||||
});
|
||||
320
src/app/core/services/checkout.service.ts
Normal file
@@ -0,0 +1,320 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { ApiPaginatedResponse } from './api-paginated-response.interface';
|
||||
import { ApiPaginationQueryParams } from './api-pagination-query-params.interface';
|
||||
import { BaseApiService } from './base-api.service';
|
||||
import { CatalogAvailabilityService } from './catalog/catalog-availability.service';
|
||||
|
||||
export interface UpdatePurchaseCustomerPayload {
|
||||
dni: string;
|
||||
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 interface ExpiredStockReservationResponse {
|
||||
code: 'stock_reservation.expired';
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function isExpiredStockReservationResponse(
|
||||
value: unknown,
|
||||
): value is ExpiredStockReservationResponse {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
(value as Partial<ExpiredStockReservationResponse>).code === 'stock_reservation.expired'
|
||||
);
|
||||
}
|
||||
|
||||
export type StartCheckoutPayload =
|
||||
| {
|
||||
cart_id: number;
|
||||
}
|
||||
| {
|
||||
direct_items: DirectCheckoutItem[];
|
||||
};
|
||||
|
||||
export interface PurchaseStatusResponse {
|
||||
status: string | null;
|
||||
expires_at: string | null;
|
||||
expires_in_seconds: number | null;
|
||||
server_time: string;
|
||||
payment_verification?: PurchasePaymentVerificationResponse;
|
||||
}
|
||||
|
||||
export type PurchasePaymentCandidateReason =
|
||||
| 'ambiguous_exact_match'
|
||||
| 'exact_dni_near_amount'
|
||||
| 'exact_amount_near_dni';
|
||||
|
||||
export interface PurchasePaymentCandidatePrimaryResponse {
|
||||
reason: PurchasePaymentCandidateReason;
|
||||
dni_distance: number | null;
|
||||
payment_amount: string;
|
||||
purchase_amount: string;
|
||||
amount_difference: string;
|
||||
confidence: 'exact' | 'high' | 'medium';
|
||||
detected_at: string | null;
|
||||
}
|
||||
|
||||
export interface PurchasePaymentVerificationResponse {
|
||||
status: 'pending' | 'candidate';
|
||||
candidate_count: number;
|
||||
primary: PurchasePaymentCandidatePrimaryResponse | null;
|
||||
reasons: PurchasePaymentCandidateReason[];
|
||||
}
|
||||
|
||||
export interface PurchaseSummaryResponse extends PurchaseStatusResponse {
|
||||
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 PurchaseCheckoutEventResponse {
|
||||
id: number;
|
||||
title: string;
|
||||
image: string | null;
|
||||
}
|
||||
|
||||
export interface PurchaseDetailResponse extends PurchaseStatusResponse {
|
||||
id: number;
|
||||
cart_id: number | null;
|
||||
tenant_codigo: string;
|
||||
user_id: number;
|
||||
created_at: string | null;
|
||||
payment_method: string | null;
|
||||
dni: string | null;
|
||||
transfer_payer_dni: string | null;
|
||||
telefono: string | null;
|
||||
nombre_apellido: string | null;
|
||||
email: string | null;
|
||||
items_source: 'purchase';
|
||||
items: PurchaseDetailItemResponse[];
|
||||
event?: PurchaseCheckoutEventResponse | null;
|
||||
tickets_count?: number;
|
||||
has_generated_tickets?: boolean;
|
||||
subtotal: string;
|
||||
total: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class CheckoutService extends BaseApiService {
|
||||
private readonly catalogAvailabilityService = inject(CatalogAvailabilityService);
|
||||
|
||||
async startCheckout(
|
||||
tenantCode: string,
|
||||
payload: StartCheckoutPayload,
|
||||
): Promise<PurchaseDetailResponse> {
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.http.post<{ data?: PurchaseDetailResponse } | PurchaseDetailResponse>(
|
||||
`${environment.url}tenants/${tenantCode}/compras/start-checkout`,
|
||||
payload,
|
||||
),
|
||||
);
|
||||
|
||||
const purchase = this.extractResponseData<PurchaseDetailResponse>(response);
|
||||
|
||||
if (!purchase?.id) {
|
||||
throw new Error('Error al crear la compra.');
|
||||
}
|
||||
|
||||
return purchase;
|
||||
} catch (error) {
|
||||
const responseBody = (error as { error?: unknown } | null)?.error;
|
||||
if (!isExpiredStockReservationResponse(responseBody)) {
|
||||
this.catalogAvailabilityService.notifyAvailabilityChanged();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
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 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 purchase;
|
||||
}
|
||||
|
||||
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 purchase;
|
||||
}
|
||||
|
||||
async getPurchases(
|
||||
tenantCode: string,
|
||||
status?: string,
|
||||
pagination: ApiPaginationQueryParams = {},
|
||||
): Promise<ApiPaginatedResponse<PurchaseSummaryResponse[]>> {
|
||||
const params: Record<string, string | number> = {};
|
||||
|
||||
if (status) params['status'] = status;
|
||||
if (pagination.page) params['page'] = pagination.page;
|
||||
if (pagination.per_page) params['per_page'] = pagination.per_page;
|
||||
|
||||
const response = await firstValueFrom(
|
||||
this.http.get<ApiPaginatedResponse<PurchaseSummaryResponse[]>>(
|
||||
`${environment.url}tenants/${tenantCode}/compras`,
|
||||
{ params },
|
||||
),
|
||||
);
|
||||
if (!response) {
|
||||
throw new Error('Error al obtener las compras.');
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
59
src/app/core/services/event-date-notice.service.spec.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { EventDateNotice, EventDateNoticeService } from './event-date-notice.service';
|
||||
import { ApiResponse } from './api-response.interface';
|
||||
import { TenantService } from './tenant.service';
|
||||
|
||||
describe('EventDateNoticeService', () => {
|
||||
let service: EventDateNoticeService;
|
||||
let httpMock: HttpTestingController;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
EventDateNoticeService,
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting(),
|
||||
{
|
||||
provide: TenantService,
|
||||
useValue: { getTenant: () => ({ codigo: 'festival' }) },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(EventDateNoticeService);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => httpMock.verify());
|
||||
|
||||
it('claims pending notices for the active tenant', () => {
|
||||
const response = {
|
||||
data: [
|
||||
{
|
||||
type: 'suspended' as const,
|
||||
change_ids: [10],
|
||||
title: 'FECHA CANCELADA!',
|
||||
message: [{ text: 'La fecha fue cancelada.', bold: false }],
|
||||
},
|
||||
],
|
||||
};
|
||||
let result: ApiResponse<EventDateNotice[]> | undefined;
|
||||
|
||||
service.claim().subscribe((value) => (result = value));
|
||||
|
||||
const request = httpMock.expectOne(
|
||||
`${environment.url}tenants/festival/event-date-notices/claim`,
|
||||
);
|
||||
expect(request.request.method).toBe('POST');
|
||||
expect(request.request.body).toEqual({});
|
||||
|
||||
request.flush(response);
|
||||
|
||||
expect(result).toEqual(response);
|
||||
});
|
||||
});
|
||||
39
src/app/core/services/event-date-notice.service.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { ApiResponse } from './api-response.interface';
|
||||
import { BaseApiService } from './base-api.service';
|
||||
import { TenantService } from './tenant.service';
|
||||
|
||||
export type EventDateNoticeType = 'rescheduled' | 'suspended';
|
||||
|
||||
export interface EventDateNoticeSegment {
|
||||
text: string;
|
||||
bold: boolean;
|
||||
}
|
||||
|
||||
export interface EventDateNotice {
|
||||
type: EventDateNoticeType;
|
||||
change_ids: number[];
|
||||
title: string;
|
||||
message: EventDateNoticeSegment[];
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class EventDateNoticeService extends BaseApiService {
|
||||
private readonly tenantService = inject(TenantService);
|
||||
|
||||
claim(): Observable<ApiResponse<EventDateNotice[]>> {
|
||||
const tenant = this.tenantService.getTenant();
|
||||
|
||||
if (!tenant) {
|
||||
throw new Error('No se pudo resolver el tenant activo.');
|
||||
}
|
||||
|
||||
return this.withoutLoading().http.post<ApiResponse<EventDateNotice[]>>(
|
||||
`${environment.url}tenants/${tenant.codigo}/event-date-notices/claim`,
|
||||
{},
|
||||
);
|
||||
}
|
||||
}
|
||||
36
src/app/core/services/event/event.interface.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { ApiPaginatedResponse } from '../api-paginated-response.interface';
|
||||
|
||||
export interface StoreEvent {
|
||||
id: number;
|
||||
slug: string;
|
||||
event_category_id: number | null;
|
||||
title: string;
|
||||
subtitle: string | null;
|
||||
description: string | null;
|
||||
location: string | null;
|
||||
exact_location: { latitude: number; longitude: number } | null;
|
||||
date_text: string | null;
|
||||
start_time?: string | null;
|
||||
starts_at?: string | null;
|
||||
dates?: { id: number; date: string; time_start: string }[];
|
||||
social_media?: { code: string; url: string }[];
|
||||
attachment_id: number | null;
|
||||
image: string | null;
|
||||
catalog_items?: {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
price: string;
|
||||
image: string | null;
|
||||
requires_selection: boolean;
|
||||
maximum_quantity: number | null;
|
||||
variants: {
|
||||
id: number;
|
||||
price: string;
|
||||
event_date_ids: number[];
|
||||
maximum_quantity: number | null;
|
||||
}[];
|
||||
}[];
|
||||
}
|
||||
|
||||
export type StoreEventPage = ApiPaginatedResponse<StoreEvent[]>;
|
||||
24
src/app/core/services/event/event.service.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { BaseApiService } from '../base-api.service';
|
||||
import { ApiResponse } from '../api-response.interface';
|
||||
import { TenantService } from '../tenant.service';
|
||||
import { StoreEvent, StoreEventPage } from './event.interface';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class EventService extends BaseApiService {
|
||||
private readonly tenantService = inject(TenantService);
|
||||
|
||||
list(page: number, query = '', categoryId?: number): Observable<StoreEventPage> {
|
||||
return this.http.get<StoreEventPage>(`${this.tenantService.getTenantApiUrl()}/events`, {
|
||||
params: { page, ...(query ? { q: query } : {}), ...(categoryId ? { category_id: categoryId } : {}) },
|
||||
});
|
||||
}
|
||||
|
||||
get(slug: string): Observable<ApiResponse<StoreEvent>> {
|
||||
return this.http.get<ApiResponse<StoreEvent>>(
|
||||
`${this.tenantService.getTenantApiUrl()}/events/${slug}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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({});
|
||||
}
|
||||
);
|
||||
});
|
||||